Mercurial > libervia-web
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 |