From 0b1de8a487034724e8acbdccf8a7b506d1ecaeed Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Thu, 25 Jul 2013 17:39:35 +0200 Subject: Add new feature to save and recall text snippets (aka canned responses) when composing messages --- program/js/app.js | 170 +++++++++++++++++++++++++++++++- program/lib/Roundcube/html.php | 2 +- program/localization/en_US/labels.inc | 8 ++ program/localization/en_US/messages.inc | 1 + program/steps/mail/compose.inc | 42 +++++++- program/steps/settings/responses.inc | 53 ++++++++++ skins/larry/styles.css | 33 ++++++- skins/larry/templates/compose.html | 11 +++ 8 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 program/steps/settings/responses.inc diff --git a/program/js/app.js b/program/js/app.js index dedad37d2..7e58121f2 100644 --- a/program/js/app.js +++ b/program/js/app.js @@ -256,7 +256,9 @@ function rcube_webmail() } else if (this.env.action == 'compose') { this.env.address_group_stack = []; - this.env.compose_commands = ['send-attachment', 'remove-attachment', 'send', 'cancel', 'toggle-editor', 'list-adresses', 'pushgroup', 'search', 'reset-search', 'extwin']; + this.env.compose_commands = ['send-attachment', 'remove-attachment', 'send', 'cancel', + 'toggle-editor', 'list-adresses', 'pushgroup', 'search', 'reset-search', 'extwin', + 'insert-response', 'save-response', 'edit-responses']; if (this.env.drafts_mailbox) this.env.compose_commands.push('savedraft') @@ -272,6 +274,22 @@ function rcube_webmail() this.enable_command('spellcheck', true); } + // init canned response functions + if (this.gui_objects.responseslist) { + $('a.insertresponse', this.gui_objects.responseslist) + .mousedown(function(e){ return rcube_event.cancel(e); }) + .mouseup(function(e){ + ref.command('insert-response', $(this).attr('rel')); + $(document.body).trigger('mouseup'); // hides the menu + return rcube_event.cancel(e); + }); + + // avoid textarea loosing focus when hitting the save-response button/link + for (var i=0; this.buttons['save-response'] && i < this.buttons['save-response'].length; i++) { + $('#'+this.buttons['save-response'][i].id).mousedown(function(e){ return rcube_event.cancel(e); }) + } + } + document.onmouseup = function(e){ return p.doc_mouse_up(e); }; // init message compose form @@ -3283,6 +3301,108 @@ function rcube_webmail() return true; }; + this.insert_response = function(key) + { + var insert = this.env.textresponses[key] ? this.env.textresponses[key].text : null; + if (!insert) + return false; + + // get cursor pos + var textarea = rcube_find_object(this.env.composebody), + selection = $(textarea).is(':focus') ? this.get_input_selection(textarea) : { start:0, end:0 }, + inp_value = textarea.value; + pre = inp_value.substring(0, selection.start), + end = inp_value.substring(selection.end, inp_value.length); + + // insert response text + textarea.value = pre + insert + end; + + // set caret after inserted text + this.set_caret_pos(textarea, selection.start + insert.length); + textarea.focus(); + }; + + /** + * Open the dialog to save a new canned response + */ + this.save_response = function() + { + var textarea = rcube_find_object(this.env.composebody), + text = '', sigstart; + + if (textarea && $(textarea).is(':focus')) { + text = this.get_input_selection(textarea).text; + } + + if (!text && textarea) { + text = textarea.value; + + // strip off signature + sigstart = text.indexOf('-- \n'); + if (sigstart > 0) { + text = textarea.value.substring(0, sigstart); + } + } + + // show dialog to enter a name and to modify the text to be saved + var buttons = {}, + html = '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + buttons[this.gettext('save')] = function(e) { + var name = $('#ffresponsename').val(), + text = $('#ffresponsetext').val(); + + if (!text) { + $('#ffresponsetext').select(); + return false; + } + if (!name) + name = text.substring(0,40); + + var lock = ref.display_message(ref.get_label('savingresponse'), 'loading'); + ref.http_post('settings/responses', { _insert:1, _name:name, _text:text }, lock); + $(this).dialog('close'); + }; + + buttons[this.gettext('cancel')] = function() { + $(this).dialog('close'); + }; + + this.show_popup_dialog(html, this.gettext('savenewresponse'), buttons); + + $('#ffresponsetext').val(text); + $('#ffresponsename').select(); + }; + + this.add_response_item = function(response) + { + var key = response.key; + this.env.textresponses[key] = response; + + // append to responses list + if (this.gui_objects.responseslist) { + var li = $('
  • ').appendTo(this.gui_objects.responseslist); + $('').addClass('insertresponse active') + .attr('href', '#') + .attr('rel', key) + .html(response.name) + .appendTo(li) + .mousedown(function(e){ + return rcube_event.cancel(e); + }) + .mouseup(function(e){ + ref.command('insert-response', key); + $(document.body).trigger('mouseup'); // hides the menu + return rcube_event.cancel(e); + }); + } + }; + this.stop_spellchecking = function() { var ed; @@ -6822,6 +6942,54 @@ function rcube_webmail() } }; + // get selected text from an input field + // http://stackoverflow.com/questions/7186586/how-to-get-the-selected-text-in-textarea-using-jquery-in-internet-explorer-7 + this.get_input_selection = function(obj) + { + var start = 0, end = 0, + normalizedValue, range, + textInputRange, len, endRange; + + if (typeof obj.selectionStart == "number" && typeof obj.selectionEnd == "number") { + normalizedValue = obj.value; + start = obj.selectionStart; + end = obj.selectionEnd; + } else { + range = document.selection.createRange(); + + if (range && range.parentElement() == obj) { + len = obj.value.length; + normalizedValue = obj.value.replace(/\r\n/g, "\n"); + + // create a working TextRange that lives only in the input + textInputRange = obj.createTextRange(); + textInputRange.moveToBookmark(range.getBookmark()); + + // Check if the start and end of the selection are at the very end + // of the input, since moveStart/moveEnd doesn't return what we want + // in those cases + endRange = obj.createTextRange(); + endRange.collapse(false); + + if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) { + start = end = len; + } else { + start = -textInputRange.moveStart("character", -len); + start += normalizedValue.slice(0, start).split("\n").length - 1; + + if (textInputRange.compareEndPoints("EndToEnd", endRange) > -1) { + end = len; + } else { + end = -textInputRange.moveEnd("character", -len); + end += normalizedValue.slice(0, end).split("\n").length - 1; + } + } + } + } + + return { start:start, end:end, text:normalizedValue.substr(start, end-start) }; + }; + // disable/enable all fields of a form this.lock_form = function(form, lock) { diff --git a/program/lib/Roundcube/html.php b/program/lib/Roundcube/html.php index a36711281..2086aaa85 100644 --- a/program/lib/Roundcube/html.php +++ b/program/lib/Roundcube/html.php @@ -33,7 +33,7 @@ class html public static $doctype = 'xhtml'; public static $lc_tags = true; public static $common_attrib = array('id','class','style','title','align'); - public static $containers = array('iframe','div','span','p','h1','h2','h3','form','textarea','table','thead','tbody','tr','th','td','style','script'); + public static $containers = array('iframe','div','span','p','h1','h2','h3','ul','form','textarea','table','thead','tbody','tr','th','td','style','script'); /** diff --git a/program/localization/en_US/labels.inc b/program/localization/en_US/labels.inc index 1865bcb3d..8cda30990 100644 --- a/program/localization/en_US/labels.inc +++ b/program/localization/en_US/labels.inc @@ -232,6 +232,14 @@ $labels['checkspelling'] = 'Check spelling'; $labels['resumeediting'] = 'Resume editing'; $labels['revertto'] = 'Revert to'; +$labels['responses'] = 'Responses'; +$labels['insertresponse'] = 'Insert a response'; +$labels['manageresponses'] = 'Manage responses'; +$labels['savenewresponse'] = 'Save new response'; +$labels['editresponses'] = 'Edit responses'; +$labels['responsename'] = 'Name'; +$labels['responsetext'] = 'Response Text'; + $labels['attach'] = 'Attach'; $labels['attachments'] = 'Attachments'; $labels['upload'] = 'Upload'; diff --git a/program/localization/en_US/messages.inc b/program/localization/en_US/messages.inc index 47b0f797d..e9feb243d 100644 --- a/program/localization/en_US/messages.inc +++ b/program/localization/en_US/messages.inc @@ -46,6 +46,7 @@ $messages['messagesent'] = 'Message sent successfully.'; $messages['savingmessage'] = 'Saving message...'; $messages['messagesaved'] = 'Message saved to Drafts.'; $messages['successfullysaved'] = 'Successfully saved.'; +$messages['savingresponse'] = 'Saving response text...'; $messages['addedsuccessfully'] = 'Contact added successfully to address book.'; $messages['contactexists'] = 'A contact with the same e-mail address already exists.'; $messages['contactnameexists'] = 'A contact with the same name already exists.'; diff --git a/program/steps/mail/compose.inc b/program/steps/mail/compose.inc index f3ff19d72..efc0cc8e0 100644 --- a/program/steps/mail/compose.inc +++ b/program/steps/mail/compose.inc @@ -127,7 +127,8 @@ if (!is_array($COMPOSE)) $OUTPUT->add_label('nosubject', 'nosenderwarning', 'norecipientwarning', 'nosubjectwarning', 'cancel', 'nobodywarning', 'notsentwarning', 'notuploadedwarning', 'savingmessage', 'sendingmessage', 'messagesaved', 'converting', 'editorwarning', 'searching', 'uploading', 'uploadingmany', - 'fileuploaderror', 'sendmessage'); + 'fileuploaderror', 'sendmessage', 'savenewresponse', 'responsename', 'responsetext', 'save', + 'savingresponse'); $OUTPUT->set_env('compose_id', $COMPOSE['id']); $OUTPUT->set_pagetitle(rcube_label('compose')); @@ -1696,6 +1697,44 @@ function compose_file_drop_area($attrib) } +/** + * + */ +function rcmail_compose_responses_list($attrib) +{ + global $RCMAIL, $OUTPUT; + + $attrib += array('id' => 'rcmresponseslist', 'tagname' => 'ul', 'cols' => 1); + + $jsenv = array(); + $items = array(); + foreach ($RCMAIL->config->get('compose_responses', array()) as $response) { + $key = $response['key'] ? $response['key'] : substr(md5($response['name']), 0, 16); + $items[strtolower($response['name'])] = html::a(array( + 'href '=> '#'.urlencode($response['name']), + 'class' => rtrim('insertresponse ' . $attrib['itemclass']), + 'rel' => $key, + ), Q($response['name'])); + + $jsenv[$key] = $response; + } + + // sort list by name + ksort($items, SORT_LOCALE_STRING); + + $list = new html_table($attrib); + foreach ($items as $item) { + $list->add(array(), $item); + } + + // set client env + $OUTPUT->set_env('textresponses', $jsenv); + $OUTPUT->add_gui_object('responseslist', $attrib['id']); + + return $list->show(); +} + + // register UI objects $OUTPUT->add_handlers(array( 'composeheaders' => 'rcmail_compose_headers', @@ -1712,6 +1751,7 @@ $OUTPUT->add_handlers(array( 'storetarget' => 'rcmail_store_target_selection', 'addressbooks' => 'rcmail_addressbook_list', 'addresslist' => 'rcmail_contacts_list', + 'responseslist' => 'rcmail_compose_responses_list', )); $OUTPUT->send('compose'); diff --git a/program/steps/settings/responses.inc b/program/steps/settings/responses.inc new file mode 100644 index 000000000..5a7db5687 --- /dev/null +++ b/program/steps/settings/responses.inc @@ -0,0 +1,53 @@ + | + +-----------------------------------------------------------------------+ +*/ + + +if (!empty($_POST['_insert'])) { + $name = get_input_value('_name', RCUBE_INPUT_POST); + $text = trim(get_input_value('_text', RCUBE_INPUT_POST)); + + if (!empty($name) && !empty($text)) { + $dupes = 0; + $responses = $RCMAIL->config->get('compose_responses', array()); + foreach ($responses as $resp) { + if (strcasecmp($name, preg_replace('/\s\(\d+\)$/', '', $resp['name'])) == 0) + $dupes++; + } + if ($dupes) { // require a unique name + $name .= ' (' . ++$dupes . ')'; + } + + $response = array('name' => $name, 'text' => $text, 'format' => 'text', 'key' => substr(md5($name), 0, 16)); + $responses[] = $response; + + if ($RCMAIL->user->save_prefs(array('compose_responses' => $responses))) { + $RCMAIL->output->command('add_response_item', $response); + $RCMAIL->output->command('display_message', rcube_label('successfullysaved'), 'confirmation'); + } + else { + $RCMAIL->output->command('display_message', rcube_label('errorsaving'), 'error'); + } + } +} + +// send response +$RCMAIL->output->send(); + diff --git a/skins/larry/styles.css b/skins/larry/styles.css index d542768b7..131e85030 100644 --- a/skins/larry/styles.css +++ b/skins/larry/styles.css @@ -1440,6 +1440,20 @@ body.iframe .footerleft.floating:before, font-size: 12px; } +.propform div.prop { + margin-bottom: 0.5em; +} + +.propform div.prop.block label { + display: block; + margin-bottom: 0.3em; +} + +.propform div.prop.block input, +.propform div.prop.block textarea { + width: 95%; +} + fieldset.floating { float: left; margin-right: 10px; @@ -1922,6 +1936,7 @@ select.decorated option { } ul.toolbarmenu, +ul.toolbarmenu ul, #rcmKSearchpane ul { margin: 0; padding: 0; @@ -1940,13 +1955,13 @@ ul.toolbarmenu li, } .googie_list tr:first-child td, -ul.toolbarmenu li:first-child, +ul.toolbarmenu > li:first-child, select.decorated option:first-child { border-top: 0; } .googie_list tr:last-child td, -ul.toolbarmenu li:last-child, +ul.toolbarmenu > li:last-child, select.decorated option:last-child { border-bottom: 0; } @@ -2000,6 +2015,11 @@ ul.toolbarmenu li label { text-shadow: 0px 1px 1px #333; } +ul.toolbarmenu li.separator label { + color: #bbb; + font-style: italic; +} + ul.toolbarmenu li a.icon { color: #eee; padding: 2px 6px; @@ -2078,6 +2098,15 @@ ul.toolbarmenu li span.conversation { background-position: 0 -1532px; } +#snippetslist { + max-width: 200px; +} + +#snippetslist li a { + overflow: hidden; + text-overflow: ellipsis; +} + #rcmKSearchpane { border-radius: 0 0 4px 4px; border-top: 0; diff --git a/skins/larry/templates/compose.html b/skins/larry/templates/compose.html index 806939a42..0e4568bdd 100644 --- a/skins/larry/templates/compose.html +++ b/skins/larry/templates/compose.html @@ -30,6 +30,7 @@ + @@ -194,6 +195,16 @@
    +
    +
      +
    • + +
    • +
    • +
    • +
    +
    + -- cgit v1.2.3