comparison browser/sat_browser/richtext.py @ 1124:28e3eb3bb217

files reorganisation and installation rework: - files have been reorganised to follow other SàT projects and usual Python organisation (no more "/src" directory) - VERSION file is now used, as for other SàT projects - replace the overcomplicated setup.py be a more sane one. Pyjamas part is not compiled anymore by setup.py, it must be done separatly - removed check for data_dir if it's empty - installation tested working in virtual env - libervia launching script is now in bin/libervia
author Goffi <goffi@goffi.org>
date Sat, 25 Aug 2018 17:59:48 +0200
parents src/browser/sat_browser/richtext.py@2d15b484ca33
children 3048bd137aaf
comparison
equal deleted inserted replaced
1123:63a4b8fe9782 1124:28e3eb3bb217
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Libervia: a Salut à Toi frontend
5 # Copyright (C) 2013-2016 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 from sat.core.log import getLogger
23 log = getLogger(__name__)
24 from sat.tools.common import data_format
25
26 from pyjamas.ui.TextArea import TextArea
27 from pyjamas.ui.Button import Button
28 from pyjamas.ui.CheckBox import CheckBox
29 from pyjamas.ui.Label import Label
30 from pyjamas.ui.FlexTable import FlexTable
31 from pyjamas.ui.HorizontalPanel import HorizontalPanel
32 from pyjamas.ui.KeyboardListener import KeyboardHandler
33 from pyjamas import Window
34 from __pyjamas__ import doc
35
36 from constants import Const as C
37 import dialog
38 import base_panel
39 import editor_widget
40 import html_tools
41 import list_manager
42
43
44 class RichTextEditor(editor_widget.BaseTextEditor, FlexTable):
45 """Panel for the rich text editor."""
46
47 STYLE = {'main': 'richTextEditor',
48 'title': 'richTextTitle',
49 'toolbar': 'richTextToolbar',
50 'textarea': 'richTextArea'
51 }
52
53 def __init__(self, host, content=None, modifiedCb=None, afterEditCb=None, options=None):
54 """
55
56 @param host (SatWebFrontend): host instance
57 @param content (dict): dict with at least a 'text' key
58 @param modifiedCb (callable): to be called when the text has been modified
59 @param afterEditCb (callable): to be called when the edition is done
60 @param options (list[unicode]): UI options ("read_only", "update_msg")
61 """
62 FlexTable.__init__(self) # FIXME
63 self.host = host
64 self.wysiwyg = False
65 self.read_only = 'read_only' in options
66 self.update_msg = 'update_msg' in options
67
68 indices = (-1, -1, 0, -1, -1) if self.read_only else (0, 1, 2, 3, 4)
69 self.title_offset, self.toolbar_offset, self.content_offset, self.tags_offset, self.command_offset = indices
70 self.addStyleName(self.STYLE['main'])
71
72 editor_widget.BaseTextEditor.__init__(self, content, None, modifiedCb, afterEditCb)
73
74 def addEditListener(self, listener):
75 """Add a method to be called whenever the text is edited.
76
77 @param listener: method taking two arguments: sender, keycode
78 """
79 editor_widget.BaseTextEditor.addEditListener(self, listener)
80 if hasattr(self, 'display'):
81 self.display.addEditListener(listener)
82
83 def refresh(self, edit=None):
84 """Refresh the UI for edition/display mode.
85
86 @param edit: set to True to display the edition mode
87 """
88 if edit is None:
89 edit = hasattr(self, 'textarea') and self.textarea.getVisible()
90
91 for widget in ['title_panel', 'tags_panel', 'command']:
92 if hasattr(self, widget):
93 getattr(self, widget).setVisible(edit)
94
95 if hasattr(self, 'toolbar'):
96 self.toolbar.setVisible(False)
97
98 if not hasattr(self, 'display'):
99 self.display = editor_widget.HTMLTextEditor(options={'enhance_display': False, 'listen_keyboard': False}) # for display mode
100 for listener in self.edit_listeners:
101 self.display.addEditListener(listener)
102
103 if not self.read_only and not hasattr(self, 'textarea'):
104 self.textarea = EditTextArea(self) # for edition mode
105 self.textarea.addStyleName(self.STYLE['textarea'])
106
107 self.getFlexCellFormatter().setColSpan(self.content_offset, 0, 2)
108 if edit and not self.wysiwyg:
109 self.textarea.setWidth('100%') # CSS width doesn't do it, don't know why
110 self.setWidget(self.content_offset, 0, self.textarea)
111 else:
112 self.setWidget(self.content_offset, 0, self.display)
113 if not edit:
114 return
115
116 if not self.read_only and not hasattr(self, 'title_panel'):
117 self.title_panel = base_panel.TitlePanel()
118 self.title_panel.addStyleName(self.STYLE['title'])
119 self.getFlexCellFormatter().setColSpan(self.title_offset, 0, 2)
120 self.setWidget(self.title_offset, 0, self.title_panel)
121
122 if not self.read_only and not hasattr(self, 'tags_panel'):
123 suggested_tags = [] # TODO: feed this list with tags suggestion
124 self.tags_panel = list_manager.TagsPanel(suggested_tags)
125 self.getFlexCellFormatter().setColSpan(self.tags_offset, 0, 2)
126 self.setWidget(self.tags_offset, 0, self.tags_panel)
127
128 if not self.read_only and not hasattr(self, 'command'):
129 self.command = HorizontalPanel()
130 self.command.addStyleName("marginAuto")
131 self.command.add(Button("Cancel", lambda: self.edit(True, True)))
132 self.command.add(Button("Update" if self.update_msg else "Send message", lambda: self.edit(False)))
133 self.getFlexCellFormatter().setColSpan(self.command_offset, 0, 2)
134 self.setWidget(self.command_offset, 0, self.command)
135
136 def setToolBar(self, syntax):
137 """This method is called asynchronously after the parameter
138 holding the rich text syntax is retrieved. It is called at
139 each call of self.edit(True) because the user may
140 have change his setting since the last time."""
141 if syntax is None or syntax not in composition.RICH_SYNTAXES.keys():
142 syntax = composition.RICH_SYNTAXES.keys()[0]
143 if hasattr(self, "toolbar") and self.toolbar.syntax == syntax:
144 self.toolbar.setVisible(True)
145 return
146 self.toolbar = HorizontalPanel()
147 self.toolbar.syntax = syntax
148 self.toolbar.addStyleName(self.STYLE['toolbar'])
149 for key in composition.RICH_SYNTAXES[syntax].keys():
150 self.addToolbarButton(syntax, key)
151 self.wysiwyg_button = CheckBox(_('preview'))
152 wysiywgCb = lambda sender: self.setWysiwyg(sender.getChecked())
153 self.wysiwyg_button.addClickListener(wysiywgCb)
154 self.toolbar.add(self.wysiwyg_button)
155 self.syntax_label = Label(_("Syntax: %s") % syntax)
156 self.syntax_label.addStyleName("richTextSyntaxLabel")
157 self.toolbar.add(self.syntax_label)
158 self.toolbar.setCellWidth(self.syntax_label, "100%")
159 self.getFlexCellFormatter().setColSpan(self.toolbar_offset, 0, 2)
160 self.setWidget(self.toolbar_offset, 0, self.toolbar)
161
162 def setWysiwyg(self, wysiwyg, init=False):
163 """Toggle the edition mode between rich content syntax and wysiwyg.
164 @param wysiwyg: boolean value
165 @param init: set to True to re-init without switching the widgets."""
166 def setWysiwyg():
167 self.wysiwyg = wysiwyg
168 try:
169 self.wysiwyg_button.setChecked(wysiwyg)
170 except (AttributeError, TypeError):
171 pass
172 try:
173 if wysiwyg:
174 self.syntax_label.addStyleName('transparent')
175 else:
176 self.syntax_label.removeStyleName('transparent')
177 except (AttributeError, TypeError):
178 pass
179 if not wysiwyg:
180 self.display.removeStyleName('richTextWysiwyg')
181
182 if init:
183 setWysiwyg()
184 return
185
186 self.getFlexCellFormatter().setColSpan(self.content_offset, 0, 2)
187 if wysiwyg:
188 def syntaxConvertCb(text):
189 self.display.setContent({'text': text})
190 self.textarea.removeFromParent() # XXX: force as it is not always done...
191 self.setWidget(self.content_offset, 0, self.display)
192 self.display.addStyleName('richTextWysiwyg')
193 self.display.edit(True)
194 content = self.getContent()
195 if content['text'] and content['syntax'] != C.SYNTAX_XHTML:
196 self.host.bridge.call('syntaxConvert', syntaxConvertCb, content['text'], content['syntax'], C.SYNTAX_XHTML)
197 else:
198 syntaxConvertCb(content['text'])
199 else:
200 syntaxConvertCb = lambda text: self.textarea.setText(text)
201 text = self.display.getContent()['text']
202 if text and self.toolbar.syntax != C.SYNTAX_XHTML:
203 self.host.bridge.call('syntaxConvert', syntaxConvertCb, text)
204 else:
205 syntaxConvertCb(text)
206 self.setWidget(self.content_offset, 0, self.textarea)
207 self.textarea.setWidth('100%') # CSS width doesn't do it, don't know why
208
209 setWysiwyg() # do it in the end because it affects self.getContent
210
211 def addToolbarButton(self, syntax, key):
212 """Add a button with the defined parameters."""
213 button = Button('<img src="%s" class="richTextIcon" />' %
214 composition.RICH_BUTTONS[key]["icon"])
215 button.setTitle(composition.RICH_BUTTONS[key]["tip"])
216 button.addStyleName('richTextToolButton')
217 self.toolbar.add(button)
218
219 def buttonCb():
220 """Generic callback for a toolbar button."""
221 text = self.textarea.getText()
222 cursor_pos = self.textarea.getCursorPos()
223 selection_length = self.textarea.getSelectionLength()
224 data = composition.RICH_SYNTAXES[syntax][key]
225 if selection_length == 0:
226 middle_text = data[1]
227 else:
228 middle_text = text[cursor_pos:cursor_pos + selection_length]
229 self.textarea.setText(text[:cursor_pos]
230 + data[0]
231 + middle_text
232 + data[2]
233 + text[cursor_pos + selection_length:])
234 self.textarea.setCursorPos(cursor_pos + len(data[0]) + len(middle_text))
235 self.textarea.setFocus(True)
236 self.textarea.onKeyDown()
237
238 def wysiwygCb():
239 """Callback for a toolbar button while wysiwyg mode is enabled."""
240 data = composition.COMMANDS[key]
241
242 def execCommand(command, arg):
243 self.display.setFocus(True)
244 doc().execCommand(command, False, arg.strip() if arg else '')
245 # use Window.prompt instead of dialog.PromptDialog to not loose the focus
246 prompt = lambda command, text: execCommand(command, Window.prompt(text))
247 if isinstance(data, tuple) or isinstance(data, list):
248 if data[1]:
249 prompt(data[0], data[1])
250 else:
251 execCommand(data[0], data[2])
252 else:
253 execCommand(data, False, '')
254 self.textarea.onKeyDown()
255
256 button.addClickListener(lambda: wysiwygCb() if self.wysiwyg else buttonCb())
257
258 def getContent(self):
259 assert(hasattr(self, 'textarea'))
260 assert(hasattr(self, 'toolbar'))
261 if self.wysiwyg:
262 content = {'text': self.display.getContent()['text'], 'syntax': C.SYNTAX_XHTML}
263 else:
264 content = {'text': self.strproc(self.textarea.getText()), 'syntax': self.toolbar.syntax}
265 if hasattr(self, 'title_panel'):
266 content.update({'title': self.strproc(self.title_panel.getText())})
267 if hasattr(self, 'tags_panel'):
268 data_format.iter2dict('tag', self.tags_panel.getTags(), content)
269 return content
270
271 def edit(self, edit=False, abort=False, sync=False):
272 """
273 Remark: the editor must be visible before you call this method.
274 @param edit: set to True to edit the content or False to only display it
275 @param abort: set to True to cancel the edition and loose the changes.
276 If edit and abort are both True, self.abortEdition can be used to ask for a
277 confirmation. When edit is False and abort is True, abortion is actually done.
278 @param sync: set to True to cancel the edition after the content has been saved somewhere else
279 """
280 if not (edit and abort):
281 self.refresh(edit) # not when we are asking for a confirmation
282 editor_widget.BaseTextEditor.edit(self, edit, abort, sync) # after the UI has been refreshed
283 if (edit and abort):
284 return # self.abortEdition is called by editor_widget.BaseTextEditor.edit
285 self.setWysiwyg(False, init=True) # after editor_widget.BaseTextEditor (it affects self.getContent)
286 if sync:
287 return
288 # the following must NOT be done at each UI refresh!
289 content = self._original_content
290 if edit:
291 def getParamCb(syntax):
292 # set the editable text in the current user-selected syntax
293 def syntaxConvertCb(text=None):
294 if text is not None:
295 # Important: this also update self._original_content
296 content.update({'text': text})
297 content.update({'syntax': syntax})
298 self.textarea.setText(content['text'])
299
300 if hasattr(self, 'title_panel') and 'title' in content:
301 self.title_panel.setText(content['title'])
302 self.title_panel.setStackVisible(0, content['title'] != '')
303
304 if hasattr(self, 'tags_panel'):
305 tags = list(data_format.dict2iter('tag', content))
306 self.tags_panel.setTags(tags)
307 self.tags_panel.setStackVisible(0, len(tags) > 0)
308
309 self.setToolBar(syntax)
310 if content['text'] and content['syntax'] != syntax:
311 self.host.bridge.call('syntaxConvert', syntaxConvertCb, content['text'], content['syntax'])
312 else:
313 syntaxConvertCb()
314 self.host.bridge.call('asyncGetParamA', getParamCb, composition.PARAM_NAME_SYNTAX, composition.PARAM_KEY_COMPOSITION)
315 else:
316 if not self.initialized:
317 # set the display text in XHTML only during init because a new MicroblogEntry instance is created after each modification
318 self.setDisplayContent()
319 self.display.edit(False)
320
321 def setDisplayContent(self):
322 """Set the content of the editor_widget.HTMLTextEditor which is used for display/wysiwyg"""
323 content = self._original_content
324 text = content['text']
325 if 'title' in content and content['title']:
326 title = '<h2>%s</h2>' % html_tools.html_sanitize(content['title'])
327 else:
328 title = ""
329
330 tags = ""
331 for tag in data_format.dict2iter('tag', content):
332 tags += "<li><a>%s</a></li>" % html_tools.html_sanitize(tag)
333 if tags:
334 tags = '<ul class="mblog_tags">%s</ul>' % tags
335
336 self.display.setContent({'text': "%s%s%s" % (title, tags, text)})
337
338 def setFocus(self, focus):
339 self.textarea.setFocus(focus)
340
341 def abortEdition(self, content):
342 """Ask for confirmation before closing the dialog."""
343 def confirm_cb(answer):
344 if answer:
345 self.edit(False, True)
346 _dialog = dialog.ConfirmDialog(confirm_cb, text="Do you really want to %s?" % ("cancel your changes" if self.update_msg else "cancel this message"))
347 _dialog.cancel_button.setText(_("No"))
348 _dialog.show()
349
350
351 class EditTextArea(TextArea, KeyboardHandler):
352 def __init__(self, _parent):
353 TextArea.__init__(self)
354 self._parent = _parent
355 KeyboardHandler.__init__(self)
356 self.addKeyboardListener(self)
357
358 def onKeyDown(self, sender=None, keycode=None, modifiers=None):
359 for listener in self._parent.edit_listeners:
360 listener(self, keycode, modifiers) # FIXME: edit_listeners must either be removed, or send an action instead of keycode/modifiers