Mercurial > libervia-web
changeset 232:0ed09cc0566f
browser_side: added UIs for rich text editor and addressing to multiple recipients
The rich text format is set according to a user parameter which is for now not created,
so you will get a warning on the backend and no toolbar will be displayed.
For testing purpose:
- you can set _debug to True in RichTextEditor: that will display one toolbar per format.
- you can add this parameter to any plugin (the same will be added later in XEP-0071):
# DEBUG: TO BE REMOVED LATER, THIS BELONGS TO RICH TEXT EDITOR
FORMATS = {"markdown": {}, "bbcode": {}, "dokuwiki": {}, "html": {}}
FORMAT_PARAM_KEY = "Composition and addressing"
FORMAT_PARAM_NAME = "Format for rich text message composition"
# In the parameter definition:
<category name="%(format_category_name)s" label="%(format_category_label)s">
<param name="%(format_param_name)s" label="%(format_param_label)s"
value="%(format_param_default)s" type="list" security="0">
%(format_options)s
</param>
</category>
# Strings for the placeholders:
'format_category_name': FORMAT_PARAM_KEY,
'format_category_label': _(FORMAT_PARAM_KEY),
'format_param_name': FORMAT_PARAM_NAME,
'format_param_label': _(FORMAT_PARAM_NAME),
'format_param_default': FORMATS.keys()[0],
'format_options': ['<option value="%s"/>' % format for format in FORMATS.keys()]
author | souliane <souliane@mailoo.org> |
---|---|
date | Tue, 08 Oct 2013 14:12:38 +0200 |
parents | fab7aa366576 |
children | 146fc6739951 |
files | browser_side/base_widget.py browser_side/panels.py browser_side/recipients.py browser_side/richtext.py public/libervia.css |
diffstat | 5 files changed, 976 insertions(+), 11 deletions(-) [+] |
line wrap: on
line diff
--- a/browser_side/base_widget.py Tue Oct 08 13:38:42 2013 +0200 +++ b/browser_side/base_widget.py Tue Oct 08 14:12:38 2013 +0200 @@ -112,6 +112,9 @@ widgets_panel.removeWidget(_new_panel) elif item_type in self.drop_keys: _new_panel = self.drop_keys[item_type](self.host, item) + elif item_type == "RECIPIENT_TEXTBOX": + # eventually open a window? + pass else: print "WARNING: unmanaged item type" return @@ -620,6 +623,9 @@ _new_panel.getWidgetsPanel().removeWidget(_new_panel) elif item_type in DropCell.drop_keys: _new_panel = DropCell.drop_keys[item_type](self.tab_panel.host, item) + elif item_type == "RECIPIENT_TEXTBOX": + # eventually open a window? + pass else: print "WARNING: unmanaged item type" return
--- a/browser_side/panels.py Tue Oct 08 13:38:42 2013 +0200 +++ b/browser_side/panels.py Tue Oct 08 14:12:38 2013 +0200 @@ -24,6 +24,7 @@ from pyjamas.ui.AbsolutePanel import AbsolutePanel from pyjamas.ui.VerticalPanel import VerticalPanel from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.DialogBox import DialogBox from pyjamas.ui.HTMLPanel import HTMLPanel from pyjamas.ui.Frame import Frame from pyjamas.ui.TextArea import TextArea @@ -45,20 +46,48 @@ from time import time import dialog import base_widget +from richtext import RichTextEditor from plugin_xep_0085 import ChatStateMachine from pyjamas import Window from __pyjamas__ import doc -class UniBoxPanel(SimplePanel): +class UniBoxPanel(HorizontalPanel): """Panel containing the UniBox""" def __init__(self, host): - SimplePanel.__init__(self) + HorizontalPanel.__init__(self) + self.host = host self.setStyleName('uniBoxPanel') + + self.button = Button ('<img src="media/icons/tango/actions/32/format-text-italic.png" class="richTextIcon"/>') + self.button.setTitle('Open the rich text editor') + self.button.addStyleName('uniBoxButton') + self.add(self.button) + self.unibox = UniBox(host) - self.unibox.setWidth('100%') self.add(self.unibox) + self.setCellWidth(self.unibox, '100%') + + self.button.addClickListener(self.openRichTextEditor) + self.unibox.addDoubleClickListener(self.openRichTextEditor) + + def openRichTextEditor(self): + """Open the rich text editor.""" + self.button.setVisible(False) + self.unibox.setVisible(False) + self.setCellWidth(self.unibox, '0px') + self.host.panel._contactsMove(self) + + def onCloseCallback(): + self.host.panel._contactsMove(self.host.panel._hpanel) + self.setCellWidth(self.unibox, '100%') + self.button.setVisible(True) + self.unibox.setVisible(True) + self.host.resize() + + RichTextEditor.getOrCreate(self.host, self, onCloseCallback) + self.host.resize() class UniBox(TextArea, MouseHandler): #AutoCompleteTextBox): @@ -848,12 +877,12 @@ status = host.status_panel # contacts - _contacts = HorizontalPanel() - _contacts.addStyleName('globalLeftArea') + self._contacts = HorizontalPanel() + self._contacts.addStyleName('globalLeftArea') contacts_switch = Button(u'«', self._contactsSwitch) contacts_switch.addStyleName('contactsSwitch') - _contacts.add(contacts_switch) - _contacts.add(self.host.contact_panel) + self._contacts.add(contacts_switch) + self._contacts.add(self.host.contact_panel) # tabs self.tab_panel = base_widget.MainTabPanel(host) @@ -868,10 +897,10 @@ header.setStyleName('header') self.add(header) - _hpanel = HorizontalPanel() - _hpanel.add(_contacts) - _hpanel.add(self.tab_panel) - self.add(_hpanel) + self._hpanel = HorizontalPanel() + self._hpanel.add(self._contacts) + self._hpanel.add(self.tab_panel) + self.add(self._hpanel) self.setWidth("100%") Window.addWindowResizeListener(self) @@ -883,6 +912,17 @@ btn.setText(u"«" if cpanel.getVisible() else u"»") self.host.resize() + def _contactsMove(self, parent): + """Move the contacts container (containing the contact list and + the "hide/show" button) to another parent, but always as the + first child position (insert at index 0). + """ + if self._contacts.getParent(): + if self._contacts.getParent() == parent: + return + self._contacts.removeFromParent() + parent.insert(self._contacts, 0) + def onWindowResized(self, width, height): _elts = doc().getElementsByClassName('gwt-TabBar') if not _elts.length:
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser_side/recipients.py Tue Oct 08 14:12:38 2013 +0200 @@ -0,0 +1,492 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +""" +Libervia: a Salut à Toi frontend +Copyright (C) 2013 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 pyjamas.ui.Grid import Grid +from pyjamas.ui.Button import Button +from pyjamas.ui.ListBox import ListBox +from pyjamas.ui.FlowPanel import FlowPanel +from pyjamas.ui.PopupPanel import PopupPanel +from pyjamas.ui.AutoComplete import AutoCompleteTextBox +from pyjamas.ui.Label import Label +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.DialogBox import DialogBox +from pyjamas.ui.KeyboardListener import KEY_ENTER, KeyboardHandler +from pyjamas.ui.MouseListener import MouseHandler +from pyjamas.ui.FocusListener import FocusHandler +from pyjamas.ui.DropWidget import DropWidget +from pyjamas.ui.DragWidget import DragWidget +from pyjamas.Timer import Timer +from pyjamas import DOM + +# Map the recipient types to their properties. For convenience, the key +# value is copied during the initialization to its associated sub-map, +# stored in the value of a new entry which uses "key" as its key. +RECIPIENT_TYPES = {"To": {"desc": "Direct recipients", "optional": False}, + "Cc": {"desc": "Carbon copies", "optional": True}, + "Bcc": {"desc": "Blind carbon copies", "optional": True}} + +# HTML content for the removal button (image or text) +REMOVE_BUTTON = '<span class="richTextRemoveIcon">x</span>' + +# Item to be considered for an empty list box selection. +# Could be whatever which doesn't look like a JID or a group name. +EMPTY_SELECTION_ITEM = "" + + +class RecipientManager(): + """A manager for sub-panels to set the recipients for each recipient type.""" + + def __init__(self, parent): + """""" + self._parent = parent + self.host = parent.host + + self.__list = [] + # TODO: be sure we also display empty groups and disconnected contacts + their groups + # store the full list of potential recipients (groups and contacts) + self.__list.extend("@%s" % group for group in self.host.contact_panel.getGroups()) + self.__list.extend(contact for contact in self.host.contact_panel.getContacts()) + self.__list.sort() + # store the list of recipients that are not selected yet + self.__remaining_list = [] + self.__remaining_list.extend(self.__list) + # mark a change to sort the list before it's used + self.__remaining_list_sorted = True + + def createWidgets(self): + """Fill the parent grid with all the widgets but + only show those for non optional recipient types.""" + self.__children = {} + for key in RECIPIENT_TYPES: + # copy the key to its associated sub-map + RECIPIENT_TYPES[key]["key"] = key + self._addChild(RECIPIENT_TYPES[key]) + + def _addChild(self, entry): + """Add a button and FlowPanel for the corresponding map entry.""" + button = Button("%s: " % entry["key"], self.selectRecipientType) + button.addStyleName("recipientTypeItem") + button.setTitle(entry["desc"]) + button.setVisible(not entry["optional"]) + self._parent.setWidget(len(self.__children), 0, button) + self._parent.getCellFormatter().setStyleName(len(self.__children), 0, "recipientButtonCell") + + _child = RecipientTypePanel(self, entry) + self._parent.setWidget(len(self.__children), 1, _child) + + self.__children[entry["key"]] = {} + self.__children[entry["key"]]["button"] = button + self.__children[entry["key"]]["panel"] = _child + + def _refresh(self): + """Set visible the sub-panels that are non optional or non empty, hide the rest.""" + for key in self.__children: + self.setRecipientPanelVisible(key, False) + _map = self.getRecipients() + for key in _map: + if len(_map[key]) > 0 or not RECIPIENT_TYPES[key]["optional"]: + self.setRecipientPanelVisible(key, True) + + def setRecipientPanelVisible(self, key, visible=True): + self.__children[key]["button"].setVisible(visible) + self.__children[key]["panel"].setVisible(visible) + + def getList(self): + """Return the full list of potential recipients.""" + return self.__list + + def getRemainingList(self): + """Return the recipients that have not been selected yet.""" + if not self.__remaining_list_sorted: + self.__remaining_list_sorted = True + self.__remaining_list.sort() + return self.__remaining_list + + def setRemainingListUnsorted(self): + """Mark a change (deletion) so the list will be sorted before it's used.""" + self.__remaining_list_sorted = False + + def removeFromRemainingList(self, recipient): + """Remove an available recipient after it has been added to a sub-panel.""" + if recipient in self.__remaining_list: + self.__remaining_list.remove(recipient) + + def addToRemainingList(self, recipient): + """Add a recipient after it has been removed from a sub-panel.""" + self.__remaining_list.append(recipient) + self.__sort_remaining_list = True + + def selectRecipients(self): + """Display the recipients chooser dialog. This has been implemented while + prototyping and is currently not used. Left for an eventual later use. + Replaced by self.selectRecipientType. + """ + RecipientChooserPanel(self) + + def selectRecipientType(self, sender): + """Display a context menu to add a new recipient type.""" + self.context_menu = VerticalPanel() + self.context_menu.setStyleName("recipientTypeMenu") + popup = PopupPanel(autoHide=True) + + for key in RECIPIENT_TYPES: + if self.__children[key]["panel"].isVisible(): + continue + + def showPanel(sender): + self.setRecipientPanelVisible(sender.getText()) + popup.hide(autoClosed=True) + + item = Button(key, showPanel) + item.setStyleName("recipientTypeItem") + item.setTitle(RECIPIENT_TYPES[key]["desc"]) + self.context_menu.add(item) + + popup.add(self.context_menu) + popup.setPopupPosition(sender.getAbsoluteLeft() + sender.getOffsetWidth(), sender.getAbsoluteTop()) + popup.show() + + def setRecipients(self, _map={}): + """Set the recipients for each recipient types.""" + for key in RECIPIENT_TYPES: + if key in _map: + self.__children[key]["panel"].setRecipients(_map[key]) + else: + self.__children[key]["panel"].setRecipients([]) + self._refresh() + + def getRecipients(self): + """Get the recipients for all the recipient types. + @return: a mapping between keys from RECIPIENT_TYPES.keys() and recipient arrays.""" + _map = {} + for key in self.__children: + _map[key] = self.__children[key]["panel"].getRecipients() + return _map + + def setTargetDropCell(self, panel): + """Used to drap and drop the recipients from one panel to another.""" + self._target_drop_cell = panel + + def getTargetDropCell(self): + """Used to drap and drop the recipients from one panel to another.""" + return self._target_drop_cell + + +class DragAutoCompleteTextBox(AutoCompleteTextBox, DragWidget): + """A draggable AutoCompleteTextBox which is used for representing a recipient. + This class is NOT generic because of the onDragEnd method which call methods + from RecipientTypePanel. It's probably not reusable for another scenario. + """ + + def __init__(self): + AutoCompleteTextBox.__init__(self) + DragWidget.__init__(self) + + def onDragStart(self, event): + dt = event.dataTransfer + # The group prefix "@" is already in text so we use only the "CONTACT" type + dt.setData('text/plain', "%s\n%s" % (self.getText(), "RECIPIENT_TEXTBOX")) + + def onDragEnd(self, event): + if self.getText() == "": + return + # get the RecipientTypePanel containing self + parent = self.getParent() + while parent is not None and not isinstance(parent, RecipientTypePanel): + parent = parent.getParent() + if parent is None: + return + # it will return parent again or another RecipientTypePanel + target = parent.getTargetDropCell() + if target == parent: + return + target.addRecipient(self.getText()) + if hasattr(self, "remove_btn"): + # self is not the last textbox, just remove it + self.remove_btn.click() + else: + # reset the value of the last textbox + self.setText("") + + +class DropCell(DropWidget): + """A cell where you can drop widgets. This class is NOT generic because of + onDrop which uses methods from RecipientTypePanel. It has been created to + separate the drag and drop methods from the others and add a bit of + lisibility, but it's probably not reusable for another scenario. + """ + + def __init__(self, host): + DropWidget.__init__(self) + + def onDragEnter(self, event): + self.addStyleName('dragover-recipientPanel') + DOM.eventPreventDefault(event) + + def onDragLeave(self, event): + if event.clientX <= self.getAbsoluteLeft() or event.clientY <= self.getAbsoluteTop()\ + or event.clientX >= self.getAbsoluteLeft() + self.getOffsetWidth() - 1\ + or event.clientY >= self.getAbsoluteTop() + self.getOffsetHeight() - 1: + # We check that we are inside widget's box, and we don't remove the style in this case because + # if the mouse is over a widget inside the DropWidget, we don't want the style to be removed + self.removeStyleName('dragover-recipientPanel') + + def onDragOver(self, event): + DOM.eventPreventDefault(event) + + def onDrop(self, event): + DOM.eventPreventDefault(event) + dt = event.dataTransfer + # 'text', 'text/plain', and 'Text' are equivalent. + item, item_type = dt.getData("text/plain").split('\n') # Workaround for webkit, only text/plain seems to be managed + if item_type and item_type[-1] == '\0': # Workaround for what looks like a pyjamas bug: the \0 should not be there, and + item_type = item_type[:-1] # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report + if item_type == "GROUP": + item = "@%s" % item + self.addRecipient(item) + elif item_type == "CONTACT": + self.addRecipient(item) + elif item_type == "RECIPIENT_TEXTBOX": + self._parent.setTargetDropCell(self) + pass + else: + return + self.removeStyleName('dragover-recipientPanel') + + +class RecipientTypePanel(FlowPanel, KeyboardHandler, MouseHandler, FocusHandler, DropCell): + """Sub-panel used for each recipient type. Beware that pyjamas.ui.FlowPanel + is not fully implemented yet and can not be used with pyjamas.ui.Label.""" + + def __init__(self, parent, entry): + """Initialization with a button and a DragAutoCompleteTextBox.""" + FlowPanel.__init__(self, Visible=(False if entry["optional"] else True)) + DropCell.__init__(self) + self.addStyleName("recipientPanel") + self._parent = parent + self.host = parent.host + + self._last_textbox = None + self.__remove_cbs = [] + + self.__resetLastTextBox() + + def __resetLastTextBox(self, setFocus=True): + """Reset the last input text box with KeyboardListener.""" + if self._last_textbox is None: + self._last_textbox = DragAutoCompleteTextBox() + self._last_textbox.addStyleName("recipientTextBox") + self._last_textbox.addKeyboardListener(self) + self._last_textbox.addFocusListener(self) + else: + # ensure we move it to the last position + self.remove(self._last_textbox) + self._last_textbox.setText("") + self.add(self._last_textbox) + self._last_textbox.setFocus(setFocus) + + def onKeyUp(self, sender, keycode, modifiers): + """This is called after DragAutoCompleteTextBox.onKeyDown, + so the completion is done before we reset the text box.""" + if not isinstance(sender, DragAutoCompleteTextBox): + return + if keycode == KEY_ENTER: + self.onLostFocus(sender) + self._last_textbox.setFocus(True) + + def onFocus(self, sender): + """A DragAutoCompleteTextBox has the focus.""" + if not isinstance(sender, DragAutoCompleteTextBox): + return + if sender != self._last_textbox: + # save the current value before it's being modified + text = sender.getText() + self._focused_textbox_previous_value = text + self._parent.addToRemainingList(text) + sender.setCompletionItems(self._parent.getRemainingList()) + + def onLostFocus(self, sender): + """A DragAutoCompleteTextBox has lost the focus.""" + if not isinstance(sender, DragAutoCompleteTextBox): + return + self.changeRecipient(sender) + + def changeRecipient(self, sender): + """Modify the value of a DragAutoCompleteTextBox.""" + text = sender.getText() + if sender == self._last_textbox: + if text != "": + # a new box is added and the last textbox is reinitialized + self.addRecipient(text, setFocusToLastTextBox=False) + return + if text == "": + sender.remove_btn.click() + return + # text = new value needs to be removed 1. if the value is unchanged, because we + # added it when we took the focus, or 2. if the value is changed (obvious!) + self._parent.removeFromRemainingList(text) + if text == self._focused_textbox_previous_value: + return + sender.setVisibleLength(len(text)) + self._parent.addToRemainingList(self._focused_textbox_previous_value) + + def addRecipient(self, recipient, resetLastTextBox=True, setFocusToLastTextBox=True): + """Add a recipient and signal it to self._parent panel.""" + if recipient is None or recipient == "": + return + textbox = DragAutoCompleteTextBox() + textbox.addStyleName("recipientTextBox") + textbox.setText(recipient) + self.add(textbox) + try: + textbox.setVisibleLength(len(recipient)) + except: + #TODO: . how come could this happen?! len(recipient) is sometimes 0 but recipient is not empty + print "len(recipient) returns %d where recipient == %s..." % (len(recipient), recipient) + self._parent.removeFromRemainingList(recipient) + + remove_btn = Button(REMOVE_BUTTON, Visible=False) + remove_btn.addStyleName("recipientRemoveButton") + + def remove_cb(sender): + """Callback for the button to remove this recipient.""" + self.remove(textbox) + self.remove(remove_btn) + self._parent.addToRemainingList(recipient) + self._parent.setRemainingListUnsorted() + + remove_btn.addClickListener(remove_cb) + self.__remove_cbs.append(remove_cb) + self.add(remove_btn) + self.__resetLastTextBox(setFocus=setFocusToLastTextBox) + + textbox.remove_btn = remove_btn + textbox.addMouseListener(self) + textbox.addFocusListener(self) + textbox.addKeyboardListener(self) + + def emptyRecipients(self): + """Empty the list of recipients.""" + for remove_cb in self.__remove_cbs: + remove_cb() + self.__remove_cbs = [] + + def onMouseMove(self, sender): + """Mouse enters the area of a DragAutoCompleteTextBox.""" + if hasattr(sender, "remove_btn"): + sender.remove_btn.setVisible(True) + + def onMouseLeave(self, sender): + """Mouse leaves the area of a DragAutoCompleteTextBox.""" + if hasattr(sender, "remove_btn"): + Timer(1500, lambda: sender.remove_btn.setVisible(False)) + + def setRecipients(self, tab): + """Set the recipients.""" + self.emptyRecipients() + for recipient in tab: + self.addRecipient(recipient, resetLastTextBox=False) + self.__resetLastTextBox() + + def getRecipients(self): + """Get the recipients + @return: an array of string""" + tab = [] + for widget in self.getChildren(): + if isinstance(widget, DragAutoCompleteTextBox): + # not to be mixed with EMPTY_SELECTION_ITEM + if widget.getText() != "": + tab.append(widget.getText()) + return tab + + def getTargetDropCell(self): + """Returns self or another panel where something has been dropped.""" + return self._parent.getTargetDropCell() + + +class RecipientChooserPanel(DialogBox): + """Display the recipients chooser dialog. This has been implemented while + prototyping and is currently not used. Left for an eventual later use. + Replaced by the popup menu which allows to add a panel for Cc or Bcc. + """ + + def __init__(self, manager, **kwargs): + """Display a listbox for each recipient type""" + DialogBox.__init__(self, autoHide=False, centered=True, **kwargs) + self.setHTML("Select recipients") + self.manager = manager + self.listboxes = {} + self.recipients = manager.getRecipients() + + container = VerticalPanel(Visible=True) + container.addStyleName("marginAuto") + + grid = Grid(2, len(RECIPIENT_TYPES)) + index = -1 + for key in RECIPIENT_TYPES: + index += 1 + grid.add(Label("%s:" % RECIPIENT_TYPES[key]["desc"]), 0, index) + listbox = ListBox() + listbox.setMultipleSelect(True) + listbox.setVisibleItemCount(15) + listbox.addItem(EMPTY_SELECTION_ITEM) + for element in manager.getList(): + listbox.addItem(element) + self.listboxes[key] = listbox + grid.add(listbox, 1, index) + self._reset() + + buttons = HorizontalPanel() + buttons.addStyleName("marginAuto") + btn_close = Button("Cancel", self.hide) + buttons.add(btn_close) + btn_reset = Button("Reset", self._reset) + buttons.add(btn_reset) + btn_ok = Button("OK", self._validate) + buttons.add(btn_ok) + + container.add(grid) + container.add(buttons) + + self.add(container) + self.center() + + def _reset(self): + """Reset the selections.""" + for key in RECIPIENT_TYPES: + listbox = self.listboxes[key] + for i in xrange(0, listbox.getItemCount()): + if listbox.getItemText(i) in self.recipients[key]: + listbox.setItemSelected(i, "selected") + else: + listbox.setItemSelected(i, "") + + def _validate(self): + """Sets back the selected recipients to the good sub-panels.""" + _map = {} + for key in RECIPIENT_TYPES: + selections = self.listboxes[key].getSelectedItemText() + if EMPTY_SELECTION_ITEM in selections: + selections.remove(EMPTY_SELECTION_ITEM) + _map[key] = selections + self.manager.setRecipients(_map) + self.hide()
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/browser_side/richtext.py Tue Oct 08 14:12:38 2013 +0200 @@ -0,0 +1,310 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +""" +Libervia: a Salut à Toi frontend +Copyright (C) 2013 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 dialog import ConfirmDialog +from pyjamas.ui.TextArea import TextArea +from pyjamas.ui.Button import Button +from dialog import InfoDialog +from pyjamas.ui.DialogBox import DialogBox +from pyjamas.ui.Label import Label +from pyjamas.ui.FlexTable import FlexTable +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from recipients import RECIPIENT_TYPES, RecipientManager + +BUTTONS = { + "bold": {"tip": "Bold", "icon": "media/icons/dokuwiki/toolbar/16/bold.png"}, + "italic": {"tip": "Italic", "icon": "media/icons/dokuwiki/toolbar/16/italic.png"}, + "underline": {"tip": "Underline", "icon": "media/icons/dokuwiki/toolbar/16/underline.png"}, + "code": {"tip": "Code", "icon": "media/icons/dokuwiki/toolbar/16/mono.png"}, + "strikethrough": {"tip": "Strikethrough", "icon": "media/icons/dokuwiki/toolbar/16/strike.png"}, + "heading": {"tip": "Heading", "icon": "media/icons/dokuwiki/toolbar/16/hequal.png"}, + "numberedlist": {"tip": "Numbered List", "icon": "media/icons/dokuwiki/toolbar/16/ol.png"}, + "list": {"tip": "List", "icon": "media/icons/dokuwiki/toolbar/16/ul.png"}, + "link": {"tip": "Link", "icon": "media/icons/dokuwiki/toolbar/16/linkextern.png"}, + "horizontalrule": {"tip": "Horizontal rule", "icon": "media/icons/dokuwiki/toolbar/16/hr.png"} + } + +# Define here your formats, the key must match the ones used in button. +# Tupples values must have 3 elements : prefix to the selection or cursor +# position, sample text to write if the marker is not applied on a selection, +# suffix to the selection or cursor position. +FORMATS = {"markdown": {"bold": ("**", "bold", "**"), + "italic": ("*", "italic", "*"), + "code": ("`", "code", "`"), + "heading": ("\n# ", "Heading 1", "\n## Heading 2\n"), + "list": ("\n* ", "item", "\n + subitem\n"), + "link": ("[desc](", "link", ")"), + "horizontalrule": ("\n***\n", "", "") + }, + "bbcode": {"bold": ("[b]", "bold", "[/b]"), + "italic": ("[i]", "italic", "[/i]"), + "underline": ("[u]", "underline", "[/u]"), + "strikethrough": ("[s]", "strikethrough", "[/s]"), + "code": ("[code]", "code", "[/code]"), + "link": ("[url=", "link", "]desc[/url]"), + "list": ("\n[list] [*]", "item 1", " [*]item 2 [/list]\n") + }, + "dokuwiki": {"bold": ("**", "bold", "**"), + "italic": ("//", "italic", "//"), + "underline": ("__", "underline", "__"), + "strikethrough": ("<del>", "strikethrough", "</del>"), + "code": ("<code>", "code", "</code>"), + "heading": ("\n==== ", "Heading 1", " ====\n=== Heading 2 ===\n"), + "link": ("[[", "link", "|desc]]"), + "list": ("\n * ", "item\n", "\n * subitem\n"), + "horizontalrule": ("\n----\n", "", "") + }, + "html": {"bold": ("<b>", "bold", "</b>"), + "italic": ("<i>", "italic", "</i>"), + "underline": ("<u>", "underline", "</u>"), + "strikethrough": ("<s>", "strikethrough", "</s>"), + "code": ("<pre>", "code", "</pre>"), + "heading": ("\n<h3>", "Heading 1", "</h3>\n<h4>Heading 2</h4>\n"), + "link": ("<a href=\"", "link", "\">desc</a>"), + "list": ("\n<ul><li>", "item 1", "</li><li>item 2</li></ul>\n"), + "horizontalrule": ("\n<hr/>\n", "", "") + } + + } + +FORMAT_PARAM_KEY = "Composition and addressing" +FORMAT_PARAM_NAME = "Format for rich text message composition" + + +class RichTextEditor(FlexTable): + """Panel for the rich text editor.""" + + def __init__(self, host, parent=None, onCloseCallback=None): + """Fill the editor with recipients panel, toolbar, text area...""" + + # TODO: don't forget to comment this before commit + self._debug = False + + # This must be done before FlexTable.__init__ because it is used by setVisible + self.host = host + + offset1 = len(RECIPIENT_TYPES) + offset2 = len(FORMATS) if self._debug else 1 + FlexTable.__init__(self, offset1 + offset2 + 2, 2) + self.addStyleName('richTextEditor') + + self._parent = parent + self._on_close_callback = onCloseCallback + + # recipient types sub-panels are automatically added by the manager + self.recipient = RecipientManager(self) + self.recipient.createWidgets() + + # Rich text tool bar is automatically added by setVisible + + self.textarea = TextArea() + self.textarea.addStyleName('richTextArea') + + self.command = HorizontalPanel() + self.command.addStyleName("marginAuto") + self.command.add(Button("Cancel", listener=self.cancelWithoutSaving)) + self.command.add(Button("Back to quick box", listener=self.closeAndSave)) + self.command.add(Button("Send message", listener=self.sendMessage)) + + self.getFlexCellFormatter().setColSpan(offset1 + offset2, 0, 2) + self.getFlexCellFormatter().setColSpan(offset1 + offset2 + 1, 0, 2) + self.setWidget(offset1 + offset2, 0, self.textarea) + self.setWidget(offset1 + offset2 + 1, 0, self.command) + + @classmethod + def getOrCreate(cls, host, parent=None, onCloseCallback=None): + """Get or create the richtext editor associated to that host. + Add it to parent if parent is not None, otherwise display it + in a popup dialog. Information are saved for later the widget + to be also automatically removed from its parent, or the + popup to be closed. + @param host: the host + @popup parent: parent panel (in a popup if parent == None) . + @return: the RichTextEditor instance if popup is False, otherwise + a popup DialogBox containing the RichTextEditor. + """ + if not hasattr(host, 'richtext'): + host.richtext = RichTextEditor(host, parent, onCloseCallback) + + 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) + + 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) + host.richtext.syncFromUniBox() + return host.richtext.popup if parent is None else host.richtext + + def setVisible(self, kwargs): + """Called each time the widget is displayed, after creation or after having been hidden.""" + self.host.bridge.call('asyncGetParamA', self.setToolBar, FORMAT_PARAM_NAME, FORMAT_PARAM_KEY) + FlexTable.setVisible(self, kwargs) + + def __close(self): + """Remove the widget from parent or close the popup.""" + if self._parent is None: + self.popup.hide() + else: + self.setVisible(False) + if self._on_close_callback is not None: + self._on_close_callback() + + def setToolBar(self, _format): + """This method is called asynchronously after the parameter + holding the rich text format is retrieved. It is called at + each opening of the rich text editor because the user may + have change his setting since the last time.""" + if _format is None or _format not in FORMATS.keys(): + _format = FORMATS.keys()[0] + if hasattr(self, "_format") and self._format == _format: + return + self._format = _format + offset1 = len(RECIPIENT_TYPES) + count = 0 + for _format in FORMATS.keys() if self._debug else [self._format]: + toolbar = HorizontalPanel() + toolbar.addStyleName('richTextToolbar') + for key in FORMATS[_format].keys(): + self.addToolbarButton(toolbar, _format, key) + label = Label("Format: %s" % _format) + label.addStyleName("richTextFormatLabel") + toolbar.add(label) + self.getFlexCellFormatter().setColSpan(offset1 + count, 0, 2) + self.setWidget(offset1 + count, 0, toolbar) + count += 1 + + def addToolbarButton(self, toolbar, _format, key): + """Add a button with the defined parameters.""" + button = Button('<img src="%s" class="richTextIcon" />' % + BUTTONS[key]["icon"]) + button.setTitle(BUTTONS[key]["tip"]) + button.addStyleName('richTextToolButton') + toolbar.add(button) + + def button_callback(): + """Generic callback for a toolbar button.""" + text = self.textarea.getText() + cursor_pos = self.textarea.getCursorPos() + selection_length = self.textarea.getSelectionLength() + infos = FORMATS[_format][key] + if selection_length == 0: + middle_text = infos[1] + else: + middle_text = text[cursor_pos:cursor_pos + selection_length] + self.textarea.setText(text[:cursor_pos] + + infos[0] + + middle_text + + infos[2] + + text[cursor_pos + selection_length:]) + self.textarea.setCursorPos(cursor_pos + len(infos[0]) + len(middle_text)) + self.textarea.setFocus(True) + + button.addClickListener(button_callback) + + def syncFromUniBox(self): + """Synchronize from unibox.""" + data, target = self.host.uni_box.getTargetAndData() + self.recipient.setRecipients({"To": [target]} if target else {}) + self.textarea.setText(data if data else "") + + def syncToUniBox(self, recipients=None): + """Synchronize to unibox if a maximum of one recipient is set, + and it is not set to for optional recipient type. That means + synchronization is not done if more then one recipients are set + or if a recipient is set to an optional type (Cc, Bcc). + @return True if the sync could be done, False otherwise""" + if recipients is None: + recipients = self.recipient.getRecipients() + 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 or RECIPIENT_TYPES[key]["optional"]: + return False + # TODO: change this if later more then one recipients are allowed + target = recipients[key][0] + self.host.uni_box.setText(self.textarea.getText()) + from panels import ChatPanel, MicroblogPanel + if target == "": + return True + if target.startswith("@"): + _class = MicroblogPanel + target = None if target == "@@" else target[1:] + else: + _class = ChatPanel + self.host.getOrCreateLiberviaWidget(_class, target) + return True + + def cancelWithoutSaving(self): + """Ask for confirmation before closing the dialog.""" + def confirm_cb(answer): + if answer: + self.__close() + + _dialog = ConfirmDialog(confirm_cb, text="Do you really want to cancel this message?") + _dialog.show() + + def closeAndSave(self): + """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.__close() + 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 sendMessage(self): + """Send the message.""" + recipients = self.recipient.getRecipients() + if self.syncToUniBox(recipients): + # also check that we actually have a message target and data + if len(recipients["To"]) > 0 and self.textarea.getText() != "": + from pyjamas.ui.KeyboardListener import KEY_ENTER + self.host.uni_box.onKeyPress(self.host.uni_box, KEY_ENTER, None) + self.__close() + else: + InfoDialog("Missing information", + "Some information are missing and the message hasn't been sent," + + " but it has been stored to the quick box instead.", Width="400px").center() + return + InfoDialog("Feature in development", + "Sending a message to more the one recipient," + + " to Cc or Bcc is not implemented yet!", Width="400px").center()
--- a/public/libervia.css Tue Oct 08 13:38:42 2013 +0200 +++ b/public/libervia.css Tue Oct 08 14:12:38 2013 +0200 @@ -377,6 +377,14 @@ div.contactBox { width: 100%; + /* We want the contact panel to not use all the available height when displayed + in the unibox panel (grey part), because the dialogs panels (white part) should + still be visible. The setting max-height: fit-content would be appropriate here + but it doesn't work with firefox 24.0. TODO: check if the current setting works + with other browsers... the panel should of course not be displayed on 100px + but exactly fit the contacts box. + */ + max-height: 100px; } .contactTitle { @@ -472,6 +480,7 @@ } .uniBox { + width: 100%; height: 45px; padding: 5px; border: 1px solid #bbb; @@ -482,6 +491,11 @@ -moz-box-shadow:inset 0 0 10px #ddd; } +.uniBoxButton { + width:30px; + height:45px; +} + .statusPanel { margin: auto; text-align: center; @@ -1126,3 +1140,106 @@ a:hover.url { text-decoration: underline } + +/* Rich Text Editor */ + +.richTextEditor { + width: 600px; + max-width:600px; + min-width: 600px; + margin-top: 9px; + margin-left:18px; +} + +.richTextToolbar { + margin: 15px auto auto 0px; +} + +.richTextFormatLabel { + text-align: right; + margin: 14px 0px 0px 14px; + font-size: 12px; +} + +.richTextArea { + width:100%; + height:250px; +} + +.richTextToolButton { + cursor: pointer; + width:26px; + height:26px; + vertical-align: middle; + margin: 2px 1px; + border-radius: 5px 5px 5px 5px; + -webkit-border-radius: 5px 5px 5px 5px; + -moz-border-radius: 5px 5px 5px 5px; + box-shadow: 0px 1px 4px #000; + -webkit-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); + -moz-box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); + border: none; + -webkit-transition: color 0.2s linear; + -moz-transition: color 0.2s linear; + -o-transition: color 0.2s linear; +} + +.richTextIcon { + width:16px; + height:16px; + vertical-align: middle; +} + +/* Recipients panel */ + +.recipientButtonCell { + width:55px; +} + +.recipientTypeMenu { +} + +.recipientTypeItem { + cursor: pointer; + border-radius: 5px; + width: 50px; +} + +.recipientPanel { +} + +.recipientTextBox { + cursor: pointer; + width: auto; + border-radius: 5px 5px 5px 5px; + -webkit-border-radius: 5px 5px 5px 5px; + -moz-border-radius: 5px 5px 5px 5px; + box-shadow: inset 0px 1px 4px rgba(135, 179, 255, 0.6); + -webkit-box-shadow:inset 0 1px 4px rgba(135, 179, 255, 0.6); + -moz-box-shadow:inset 0 1px 4px rgba(135, 179, 255, 0.6); + padding: 2px 1px; + margin: 0px; + color: #444; + font-size: 1em; +} + +.recipientRemoveButton { + margin: 0px 10px 0px 0px; + padding: 0px; + border: 1px dashed red; + border-radius: 5px 5px 5px 5px; +} + +.richTextRemoveIcon { + color: red; + width:15px; + height:15px; + vertical-align: baseline; +} + +.dragover-recipientPanel { + border-radius: 5px; + background: none repeat scroll 0% 0% rgb(135, 179, 255); + border: 1px dashed rgb(35,79,255); +} +