comparison src/browser/richtext.py @ 449:981ed669d3b3

/!\ reorganize all the file hierarchy, move the code and launching script to src: - browser_side --> src/browser - public --> src/browser_side/public - libervia.py --> src/browser/libervia_main.py - libervia_server --> src/server - libervia_server/libervia.sh --> src/libervia.sh - twisted --> src/twisted - new module src/common - split constants.py in 3 files: - src/common/constants.py - src/browser/constants.py - src/server/constants.py - output --> html (generated by pyjsbuild during the installation) - new option/parameter "data_dir" (-d) to indicates the directory containing html and server_css - setup.py installs libervia to the following paths: - src/common --> <LIB>/libervia/common - src/server --> <LIB>/libervia/server - src/twisted --> <LIB>/twisted - html --> <SHARE>/libervia/html - server_side --> <SHARE>libervia/server_side - LIBERVIA_INSTALL environment variable takes 2 new options with prompt confirmation: - clean: remove previous installation directories - purge: remove building and previous installation directories You may need to update your sat.conf and/or launching script to update the following options/parameters: - ssl_certificate - data_dir
author souliane <souliane@mailoo.org>
date Tue, 20 May 2014 06:41:16 +0200
parents browser_side/richtext.py@bbdbee25123a
children
comparison
equal deleted inserted replaced
448:14c35f7f1ef5 449:981ed669d3b3
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Libervia: a Salut à Toi frontend
5 # Copyright (C) 2013, 2014 Adrien Cossa <souliane@mailoo.org>
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat_frontends.tools import composition
21 from sat.core.i18n import _
22
23 from pyjamas.ui.TextArea import TextArea
24 from pyjamas.ui.Button import Button
25 from pyjamas.ui.CheckBox import CheckBox
26 from pyjamas.ui.DialogBox import DialogBox
27 from pyjamas.ui.Label import Label
28 from pyjamas.ui.HTML import HTML
29 from pyjamas.ui.FlexTable import FlexTable
30 from pyjamas.ui.HorizontalPanel import HorizontalPanel
31 from pyjamas import Window
32 from pyjamas.ui.KeyboardListener import KeyboardHandler
33 from __pyjamas__ import doc
34
35 from constants import Const as C
36 from dialog import ConfirmDialog, InfoDialog
37 from base_panels import TitlePanel, BaseTextEditor, HTMLTextEditor
38 from list_manager import ListManager
39 from html_tools import html_sanitize
40 import panels
41
42
43 class RichTextEditor(BaseTextEditor, FlexTable):
44 """Panel for the rich text editor."""
45
46 def __init__(self, host, content=None, modifiedCb=None, afterEditCb=None, options=None, style=None):
47 """
48 @param host: the SatWebFrontend instance
49 @param content: dict with at least a 'text' key
50 @param modifiedCb: method to be called when the text has been modified
51 @param afterEditCb: method to be called when the edition is done
52 @param options: list of UI options (see self.readOptions)
53 """
54 self.host = host
55 self._debug = False # TODO: don't forget to set it False before commit
56 self.wysiwyg = False
57 self.__readOptions(options)
58 self.style = {'main': 'richTextEditor',
59 'title': 'richTextTitle',
60 'toolbar': 'richTextToolbar',
61 'textarea': 'richTextArea'}
62 if isinstance(style, dict):
63 self.style.update(style)
64 self._prepareUI()
65 BaseTextEditor.__init__(self, content, None, modifiedCb, afterEditCb)
66
67 def __readOptions(self, options):
68 """Set the internal flags according to the given options."""
69 if options is None:
70 options = []
71 self.read_only = 'read_only' in options
72 self.update_msg = 'update_msg' in options
73 self.no_title = 'no_title' in options or self.read_only
74 self.no_command = 'no_command' in options or self.read_only
75
76 def _prepareUI(self, y_offset=0):
77 """Prepare the UI to host title panel, toolbar, text area...
78 @param y_offset: Y offset to start from (extra rows on top)"""
79 if not self.read_only:
80 self.title_offset = y_offset
81 self.toolbar_offset = self.title_offset + (0 if self.no_title else 1)
82 self.content_offset = self.toolbar_offset + (len(composition.RICH_SYNTAXES) if self._debug else 1)
83 self.command_offset = self.content_offset + 1
84 else:
85 self.title_offset = self.toolbar_offset = self.content_offset = y_offset
86 self.command_offset = self.content_offset + 1
87 FlexTable.__init__(self, self.command_offset + (0 if self.no_command else 1), 2)
88 self.addStyleName(self.style['main'])
89
90 def addEditListener(self, listener):
91 """Add a method to be called whenever the text is edited.
92 @param listener: method taking two arguments: sender, keycode"""
93 BaseTextEditor.addEditListener(self, listener)
94 if hasattr(self, 'display'):
95 self.display.addEditListener(listener)
96
97 def refresh(self, edit=None):
98 """Refresh the UI for edition/display mode
99 @param edit: set to True to display the edition mode"""
100 if edit is None:
101 edit = hasattr(self, 'textarea') and self.textarea.getVisible()
102
103 for widget in ['title_panel', 'command']:
104 if hasattr(self, widget):
105 getattr(self, widget).setVisible(edit)
106
107 if hasattr(self, 'toolbar'):
108 self.toolbar.setVisible(False)
109 if not hasattr(self, 'display'):
110 self.display = HTMLTextEditor(options={'enhance_display': False, 'listen_keyboard': False}) # for display mode
111 for listener in self.edit_listeners:
112 self.display.addEditListener(listener)
113 if not self.read_only and not hasattr(self, 'textarea'):
114 self.textarea = EditTextArea(self) # for edition mode
115 self.textarea.addStyleName(self.style['textarea'])
116
117 self.getFlexCellFormatter().setColSpan(self.content_offset, 0, 2)
118 if edit and not self.wysiwyg:
119 self.textarea.setWidth('100%') # CSS width doesn't do it, don't know why
120 self.setWidget(self.content_offset, 0, self.textarea)
121 else:
122 self.setWidget(self.content_offset, 0, self.display)
123 if not edit:
124 return
125
126 if not self.no_title and not hasattr(self, 'title_panel'):
127 self.title_panel = TitlePanel()
128 self.title_panel.addStyleName(self.style['title'])
129 self.getFlexCellFormatter().setColSpan(self.title_offset, 0, 2)
130 self.setWidget(self.title_offset, 0, self.title_panel)
131
132 if not self.no_command and not hasattr(self, 'command'):
133 self.command = HorizontalPanel()
134 self.command.addStyleName("marginAuto")
135 self.command.add(Button("Cancel", lambda: self.edit(True, True)))
136 self.command.add(Button("Update" if self.update_msg else "Send message", lambda: self.edit(False)))
137 self.getFlexCellFormatter().setColSpan(self.command_offset, 0, 2)
138 self.setWidget(self.command_offset, 0, self.command)
139
140 def setToolBar(self, syntax):
141 """This method is called asynchronously after the parameter
142 holding the rich text syntax is retrieved. It is called at
143 each call of self.edit(True) because the user may
144 have change his setting since the last time."""
145 if syntax is None or syntax not in composition.RICH_SYNTAXES.keys():
146 syntax = composition.RICH_SYNTAXES.keys()[0]
147 if hasattr(self, "toolbar") and self.toolbar.syntax == syntax:
148 self.toolbar.setVisible(True)
149 return
150 count = 0
151 for syntax in composition.RICH_SYNTAXES.keys() if self._debug else [syntax]:
152 self.toolbar = HorizontalPanel()
153 self.toolbar.syntax = syntax
154 self.toolbar.addStyleName(self.style['toolbar'])
155 for key in composition.RICH_SYNTAXES[syntax].keys():
156 self.addToolbarButton(syntax, key)
157 self.wysiwyg_button = CheckBox(_('WYSIWYG edition'))
158 wysiywgCb = lambda sender: self.setWysiwyg(sender.getChecked())
159 self.wysiwyg_button.addClickListener(wysiywgCb)
160 self.toolbar.add(self.wysiwyg_button)
161 self.syntax_label = Label(_("Syntax: %s") % syntax)
162 self.syntax_label.addStyleName("richTextSyntaxLabel")
163 self.toolbar.add(self.syntax_label)
164 self.toolbar.setCellWidth(self.syntax_label, "100%")
165 self.getFlexCellFormatter().setColSpan(self.toolbar_offset + count, 0, 2)
166 self.setWidget(self.toolbar_offset + count, 0, self.toolbar)
167 count += 1
168
169 def setWysiwyg(self, wysiwyg, init=False):
170 """Toggle the edition mode between rich content syntax and wysiwyg.
171 @param wysiwyg: boolean value
172 @param init: set to True to re-init without switching the widgets."""
173 def setWysiwyg():
174 self.wysiwyg = wysiwyg
175 try:
176 self.wysiwyg_button.setChecked(wysiwyg)
177 except TypeError:
178 pass
179 try:
180 if wysiwyg:
181 self.syntax_label.addStyleName('transparent')
182 else:
183 self.syntax_label.removeStyleName('transparent')
184 except TypeError:
185 pass
186 if not wysiwyg:
187 self.display.removeStyleName('richTextWysiwyg')
188
189 if init:
190 setWysiwyg()
191 return
192
193 self.getFlexCellFormatter().setColSpan(self.content_offset, 0, 2)
194 if wysiwyg:
195 def syntaxConvertCb(text):
196 self.display.setContent({'text': text})
197 self.textarea.removeFromParent() # XXX: force as it is not always done...
198 self.setWidget(self.content_offset, 0, self.display)
199 self.display.addStyleName('richTextWysiwyg')
200 self.display.edit(True)
201 content = self.getContent()
202 if content['text'] and content['syntax'] != C.SYNTAX_XHTML:
203 self.host.bridge.call('syntaxConvert', syntaxConvertCb, content['text'], content['syntax'], C.SYNTAX_XHTML)
204 else:
205 syntaxConvertCb(content['text'])
206 else:
207 syntaxConvertCb = lambda text: self.textarea.setText(text)
208 text = self.display.getContent()['text']
209 if text and self.toolbar.syntax != C.SYNTAX_XHTML:
210 self.host.bridge.call('syntaxConvert', syntaxConvertCb, text)
211 else:
212 syntaxConvertCb(text)
213 self.setWidget(self.content_offset, 0, self.textarea)
214 self.textarea.setWidth('100%') # CSS width doesn't do it, don't know why
215
216 setWysiwyg() # do it in the end because it affects self.getContent
217
218 def addToolbarButton(self, syntax, key):
219 """Add a button with the defined parameters."""
220 button = Button('<img src="%s" class="richTextIcon" />' %
221 composition.RICH_BUTTONS[key]["icon"])
222 button.setTitle(composition.RICH_BUTTONS[key]["tip"])
223 button.addStyleName('richTextToolButton')
224 self.toolbar.add(button)
225
226 def buttonCb():
227 """Generic callback for a toolbar button."""
228 text = self.textarea.getText()
229 cursor_pos = self.textarea.getCursorPos()
230 selection_length = self.textarea.getSelectionLength()
231 data = composition.RICH_SYNTAXES[syntax][key]
232 if selection_length == 0:
233 middle_text = data[1]
234 else:
235 middle_text = text[cursor_pos:cursor_pos + selection_length]
236 self.textarea.setText(text[:cursor_pos]
237 + data[0]
238 + middle_text
239 + data[2]
240 + text[cursor_pos + selection_length:])
241 self.textarea.setCursorPos(cursor_pos + len(data[0]) + len(middle_text))
242 self.textarea.setFocus(True)
243 self.textarea.onKeyDown()
244
245 def wysiwygCb():
246 """Callback for a toolbar button while wysiwyg mode is enabled."""
247 data = composition.COMMANDS[key]
248
249 def execCommand(command, arg):
250 self.display.setFocus(True)
251 doc().execCommand(command, False, arg.strip() if arg else '')
252 # use Window.prompt instead of dialog.PromptDialog to not loose the focus
253 prompt = lambda command, text: execCommand(command, Window.prompt(text))
254 if isinstance(data, tuple) or isinstance(data, list):
255 if data[1]:
256 prompt(data[0], data[1])
257 else:
258 execCommand(data[0], data[2])
259 else:
260 execCommand(data, False, '')
261 self.textarea.onKeyDown()
262
263 button.addClickListener(lambda: wysiwygCb() if self.wysiwyg else buttonCb())
264
265 def getContent(self):
266 assert(hasattr(self, 'textarea'))
267 assert(hasattr(self, 'toolbar'))
268 if self.wysiwyg:
269 content = {'text': self.display.getContent()['text'], 'syntax': C.SYNTAX_XHTML}
270 else:
271 content = {'text': self.strproc(self.textarea.getText()), 'syntax': self.toolbar.syntax}
272 if hasattr(self, 'title_panel'):
273 content.update({'title': self.strproc(self.title_panel.getText())})
274 return content
275
276 def edit(self, edit=False, abort=False, sync=False):
277 """
278 Remark: the editor must be visible before you call this method.
279 @param edit: set to True to edit the content or False to only display it
280 @param abort: set to True to cancel the edition and loose the changes.
281 If edit and abort are both True, self.abortEdition can be used to ask for a
282 confirmation. When edit is False and abort is True, abortion is actually done.
283 @param sync: set to True to cancel the edition after the content has been saved somewhere else
284 """
285 if not (edit and abort):
286 self.refresh(edit) # not when we are asking for a confirmation
287 BaseTextEditor.edit(self, edit, abort, sync) # after the UI has been refreshed
288 if (edit and abort):
289 return # self.abortEdition is called by BaseTextEditor.edit
290 self.setWysiwyg(False, init=True) # after BaseTextEditor (it affects self.getContent)
291 if sync:
292 return
293 # the following must NOT be done at each UI refresh!
294 content = self._original_content
295 if edit:
296 def getParamCb(syntax):
297 # set the editable text in the current user-selected syntax
298 def syntaxConvertCb(text=None):
299 if text is not None:
300 # Important: this also update self._original_content
301 content.update({'text': text})
302 content.update({'syntax': syntax})
303 self.textarea.setText(content['text'])
304 if hasattr(self, 'title_panel') and 'title' in content:
305 self.title_panel.setText(content['title'])
306 self.title_panel.setStackVisible(0, content['title'] != '')
307 self.setToolBar(syntax)
308 if content['text'] and content['syntax'] != syntax:
309 self.host.bridge.call('syntaxConvert', syntaxConvertCb, content['text'], content['syntax'])
310 else:
311 syntaxConvertCb()
312 self.host.bridge.call('asyncGetParamA', getParamCb, composition.PARAM_NAME_SYNTAX, composition.PARAM_KEY_COMPOSITION)
313 else:
314 if not self.initialized:
315 # set the display text in XHTML only during init because a new MicroblogEntry instance is created after each modification
316 self.setDisplayContent()
317 self.display.edit(False)
318
319 def setDisplayContent(self):
320 """Set the content of the HTMLTextEditor which is used for display/wysiwyg"""
321 content = self._original_content
322 text = content['text']
323 if 'title' in content and content['title']:
324 text = '<h1>%s</h1>%s' % (html_sanitize(content['title']), content['text'])
325 self.display.setContent({'text': text})
326
327 def setFocus(self, focus):
328 self.textarea.setFocus(focus)
329
330 def abortEdition(self, content):
331 """Ask for confirmation before closing the dialog."""
332 def confirm_cb(answer):
333 if answer:
334 self.edit(False, True)
335 _dialog = ConfirmDialog(confirm_cb, text="Do you really want to %s?" % ("cancel your changes" if self.update_msg else "cancel this message"))
336 _dialog.cancel_button.setText(_("No"))
337 _dialog.show()
338
339
340 class RichMessageEditor(RichTextEditor):
341 """Use the rich text editor for sending messages with extended addressing.
342 Recipient panels are on top and data may be synchronized from/to the unibox."""
343
344 @classmethod
345 def getOrCreate(cls, host, parent=None, callback=None):
346 """Get or create the message editor associated to that host.
347 Add it to parent if parent is not None, otherwise display it
348 in a popup dialog.
349 @param host: the host
350 @param parent: parent panel (or None to display in a popup).
351 @return: the RichTextEditor instance if parent is not None,
352 otherwise a popup DialogBox containing the RichTextEditor.
353 """
354 if not hasattr(host, 'richtext'):
355 modifiedCb = lambda content: True
356
357 def afterEditCb(content):
358 if hasattr(host.richtext, 'popup'):
359 host.richtext.popup.hide()
360 else:
361 host.richtext.setVisible(False)
362 callback()
363 options = ['no_title']
364 style = {'main': 'richMessageEditor', 'textarea': 'richMessageArea'}
365 host.richtext = RichMessageEditor(host, None, modifiedCb, afterEditCb, options, style)
366
367 def add(widget, parent):
368 if widget.getParent() is not None:
369 if widget.getParent() != parent:
370 widget.removeFromParent()
371 parent.add(widget)
372 else:
373 parent.add(widget)
374 widget.setVisible(True)
375 widget.initialized = False # fake a new creation
376 widget.edit(True)
377
378 if parent is None:
379 if not hasattr(host.richtext, 'popup'):
380 host.richtext.popup = DialogBox(autoHide=False, centered=True)
381 host.richtext.popup.setHTML("Compose your message")
382 host.richtext.popup.add(host.richtext)
383 add(host.richtext, host.richtext.popup)
384 host.richtext.popup.center()
385 else:
386 add(host.richtext, parent)
387 return host.richtext.popup if parent is None else host.richtext
388
389 def _prepareUI(self, y_offset=0):
390 """Prepare the UI to host recipients panel, toolbar, text area...
391 @param y_offset: Y offset to start from (extra rows on top)"""
392 self.recipient_offset = y_offset
393 self.recipient_spacer_offset = self.recipient_offset + len(composition.RECIPIENT_TYPES)
394 RichTextEditor._prepareUI(self, self.recipient_spacer_offset + 1)
395
396 def refresh(self, edit=None):
397 """Refresh the UI between edition/display mode
398 @param edit: set to True to display the edition mode"""
399 if edit is None:
400 edit = hasattr(self, 'textarea') and self.textarea.getVisible()
401 RichTextEditor.refresh(self, edit)
402
403 for widget in ['recipient', 'recipient_spacer']:
404 if hasattr(self, widget):
405 getattr(self, widget).setVisible(edit)
406
407 if not edit:
408 return
409
410 if not hasattr(self, 'recipient'):
411 # recipient types sub-panels are automatically added by the manager
412 self.recipient = RecipientManager(self, self.recipient_offset)
413 self.recipient.createWidgets(title_format="%s: ")
414 self.recipient_spacer = HTML('')
415 self.recipient_spacer.setStyleName('recipientSpacer')
416 self.getFlexCellFormatter().setColSpan(self.recipient_spacer_offset, 0, 2)
417 self.setWidget(self.recipient_spacer_offset, 0, self.recipient_spacer)
418
419 if not hasattr(self, 'sync_button'):
420 self.sync_button = Button("Back to quick box", lambda: self.edit(True, sync=True))
421 self.command.insert(self.sync_button, 1)
422
423 def syncToEditor(self):
424 """Synchronize from unibox."""
425 def setContent(target, data):
426 if hasattr(self, 'recipient'):
427 self.recipient.setContacts({"To": [target]} if target else {})
428 self.setContent({'text': data if data else '', 'syntax': ''})
429 self.textarea.setText(data if data else '')
430 data, target = self.host.uni_box.getTargetAndData() if self.host.uni_box else (None, None)
431 setContent(target, data)
432
433 def __syncToUniBox(self, recipients=None, emptyText=False):
434 """Synchronize to unibox if a maximum of one recipient is set.
435 @return True if the sync could be done, False otherwise"""
436 if not self.host.uni_box:
437 return
438 setText = lambda: self.host.uni_box.setText("" if emptyText else self.getContent()['text'])
439 if not hasattr(self, 'recipient'):
440 setText()
441 return True
442 if recipients is None:
443 recipients = self.recipient.getContacts()
444 target = ""
445 # we could eventually allow more in the future
446 allowed = 1
447 for key in recipients:
448 count = len(recipients[key])
449 if count == 0:
450 continue
451 allowed -= count
452 if allowed < 0:
453 return False
454 # TODO: change this if later more then one recipients are allowed
455 target = recipients[key][0]
456 setText()
457 if target == "":
458 return True
459 if target.startswith("@"):
460 _class = panels.MicroblogPanel
461 target = None if target == "@@" else target[1:]
462 else:
463 _class = panels.ChatPanel
464 self.host.getOrCreateLiberviaWidget(_class, target)
465 return True
466
467 def syncFromEditor(self, content):
468 """Synchronize to unibox and close the dialog afterward. Display
469 a message and leave the dialog open if the sync was not possible."""
470 if self.__syncToUniBox():
471 self._afterEditCb(content)
472 return
473 InfoDialog("Too many recipients",
474 "A message with more than one direct recipient (To)," +
475 " or with any special recipient (Cc or Bcc), could not be" +
476 " stored in the quick box.\n\nPlease finish your composing" +
477 " in the rich text editor, and send your message directly" +
478 " from here.", Width="400px").center()
479
480 def edit(self, edit=True, abort=False, sync=False):
481 if not edit and not abort and not sync: # force sending message even when the text has not been modified
482 if not self.__sendMessage(): # message has not been sent (missing information), do nothing
483 return
484 RichTextEditor.edit(self, edit, abort, sync)
485
486 def __sendMessage(self):
487 """Send the message."""
488 recipients = self.recipient.getContacts()
489 targets = []
490 for addr in recipients:
491 for recipient in recipients[addr]:
492 if recipient.startswith("@"):
493 targets.append(("PUBLIC", None, addr) if recipient == "@@" else ("GROUP", recipient[1:], addr))
494 else:
495 targets.append(("chat", recipient, addr))
496 # check that we actually have a message target and data
497 content = self.getContent()
498 if content['text'] == "" or len(targets) == 0:
499 InfoDialog("Missing information",
500 "Some information are missing and the message hasn't been sent.", Width="400px").center()
501 return None
502 self.__syncToUniBox(recipients, emptyText=True)
503 extra = {'content_rich': content['text']}
504 if hasattr(self, 'title_panel'):
505 extra.update({'title': content['title']})
506 self.host.send(targets, content['text'], extra=extra)
507 return True
508
509
510 class RecipientManager(ListManager):
511 """A manager for sub-panels to set the recipients for each recipient type."""
512
513 def __init__(self, parent, y_offset=0):
514 # TODO: be sure we also display empty groups and disconnected contacts + their groups
515 # store the full list of potential recipients (groups and contacts)
516 list_ = []
517 list_.append("@@")
518 list_.extend("@%s" % group for group in parent.host.contact_panel.getGroups())
519 list_.extend(contact for contact in parent.host.contact_panel.getContacts())
520 ListManager.__init__(self, parent, composition.RECIPIENT_TYPES, list_, {'y': y_offset})
521
522 self.registerPopupMenuPanel(entries=composition.RECIPIENT_TYPES,
523 hide=lambda sender, key: self.__children[key]["panel"].isVisible(),
524 callback=self.setContactPanelVisible)
525
526
527 class EditTextArea(TextArea, KeyboardHandler):
528 def __init__(self, _parent):
529 TextArea.__init__(self)
530 self._parent = _parent
531 KeyboardHandler.__init__(self)
532 self.addKeyboardListener(self)
533
534 def onKeyDown(self, sender=None, keycode=None, modifiers=None):
535 for listener in self._parent.edit_listeners:
536 listener(self, keycode)