From aa4d0e2b9457824a4eb7fc9de9910eca765f3a34 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Mon, 31 Mar 2014 16:31:13 +0200 Subject: [PATCH] Use tinyMCE editor for notes contents; implement data saving and tags listing/filtering --- plugins/kolab_notes/kolab_notes.php | 154 +++++++++- plugins/kolab_notes/kolab_notes_ui.php | 18 ++ plugins/kolab_notes/notes.js | 267 +++++++++++++++++- plugins/kolab_notes/skins/larry/notes.css | 16 +- .../skins/larry/templates/notes.html | 27 +- 5 files changed, 455 insertions(+), 27 deletions(-) diff --git a/plugins/kolab_notes/kolab_notes.php b/plugins/kolab_notes/kolab_notes.php index 3c369583..b92e71be 100644 --- a/plugins/kolab_notes/kolab_notes.php +++ b/plugins/kolab_notes/kolab_notes.php @@ -210,7 +210,7 @@ class kolab_notes extends rcube_plugin $list = rcube_utils::get_input_value('_list', RCUBE_INPUT_GPC); $data = $this->notes_data($this->list_notes($list, $search), $tags); - $this->rc->output->command('plugin.data_ready', array('list' => $list, 'search' => $search, 'data' => $data, 'tags' => array_values(array_unique($tags)))); + $this->rc->output->command('plugin.data_ready', array('list' => $list, 'search' => $search, 'data' => $data, 'tags' => array_values($tags))); } /** @@ -221,10 +221,10 @@ class kolab_notes extends rcube_plugin $tags = array(); foreach ($records as $i => $rec) { - $this->_client_encode($records[$i]); unset($records[$i]['description']); + $this->_client_encode($records[$i]); - foreach ((array)$reg['categories'] as $tag) { + foreach ((array)$rec['categories'] as $tag) { $tags[] = $tag; } } @@ -311,7 +311,7 @@ class kolab_notes extends rcube_plugin private function _client_encode(&$note) { foreach ($note as $key => $prop) { - if ($key[0] == '_') { + if ($key[0] == '_' || $key == 'x-custom') { unset($note[$key]); } } @@ -323,6 +323,11 @@ class kolab_notes extends rcube_plugin } } + // clean HTML contents + if (!empty($note['description']) && preg_match('/<(html|body|div|p|span)(\s+[a-z]|>)/', $note['description'])) { + $note['html'] = $this->_wash_html($note['description']); + } + return $note; } @@ -331,12 +336,16 @@ class kolab_notes extends rcube_plugin $action = rcube_utils::get_input_value('_do', RCUBE_INPUT_POST); $note = rcube_utils::get_input_value('_data', RCUBE_INPUT_POST, true); - $success =false; + $success = false; switch ($action) { - case 'save': - console($action, $note); - sleep(3); - $success = true; + case 'new': + $temp_id = $rec['tempid']; + + case 'edit': + if ($success = $this->save_note($note)) { + $refresh = $this->get_note($note); + $refresh['tempid'] = $temp_id; + } break; } @@ -350,8 +359,133 @@ class kolab_notes extends rcube_plugin // unlock client $this->rc->output->command('plugin.unlock_saving'); - // $this->rc->output->command('plugin.update_note', $note); + + if ($refresh) { + $this->rc->output->command('plugin.update_note', $this->_client_encode($refresh)); + } } + /** + * Update an note record with the given data + * + * @param array Hash array with note properties + * @return boolean True on success, False on error + */ + private function save_note($note) + { + $this->_read_lists(); + + $list_id = $note['list']; + if (!$list_id || !($folder = $this->folders[$list_id])) + return false; + + // moved from another folder + if ($note['_fromlist'] && ($fromfolder = $this->folders[$note['_fromlist']])) { + if (!$fromfolder->move($note['id'], $folder->name)) + return false; + + unset($note['_fromlist']); + } + + // load previous version of this record to merge + if ($note['uid']) { + $old = $folder->get_object($note['uid']); + if (!$old || PEAR::isError($old)) + return false; + + // merge existing properties if the update isn't complete + if (!isset($note['title']) || !isset($note['description'])) + $note += $old; + } + + // generate new note object from input + $object = $this->_write_preprocess($note, $old); + $saved = $folder->save($object, 'note', $note['uid']); + + if (!$saved) { + raise_error(array( + 'code' => 600, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "Error saving note object to Kolab server"), + true, false); + $saved = false; + } + else { + $note = $object; + $note['list'] = $list_id; + // TODO: cache this in memory for later read + } + + return $saved; + } + + + /** + * Process the given note data (submitted by the client) before saving it + */ + private function _write_preprocess($note, $old = array()) + { + $object = $note; + + // TODO: handle attachments + + // clean up HTML content + $object['description'] = $this->_wash_html($note['description']); + + // try to be smart and convert to plain-text if no real formatting is detected + if (preg_match('!
(.*)
!ims', $object['description'], $m)) { + if (!preg_match('!<(a|b|i|strong|em|p|span|div|pre|li)(\s+[a-z]|>)!im', $m[1])) { + // $converter = new rcube_html2text($m[1], false, true, 0); + // $object['description'] = rtrim($converter->get_text()); + $object['description'] = preg_replace('!!', "\n", $m[1]); + } + } + + // copy meta data (starting with _) from old object + foreach ((array)$old as $key => $val) { + if (!isset($object[$key]) && $key[0] == '_') + $object[$key] = $val; + } + + unset($object['list'], $object['tempid'], $object['created'], $object['changed'], $object['created_'], $object['changed_']); + return $object; + } + + /** + * Sanity checks/cleanups HTML content + */ + private function _wash_html($html) + { + // Add header with charset spec., washtml cannot work without that + $html = '' + . '' + . '' . $html . ''; + + // clean HTML with washhtml by Frederic Motte + $wash_opts = array( + 'show_washed' => false, + 'allow_remote' => 1, + 'charset' => RCUBE_CHARSET, + 'html_elements' => array('html', 'body', 'link'), + 'html_attribs' => array('rel', 'type'), + ); + + // initialize HTML washer + $washer = new rcube_washtml($wash_opts); + + //$washer->add_callback('form', 'rcmail_washtml_callback'); + //$washer->add_callback('style', 'rcmail_washtml_callback'); + + // Remove non-UTF8 characters (#1487813) + $html = rcube_charset::clean($html); + + $html = $washer->wash($html); + + // remove unwanted comments (produced by washtml) + $html = preg_replace('//', '', $html); + + return $html; + } + } diff --git a/plugins/kolab_notes/kolab_notes_ui.php b/plugins/kolab_notes/kolab_notes_ui.php index cc22a550..264288d4 100644 --- a/plugins/kolab_notes/kolab_notes_ui.php +++ b/plugins/kolab_notes/kolab_notes_ui.php @@ -56,6 +56,22 @@ class kolab_notes_ui // TODO: load config options and user prefs relevant for the UI $settings = array(); + + // TinyMCE uses two-letter lang codes, with exception of Chinese + $lang = strtolower($_SESSION['language']); + $lang = strpos($lang, 'zh_') === 0 ? str_replace('_', '-', $lang) : substr($lang, 0, 2); + + if (!file_exists(INSTALL_PATH . 'program/js/tiny_mce/langs/'.$lang.'.js')) { + $lang = 'en'; + } + + $settings['editor'] = array( + 'lang' => $lang, + 'editor_css' => $this->plugin->url() . $this->plugin->local_skin_path() . '/editor.css', + 'spellcheck' => intval($this->rc->config->get('enable_spellcheck')), + 'spelldict' => intval($this->rc->config->get('spellcheck_dictionary')) + ); + $this->rc->output->set_env('kolab_notes_settings', $settings); } @@ -111,7 +127,9 @@ class kolab_notes_ui public function editform($attrib) { $attrib += array('action' => '#', 'id' => 'rcmkolabnoteseditform'); + $this->rc->output->add_gui_object('noteseditform', $attrib['id']); + $this->rc->output->include_script('tiny_mce/tiny_mce.js'); $textarea = new html_textarea(array('name' => 'content', 'id' => 'notecontent', 'cols' => 60, 'rows' => 20, 'tabindex' => 3)); return html::tag('form', $attrib, $textarea->show(), array_merge(html::$common_attrib, array('action'))); diff --git a/plugins/kolab_notes/notes.js b/plugins/kolab_notes/notes.js index 873c3941..d2b9319d 100644 --- a/plugins/kolab_notes/notes.js +++ b/plugins/kolab_notes/notes.js @@ -27,6 +27,7 @@ function rcube_kolab_notes_ui(settings) var search_query; var noteslist; var notesdata = {}; + var tagsfilter = []; var tags = []; var me = this; @@ -46,12 +47,14 @@ function rcube_kolab_notes_ui(settings) rcmail.register_command('list-edit', function(){ list_edit_dialog(me.selected_list); }, false); rcmail.register_command('list-remove', function(){ list_remove(me.selected_list); }, false); rcmail.register_command('save', save_note, true); + rcmail.register_command('delete', delete_note, true); rcmail.register_command('search', quicksearch, true); rcmail.register_command('reset-search', reset_search, true); // register server callbacks rcmail.addEventListener('plugin.data_ready', data_ready); rcmail.addEventListener('plugin.render_note', render_note); + rcmail.addEventListener('plugin.update_note', update_note); rcmail.addEventListener('plugin.unlock_saving', function(){ if (saving_lock) { rcmail.set_busy(false, null, saving_lock); @@ -78,6 +81,7 @@ function rcube_kolab_notes_ui(settings) noteslist.addEventListener('select', function(list) { var note; if (list.selection.length == 1 && (note = notesdata[list.selection[0]])) { + // TODO: check for unsaved changes and warn edit_note(note.uid, 'edit'); } else { @@ -87,6 +91,90 @@ function rcube_kolab_notes_ui(settings) .init(); } + // click-handler on tags list + $(rcmail.gui_objects.notestagslist).on('click', function(e){ + var item = e.target.nodeName == 'LI' ? $(e.target) : $(e.target).closest('li'), + tag = item.data('value'); + + if (!tag) + return false; + + // reset selection on regular clicks + var index = $.inArray(tag, tagsfilter); + var shift = e.shiftKey || e.ctrlKey || e.metaKey; + + if (!shift) { + if (tagsfilter.length > 1) + index = -1; + + $('li', this).removeClass('selected'); + tagsfilter = []; + } + + // add tag to filter + if (index < 0) { + item.addClass('selected'); + tagsfilter.push(tag); + } + else if (shift) { + item.removeClass('selected'); + var a = tagsfilter.slice(0,index); + tagsfilter = a.concat(tagsfilter.slice(index+1)); + } + + filter_notes(); + + // clear text selection in IE after shift+click + if (shift && document.selection) + document.selection.empty(); + + e.preventDefault(); + return false; + }) + .mousedown(function(e){ + // disable content selection with the mouse + e.preventDefault(); + return false; + }); + + // initialize tinyMCE editor + var editor_conf = { + mode: 'textareas', + elements: 'notecontent', + apply_source_formatting: true, + theme: 'advanced', + language: settings.editor.lang, + content_css: settings.editor.editor_css, + theme_advanced_toolbar_location: 'top', + theme_advanced_toolbar_align: 'left', + theme_advanced_buttons3: '', + theme_advanced_statusbar_location: 'none', +// extended_valid_elements: 'font[face|size|color|style],span[id|class|align|style]', + relative_urls: false, + remove_script_host: false, + gecko_spellcheck: true, + convert_urls: false, + paste_data_images: true, + plugins: 'paste,tabfocus,searchreplace,table,inlinepopups', + theme_advanced_buttons1: 'bold,italic,underline,|,justifyleft,justifycenter,justifyright,justifyfull,|,bullist,numlist,outdent,indent,blockquote,|,forecolor,backcolor,fontselect,fontsizeselect', + theme_advanced_buttons2: 'link,unlink,table,charmap,|,search,code,|,undo,redo', + setup: function(ed) { + // make links open on shift-click + ed.onClick.add(function(ed, e) { + var link = $(e.target).closest('a'); + if (link.length && e.shiftKey) { + window.open(link.get(0).href, '_blank'); + } + }); + } + }; + + // support external configuration settings e.g. from skin + if (window.rcmail_editor_settings) + $.extend(editor_conf, window.rcmail_editor_settings); + + tinyMCE.init(editor_conf); + if (me.selected_list) { rcmail.enable_command('createnote', true); $('#rcmliknb'+me.selected_list).click(); @@ -194,6 +282,33 @@ function rcube_kolab_notes_ui(settings) reset_view(); noteslist.clear(); notesdata = {}; + tagsfilter = []; + } + + function filter_notes() + { + // tagsfilter + var note, tr, match; + for (var id in noteslist.rows) { + tr = noteslist.rows[id].obj; + note = notesdata[id]; + match = note.categories && note.categories.length; + for (var i=0; match && i < tagsfilter.length; i++) { + if ($.inArray(tagsfilter[i], note.categories) < 0) + match = false; + } + + if (match || !tagsfilter.length) { + $(tr).show(); + } + else { + $(tr).hide(); + } + + if (me.selected_note && me.selected_note.uid == note.uid && !match) { + reset_view(); + } + } } /** @@ -220,7 +335,7 @@ function rcube_kolab_notes_ui(settings) notesdata[rec.id] = rec; } - tags = data.tags || []; + render_tagslist(data.tags || [], true) rcmail.set_busy(false, 'loading', ui_loading); } @@ -236,13 +351,14 @@ function rcube_kolab_notes_ui(settings) return; } - var list = me.notebooks[data.list] || me.notebooks[me.selected_list] - var title = $('.notetitle', rcmail.gui_objects.noteviewtitle).val(data.title); - var content = $('#notecontent').val(data.description); + var list = me.notebooks[data.list] || me.notebooks[me.selected_list]; + content = $('#notecontent').val(data.description); + $('.notetitle', rcmail.gui_objects.noteviewtitle).val(data.title); $('.dates .notecreated', rcmail.gui_objects.noteviewtitle).html(Q(data.created || '')); $('.dates .notechanged', rcmail.gui_objects.noteviewtitle).html(Q(data.changed || '')); - if (data.created || data.changed) + if (data.created || data.changed) { $('.dates', rcmail.gui_objects.noteviewtitle).show(); + } $(rcmail.gui_objects.noteseditform).show(); @@ -265,7 +381,7 @@ function rcube_kolab_notes_ui(settings) animSpeed: 100, allowEdit: false, checkNewEntriesCaseSensitive: false, - autocompleteOptions: { source: tags, minLength: 0 }, + autocompleteOptions: { source: tags, minLength: 0, noCheck: true }, texts: { removeLinkTitle: rcmail.gettext('removetag', 'kolab_notes') } }) @@ -273,8 +389,124 @@ function rcube_kolab_notes_ui(settings) .on('click', function(){ $('.tagline .placeholder').hide(); }); me.selected_note = data; - rcmail.enable_command('save', list.editable && !data.readonly); - content.select(); + rcmail.enable_command('save', 'delete', list.editable && !data.readonly); + + var html, node, editor = tinyMCE.get('notecontent'); + if (editor) { + html = data.html || data.description; + if (!html.match(/<(html|body|p|div|span)/)) + html = '
' + Q(html) + '
'; + + editor.setContent(html); + node = editor.getContentAreaContainer().childNodes[0]; + if (node) node.tabIndex = content.get(0).tabIndex; + editor.getBody().focus(); + } + + // Trigger resize (needed for proper editor resizing) + $(window).resize(); + } + + /** + * + */ + function render_tagslist(newtags, replace) + { + if (replace) { + tags = newtags; + } + else { + var append = []; + for (var i=0; i < newtags.length; i++) { + if ($.inArray(newtags[i], tags) < 0) + append.push(newtags[i]); + } + if (!append.length) { + update_tagcloud(); + return; // nothing to be added + } + tags = tags.concat(append); + } + + // sort tags first + tags.sort(function(a,b){ + return a.toLowerCase() > b.toLowerCase() ? 1 : -1; + }) + + var widget = $(rcmail.gui_objects.notestagslist).html(''); + + // append tags to tag cloud + $.each(tags, function(i, tag){ + li = $('
  • ').attr('rel', tag).data('value', tag) + .html(Q(tag) + '') + .appendTo(widget) +/* + .draggable({ + addClasses: false, + revert: 'invalid', + revertDuration: 300, + helper: tag_draggable_helper, + start: tag_draggable_start, + appendTo: 'body', + cursor: 'pointer' + }); +*/ + }); + + update_tagcloud(); + } + + /** + * Display the given counts to each tag and set those inactive which don't + * have any matching records in the current view. + */ + function update_tagcloud(counts) + { + // compute counts first by iterating over all visible task items + if (typeof counts == 'undefined') { + counts = {}; + $.each(notesdata, function(id, rec){ + for (var t, j=0; rec && rec.categories && j < rec.categories.length; j++) { + t = rec.categories[j]; + if (typeof counts[t] == 'undefined') + counts[t] = 0; + counts[t]++; + } + }); + } + + $(rcmail.gui_objects.notestagslist).children('li').each(function(i,li){ + var elem = $(li), tag = elem.attr('rel'), + count = counts[tag] || 0; + + elem.children('.count').html(count+''); + if (count == 0) elem.addClass('inactive'); + else elem.removeClass('inactive'); + + if (tagsfilter && tagsfilter.length && $.inArray(tag, tagsfilter)) { + elem.addClass('selected'); + } + }); + } + + /** + * Callback from server after saving a note record + */ + function update_note(data) + { + data.id = rcmail.html_identifier_encode(data.uid); + notesdata[data.id] = data; + render_note(data); + + // update list item + var row = noteslist.rows[data.id]; + if (row) { + $('.title', row.obj).html(Q(data.title)); + $('.date', row.obj).html(Q(data.changed || '')); + // TODO: move to top + } + + render_tagslist(data.categories || []); } /** @@ -283,10 +515,11 @@ function rcube_kolab_notes_ui(settings) function reset_view() { me.selected_note = null; + noteslist.clear_selection(); $('.notetitle', rcmail.gui_objects.noteviewtitle).val(''); $('.tagline, .dates', rcmail.gui_objects.noteviewtitle).hide(); $(rcmail.gui_objects.noteseditform).hide(); - rcmail.enable_command('save', false); + rcmail.enable_command('save', 'delete', false); } /** @@ -298,9 +531,10 @@ function rcube_kolab_notes_ui(settings) return false; } + var editor = tinyMCE.get('notecontent'); var savedata = { title: trim($('.notetitle', rcmail.gui_objects.noteviewtitle).val()), - description: $('#notecontent').val(), + description: editor ? editor.getContent({ format:'html' }) : $('#notecontent').val(), list: me.selected_note.list || me.selected_list, uid: me.selected_note.uid, categories: [] @@ -325,8 +559,19 @@ function rcube_kolab_notes_ui(settings) rcmail.lock_form(rcmail.gui_objects.noteseditform, true); saving_lock = rcmail.set_busy(true, 'kolab_notes.savingdata'); - rcmail.http_post('action', { _data: savedata, _do:'save' }, true); + rcmail.http_post('action', { _data: savedata, _do: savedata.uid?'edit':'new' }, true); } + + function delete_note() + { + if (!me.selected_note) { + return false; + } + + alert(me.selected_note.title) + reset_view(); + } + } diff --git a/plugins/kolab_notes/skins/larry/notes.css b/plugins/kolab_notes/skins/larry/notes.css index 1f86084b..a3ba1589 100644 --- a/plugins/kolab_notes/skins/larry/notes.css +++ b/plugins/kolab_notes/skins/larry/notes.css @@ -104,18 +104,24 @@ background: #f9f9f9; } -.notesview #notecontent { +.notesview #noteform { position: absolute; top: 82px; left: 0; bottom: 41px; width: 100%; +} + +.notesview #notecontent { + position: relative; + width: 100%; + height: 100%; border: 0; border-radius: 0; padding: 8px 0 8px 8px; resize: none; - font-family: monospace; - font-size: 9pt; + font-family: "Lucida Grande", Verdana, Arial, Helvetica, sans-serif; + font-size: 12px; outline: none; -webkit-box-sizing: border-box; -moz-box-sizing: border-box; @@ -135,6 +141,10 @@ box-shadow: inset 0 0 3px 2px rgba(71,135,177, 0.9); } +.notesview .defaultSkin table.mceLayout { + border: 0; +} + .notesview #notedetailstitle { height: 68px; } diff --git a/plugins/kolab_notes/skins/larry/templates/notes.html b/plugins/kolab_notes/skins/larry/templates/notes.html index 567941bc..c75a3b4c 100644 --- a/plugins/kolab_notes/skins/larry/templates/notes.html +++ b/plugins/kolab_notes/skins/larry/templates/notes.html @@ -24,7 +24,7 @@

    - +
    @@ -84,11 +84,32 @@ $(document).ready(function(e){ UI.init(); new rcube_splitter({ id:'notesviewsplitter', p1:'#sidebar', p2:'#mainview-right', - orientation:'v', relative:true, start:240, min:180, size:16, offset:2 }).init(); + orientation:'v', relative:true, start:240, min:180, size:16, offset:2, render:layout_view }).init(); new rcube_splitter({ id:'noteslistsplitter2', p1:'#noteslistbox', p2:'#notedetailsbox', - orientation:'v', relative:true, start:242, min:180, size:16, offset:2 }).init(); + orientation:'v', relative:true, start:242, min:180, size:16, offset:2, render:layout_view }).init(); new rcube_splitter({ id:'notesviewsplitterv', p1:'#tagsbox', p2:'#notebooksbox', orientation:'h', relative:true, start:242, min:120, size:16, offset:6 }).init(); + + function layout_view() + { + var form = $('#noteform'), + content = $('#notecontent'), + header = $('#notedetailstitle'), + w, h; + + form.css('top', header.outerHeight()+'px'); + + w = form.outerWidth(); + h = form.outerHeight(); + content.width(w).height(h); + + $('#notecontent_tbl').width(w+'px').height('').css('margin-top', '-1px'); + $('#notecontent_ifr').width(w+'px').height((h-54)+'px'); + } + + $(window).resize(function(e){ + layout_view(); + }); });