Mercurial > libervia-web
view browser_side/richtext.py @ 411:a0256b81d367
setup.py: update website (it's not http://www.salut-a-toi.org), and removed pyjamas framework which is not standard in pypi
author | Goffi <goffi@goffi.org> |
---|---|
date | Tue, 18 Mar 2014 19:18:16 +0100 |
parents | c393e7dc9ae6 |
children | 9977de10b7da |
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 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 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 from sat_frontends.tools import composition from sat.core.i18n import _ 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': 'richTextToolbaar', '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.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'] != Const.SYNTAX_XHTML: self.host.bridge.call('syntaxConvert', syntaxConvertCb, content['text'], content['syntax'], Const.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 != Const.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': Const.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)