From 10534276e501dd56ffe62f51e7b12a5035f3fb23 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 30 Sep 2018 21:08:40 +0530 Subject: [PATCH] Quill editor (#6159) * feat(Text Editor): Quill Editor * fix: Add imageDrop module - prevent default events * refactor(Comment): Comment is now frappe control - Use quill's bubble theme for comment control * feat: Support @mentions in comment area - Uses quill-mention package * fix: Use setContents to fix autofocus bug * fix: Spaces to Tabs * fix: Missing semicolon * fix: Fix style * fix: Remove all of summernote - Remove comment.js (use fieldtype: 'Comment') - Add quill styles to webform.css * fix: Replace color/background with indent/outdent --- frappe/public/build.json | 6 +- .../public/js/frappe/form/controls/comment.js | 124 ++++++ .../js/frappe/form/controls/text_editor.js | 371 +++--------------- .../public/js/frappe/form/footer/timeline.js | 85 ++-- .../js/frappe/form/footer/timeline_item.html | 1 + frappe/public/js/frappe/list/list_view.js | 2 +- frappe/public/js/frappe/ui/comment.js | 340 ---------------- frappe/public/less/desk.less | 62 +-- frappe/public/less/quill.less | 69 ++++ package.json | 3 + rollup/config.js | 17 + yarn.lock | 55 +++ 12 files changed, 375 insertions(+), 760 deletions(-) create mode 100644 frappe/public/js/frappe/form/controls/comment.js delete mode 100644 frappe/public/js/frappe/ui/comment.js create mode 100644 frappe/public/less/quill.less diff --git a/frappe/public/build.json b/frappe/public/build.json index 7f30bfec60..61fcd836e3 100755 --- a/frappe/public/build.json +++ b/frappe/public/build.json @@ -62,6 +62,7 @@ "public/js/frappe/form/controls/text.js", "public/js/frappe/form/controls/code.js", "public/js/frappe/form/controls/text_editor.js", + "public/js/frappe/form/controls/comment.js", "public/js/frappe/form/controls/check.js", "public/js/frappe/form/controls/image.js", "public/js/frappe/form/controls/attach.js", @@ -91,7 +92,6 @@ "public/js/frappe/ui/dialog.js" ], "css/desk.min.css": [ - "public/js/lib/summernote/summernote.css", "public/js/lib/leaflet/leaflet.css", "public/js/lib/leaflet/leaflet.draw.css", "public/js/lib/leaflet/L.Control.Locate.css", @@ -124,7 +124,6 @@ "public/js/lib/awesomplete/awesomplete.min.js", "public/js/lib/Sortable.min.js", "public/js/lib/jquery/jquery.hotkeys.js", - "public/js/lib/summernote/summernote.js", "public/js/lib/bootstrap.min.js", "node_modules/moment/min/moment-with-locales.min.js", "node_modules/moment-timezone/builds/moment-timezone-with-data.min.js", @@ -352,14 +351,13 @@ "js/web_form.min.js": [ "public/js/frappe/misc/datetime.js", "website/js/web_form.js", - "public/js/lib/summernote/summernote.js", "public/js/lib/datepicker/datepicker.min.js", "public/js/lib/datepicker/datepicker.en.js" ], "css/web_form.css": [ "public/less/list.less", "website/css/web_form.css", - "public/js/lib/summernote/summernote.css" + "public/less/quill.less" ], "js/print_format_v3.min.js": [ "public/js/legacy/layout.js", diff --git a/frappe/public/js/frappe/form/controls/comment.js b/frappe/public/js/frappe/form/controls/comment.js new file mode 100644 index 0000000000..9ca61135b7 --- /dev/null +++ b/frappe/public/js/frappe/form/controls/comment.js @@ -0,0 +1,124 @@ +import 'quill-mention'; + +frappe.ui.form.ControlComment = frappe.ui.form.ControlTextEditor.extend({ + make_wrapper() { + this.comment_wrapper = !this.no_wrapper ? $(` +
+
+ ${__("Add a comment")} + +
+
+
+
+ ${__("Ctrl+Enter to add comment")} +
+
+
+ `) : $('
'); + + this.comment_wrapper.appendTo(this.parent); + + // wrapper should point to frappe-control + this.$wrapper = !this.no_wrapper + ? this.comment_wrapper.find('.frappe-control') + : this.comment_wrapper; + + this.wrapper = this.$wrapper; + + this.button = this.comment_wrapper.find('.btn-comment'); + }, + + bind_events() { + this._super(); + + this.button.click(() => { + this.submit(); + }); + + this.$wrapper.on('keydown', e => { + const key = frappe.ui.keys.get_key(e); + if (key === 'ctrl+enter') { + e.preventDefault(); + this.submit(); + } + }); + + this.quill.on('text-change', frappe.utils.debounce(() => { + this.update_state(); + }, 300)); + }, + + submit() { + this.on_submit && this.on_submit(this.get_value()); + }, + + update_state() { + const value = this.get_value(); + if (strip_html(value)) { + this.button.removeClass('btn-default').addClass('btn-primary'); + } else { + this.button.addClass('btn-default').removeClass('btn-primary'); + } + }, + + get_quill_options() { + const options = this._super(); + return Object.assign(options, { + theme: 'bubble', + modules: Object.assign(options.modules, { + mention: this.get_mention_options() + }) + }); + }, + + get_mention_options() { + if (!(this.mentions && this.mentions.length)) { + return null; + } + + const at_values = this.mentions.map((value, i) => { + return { + id: i, + value + }; + }); + + return { + allowedChars: /^[A-Za-z0-9_]*$/, + mentionDenotationChars: ["@"], + isolateCharacter: true, + source: function(searchTerm, renderList, mentionChar) { + let values; + + if (mentionChar === "@") { + values = at_values; + } + + if (searchTerm.length === 0) { + renderList(values, searchTerm); + } else { + const matches = []; + for (let i = 0; i < values.length; i++) { + if (~values[i].value.toLowerCase().indexOf(searchTerm.toLowerCase())) { + matches.push(values[i]); + } + } + renderList(matches, searchTerm); + } + }, + }; + }, + + get_toolbar_options() { + return [ + ['bold', 'italic', 'underline'], + ['blockquote', 'code-block'], + ['link', 'image'], + [{ 'list': 'ordered' }, { 'list': 'bullet' }], + ['clean'] + ]; + }, +}); \ No newline at end of file diff --git a/frappe/public/js/frappe/form/controls/text_editor.js b/frappe/public/js/frappe/form/controls/text_editor.js index 04164aa650..ead134ebc1 100644 --- a/frappe/public/js/frappe/form/controls/text_editor.js +++ b/frappe/public/js/frappe/form/controls/text_editor.js @@ -1,338 +1,79 @@ +import Quill from 'quill/dist/quill'; +import { ImageDrop } from 'quill-image-drop-module'; + +Quill.register('modules/imageDrop', ImageDrop); + frappe.ui.form.ControlTextEditor = frappe.ui.form.ControlCode.extend({ - make_input: function() { + make_input() { this.has_input = true; - this.make_editor(); - this.hide_elements_on_mobile(); - this.setup_drag_drop(); - this.setup_image_dialog(); - this.setting_count = 0; - - $(document).on('form-refresh', () => { - // reset last keystroke when a new form is loaded - this.last_keystroke_on = null; - }) + this.make_quill_editor(); }, - render_camera_button: (context) => { - var ui = $.summernote.ui; - var button = ui.button({ - contents: '', - tooltip: 'Camera', - click: () => { - const capture = new frappe.ui.Capture(); - capture.show(); - - capture.submit((data) => { - context.invoke('editor.insertImage', data); - }); - } - }); - return button.render(); + make_quill_editor() { + if (this.quill) return; + this.quill_container = $('
').appendTo(this.input_area); + this.quill = new Quill(this.quill_container[0], this.get_quill_options()); + this.bind_events(); }, - make_editor: function() { - var me = this; - this.editor = $("
").appendTo(this.input_area); - // Note: while updating summernote, please make sure all 'p' blocks - // in the summernote source code are replaced by 'div' blocks. - // by default summernote, adds

blocks for new paragraphs, which adds - // unexpected whitespaces, esp for email replies. + bind_events() { + this.quill.on('text-change', frappe.utils.debounce(() => { + const input_value = this.get_input_value(); + if (this.value === input_value) return; - this.editor.summernote({ - minHeight: 400, - toolbar: [ - ['magic', ['style']], - ['style', ['bold', 'italic', 'underline', 'clear']], - ['fontsize', ['fontsize']], - ['color', ['color']], - ['para', ['ul', 'ol', 'paragraph', 'hr']], - //['height', ['height']], - ['media', ['link', 'picture', 'camera', 'video', 'table']], - ['misc', ['fullscreen', 'codeview']] - ], - buttons: { - camera: this.render_camera_button, - }, - keyMap: { - pc: { - 'CTRL+ENTER': '' - }, - mac: { - 'CMD+ENTER': '' - } - }, - prettifyHtml: true, - dialogsInBody: true, - callbacks: { - onInit: function() { - // firefox hack that puts the caret in the wrong position - // when div is empty. To fix, seed with a
. - // See https://bugzilla.mozilla.org/show_bug.cgi?id=550434 - // this function is executed only once - $(".note-editable[contenteditable='true']").one('focus', function() { - var $this = $(this); - if(!$this.html()) - $this.html($this.html() + '
'); - }); - }, - onChange: function(value) { - me.parse_validate_and_set_in_model(value); - }, - onKeydown: function(e) { - me.last_keystroke_on = new Date(); - var key = frappe.ui.keys.get_key(e); - // prevent 'New DocType (Ctrl + B)' shortcut in editor - if(['ctrl+b', 'meta+b'].indexOf(key) !== -1) { - e.stopPropagation(); - } - if(key.indexOf('escape') !== -1) { - if(me.note_editor.hasClass('fullscreen')) { - // exit fullscreen on escape key - me.note_editor - .find('.note-btn.btn-fullscreen') - .trigger('click'); - } - } - }, - }, - icons: { - 'align': 'fa fa-align', - 'alignCenter': 'fa fa-align-center', - 'alignJustify': 'fa fa-align-justify', - 'alignLeft': 'fa fa-align-left', - 'alignRight': 'fa fa-align-right', - 'indent': 'fa fa-indent', - 'outdent': 'fa fa-outdent', - 'arrowsAlt': 'fa fa-arrows-alt', - 'bold': 'fa fa-bold', - 'camera': 'fa fa-camera', - 'caret': 'caret', - 'circle': 'fa fa-circle', - 'close': 'fa fa-close', - 'code': 'fa fa-code', - 'eraser': 'fa fa-eraser', - 'font': 'fa fa-font', - 'frame': 'fa fa-frame', - 'italic': 'fa fa-italic', - 'link': 'fa fa-link', - 'unlink': 'fa fa-chain-broken', - 'magic': 'fa fa-magic', - 'menuCheck': 'fa fa-check', - 'minus': 'fa fa-minus', - 'orderedlist': 'fa fa-list-ol', - 'pencil': 'fa fa-pencil', - 'picture': 'fa fa-image', - 'question': 'fa fa-question', - 'redo': 'fa fa-redo', - 'square': 'fa fa-square', - 'strikethrough': 'fa fa-strikethrough', - 'subscript': 'fa fa-subscript', - 'superscript': 'fa fa-superscript', - 'table': 'fa fa-table', - 'textHeight': 'fa fa-text-height', - 'trash': 'fa fa-trash', - 'underline': 'fa fa-underline', - 'undo': 'fa fa-undo', - 'unorderedlist': 'fa fa-list-ul', - 'video': 'fa fa-video-camera' + this.parse_validate_and_set_in_model(input_value); + }, 300)); + + $(this.quill.root).on('keydown', (e) => { + const key = frappe.ui.keys.get_key(e); + if (['ctrl+b', 'meta+b'].includes(key)) { + e.stopPropagation(); } }); - this.note_editor = $(this.input_area).find('.note-editor'); - // to fix

on enter - //this.set_formatted_input('


'); - }, - setup_drag_drop: function() { - var me = this; - this.note_editor.on('dragenter dragover', false) - .on('drop', function(e) { - var dataTransfer = e.originalEvent.dataTransfer; - - if (dataTransfer && dataTransfer.files && dataTransfer.files.length) { - me.note_editor.focus(); - - var files = [].slice.call(dataTransfer.files); - files.forEach(file => { - me.get_image(file, (url) => { - me.editor.summernote('insertImage', url, file.name); - }); - }); - } - e.preventDefault(); - e.stopPropagation(); - }); + $(this.quill.root).on('drop', (e) => { + e.stopPropagation(); + }); }, - get_image: function (fileobj, callback) { - var reader = new FileReader(); - reader.onload = function() { - var dataurl = reader.result; - // add filename to dataurl - var parts = dataurl.split(","); - parts[0] += ";filename=" + fileobj.name; - dataurl = parts[0] + ',' + parts[1]; - callback(dataurl); + get_quill_options() { + return { + modules: { + toolbar: this.get_toolbar_options(), + imageDrop: true + }, + theme: 'snow' }; - reader.readAsDataURL(fileobj); - }, - hide_elements_on_mobile: function() { - this.note_editor.find('.note-btn-underline,\ - .note-btn-italic, .note-fontsize,\ - .note-color, .note-height, .btn-codeview') - .addClass('hidden-xs'); - if($('.toggle-sidebar').is(':visible')) { - // disable tooltips on mobile - this.note_editor.find('.note-btn') - .attr('data-original-title', ''); - } - }, - get_input_value: function() { - return this.editor? this.editor.summernote('code'): ''; - }, - parse: function(value) { - if(value == null) value = ""; - return frappe.dom.remove_script_and_style(value); }, - set_formatted_input: function(value) { - if(value !== this.get_input_value()) { - this.set_in_editor(value); - } - }, - set_in_editor: function(value) { - // set values in editor only if - // 1. value not be set in the last 500ms - // 2. user has not typed anything in the last 3seconds - // --- - // we will attempt to cleanup the user's DOM, hence if this happens - // in the middle of the user is typing, it creates a lot of issues - // also firefox tends to reset the cursor for some reason if the values - // are reset - - if(this.setting_count > 2) { - // we don't understand how the internal triggers work, - // so if someone is setting the value third time in 500ms, - // then quit - return; - } - - this.setting_count += 1; - - let time_since_last_keystroke = moment() - moment(this.last_keystroke_on); - if(!this.last_keystroke_on || (time_since_last_keystroke > 3000)) { - // if 3 seconds have passed since the last keystroke and - // we have not set any value in the last 1 second, do this - setTimeout(() => this.setting_count = 0, 500); - this.editor.summernote('code', value || ''); - this.last_keystroke_on = null; - } else { - // user is probably still in the middle of typing - // so lets not mess up the html by re-updating it - // keep checking every second if our 3 second barrier - // has been completed, so that we can refresh the html - this._setting_value = setInterval(() => { - if(time_since_last_keystroke > 3000) { - // 3 seconds done! lets refresh - // safe to update - if(this.last_value !== this.get_input_value()) { - // if not already in sync, reset - this.editor.summernote('code', this.last_value || ''); - } - clearInterval(this._setting_value); - this._setting_value = null; - this.setting_count = 0; - - // clear timestamp of last keystroke - this.last_keystroke_on = null; - } - }, 1000); - } - }, - set_focus: function() { - return this.editor.summernote('focus'); + get_toolbar_options() { + return [ + [{ 'header': [1, 2, 3, false] }], + ['bold', 'italic', 'underline'], + ['blockquote', 'code-block'], + ['link', 'image'], + [{ 'list': 'ordered' }, { 'list': 'bullet' }], + [{ 'align': [] }], + [{ 'indent': '-1'}, { 'indent': '+1' }], + [{ 'font': [] }], + ['clean'] + ]; }, - set_upload_options: function() { - var me = this; - this.upload_options = { - parent: this.image_dialog.get_field("upload_area").$wrapper, - args: {}, - max_width: this.df.max_width, - max_height: this.df.max_height, - options: "Image", - no_socketio: true, - btn: this.image_dialog.set_primary_action(__("Insert")), - on_no_attach: function() { - // if no attachmemts, - // check if something is selected - var selected = me.image_dialog.get_field("select").get_value(); - if(selected) { - me.editor.summernote('insertImage', selected); - me.image_dialog.hide(); - } else { - frappe.msgprint(__("Please attach a file or set a URL")); - } - }, - callback: function(attachment) { - me.editor.summernote('insertImage', attachment.file_url, attachment.file_name); - me.image_dialog.hide(); - }, - onerror: function() { - me.image_dialog.hide(); - } - }; - - if ("is_private" in this.df) { - this.upload_options.is_private = this.df.is_private; - } - if(this.frm) { - this.upload_options.args = { - from_form: 1, - doctype: this.frm.doctype, - docname: this.frm.docname - }; - } else { - this.upload_options.on_attach = function(fileobj, dataurl) { - me.editor.summernote('insertImage', dataurl); - me.image_dialog.hide(); - frappe.hide_progress(); - }; + parse(value) { + if (value == null) { + value = ""; } + return frappe.dom.remove_script_and_style(value); }, - setup_image_dialog: function() { - this.note_editor.find('[data-original-title="Image"]').on('click', () => { - if(!this.image_dialog) { - this.image_dialog = new frappe.ui.Dialog({ - title: __("Image"), - fields: [ - {fieldtype:"HTML", fieldname:"upload_area"}, - {fieldtype:"HTML", fieldname:"or_attach", options: __("Or")}, - {fieldtype:"Select", fieldname:"select", label:__("Select from existing attachments") }, - ] - }); - } - - this.image_dialog.show(); - this.image_dialog.get_field("upload_area").$wrapper.empty(); - - // select from existing attachments - var attachments = this.frm && this.frm.attachments.get_attachments() || []; - var select = this.image_dialog.get_field("select"); - if(attachments.length) { - attachments = $.map(attachments, function(o) { return o.file_url; }); - select.df.options = [""].concat(attachments); - select.toggle(true); - this.image_dialog.get_field("or_attach").toggle(true); - select.refresh(); - } else { - this.image_dialog.get_field("or_attach").toggle(false); - select.toggle(false); - } - select.$input.val(""); + set_formatted_input(value) { + if (!this.quill) return; + if (value === this.get_input_value()) return; + this.quill.setContents(this.quill.clipboard.convert(value)); + }, - this.set_upload_options(); - frappe.upload.make(this.upload_options); - }); + get_input_value() { + return this.quill ? this.quill.root.innerHTML : ''; } }); diff --git a/frappe/public/js/frappe/form/footer/timeline.js b/frappe/public/js/frappe/form/footer/timeline.js index ef23e46da9..1187282953 100644 --- a/frappe/public/js/frappe/form/footer/timeline.js +++ b/frappe/public/js/frappe/form/footer/timeline.js @@ -15,17 +15,21 @@ frappe.ui.form.Timeline = Class.extend({ this.list = this.wrapper.find(".timeline-items"); - this.comment_area = new frappe.ui.CommentArea({ + this.comment_area = frappe.ui.form.make_control({ parent: this.wrapper.find('.timeline-head'), + df: { + fieldtype: 'Comment', + fieldname: 'comment', + label: 'Comment' + }, mentions: this.get_names_for_mentions(), + render_input: true, + only_input: true, on_submit: (val) => { - val && this.insert_comment( - "Comment", val, this.comment_area.button); + val && this.insert_comment("Comment", val, this.comment_area.button); } }); - this.setup_editing_area(); - this.setup_email_button(); this.list.on("click", ".toggle-blockquote", function() { @@ -87,25 +91,13 @@ frappe.ui.form.Timeline = Class.extend({ }); } else { $.extend(args, { - txt: frappe.markdown(me.comment_area.val()) + txt: frappe.markdown(me.comment_area.get_value()) }); } new frappe.views.CommunicationComposer(args) }); }, - setup_editing_area: function() { - this.$editing_area = $('
'); - - this.editing_area = new frappe.ui.CommentArea({ - parent: this.$editing_area, - mentions: this.get_names_for_mentions(), - no_wrapper: true - }); - - this.editing_area.destroy(); - }, - refresh: function(scroll_to_end) { var me = this; @@ -117,7 +109,7 @@ frappe.ui.form.Timeline = Class.extend({ } this.wrapper.toggle(true); this.list.empty(); - this.comment_area.val(''); + this.comment_area.set_value(''); var communications = this.get_communications(true); var views = this.get_view_logs(); @@ -159,6 +151,21 @@ frappe.ui.form.Timeline = Class.extend({ this.frm.trigger('timeline_refresh'); }, + make_editing_area(container) { + return frappe.ui.form.make_control({ + parent: container, + df: { + fieldtype: 'Comment', + fieldname: 'comment', + label: 'Comment' + }, + mentions: this.get_names_for_mentions(), + render_input: true, + only_input: true, + no_wrapper: true + }); + }, + render_timeline_item: function(c) { var me = this; this.prepare_timeline_item(c); @@ -174,22 +181,31 @@ frappe.ui.form.Timeline = Class.extend({ var name = $timeline_item.data('name'); if($timeline_item.hasClass('is-editing')) { - me.editing_area.submit(); - me.$editing_area.detach(); + me.current_editing_area.submit(); } else { - var $edit_btn = $(this); - var content = $timeline_item.find('.timeline-item-content').html(); + const $edit_btn = $(this); + const $timeline_content = $timeline_item.find('.timeline-item-content'); + const $timeline_edit = $timeline_item.find('.timeline-item-edit'); + const content = $timeline_content.html(); + + // update state $edit_btn - .text("Save") + .text(__("Save")) .find('i') .removeClass('octicon-pencil') .addClass('octicon-check'); + $timeline_content.hide(); + $timeline_item.addClass('is-editing'); + + // initialize editing area + me.current_editing_area = me.make_editing_area($timeline_edit); + me.current_editing_area.set_value(content); + + // submit handler + me.current_editing_area.on_submit = (value) => { + $timeline_edit.empty(); + $timeline_content.show(); - me.editing_area.setup_summernote(); - me.editing_area.val(content); - me.editing_area.on_submit = (value) => { - me.editing_area.destroy(); - value = value.trim(); // set content to new val so that on save and refresh the new content is shown c.content = value; frappe.timeline.update_communication(c); @@ -197,15 +213,6 @@ frappe.ui.form.Timeline = Class.extend({ // all changes to the timeline_item for editing are reset after calling refresh me.refresh(); }; - - $timeline_item - .find('.timeline-item-content') - .hide(); - $timeline_item - .find('.timeline-content-show') - .append(me.$editing_area); - $timeline_item - .addClass('is-editing'); } return false; @@ -570,7 +577,7 @@ frappe.ui.form.Timeline = Class.extend({ btn: btn, callback: function(r) { if(!r.exc) { - me.comment_area.val(''); + me.comment_area.set_value(''); frappe.utils.play_sound("click"); var comment = r.message; diff --git a/frappe/public/js/frappe/form/footer/timeline_item.html b/frappe/public/js/frappe/form/footer/timeline_item.html index 54d1a34d47..558e287175 100755 --- a/frappe/public/js/frappe/form/footer/timeline_item.html +++ b/frappe/public/js/frappe/form/footer/timeline_item.html @@ -128,6 +128,7 @@ {%= data.content_html %}
+
{% if(data.attachments && data.attachments.length) { %}
{% $.each(data.attachments, function(i, a) { %} diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 563024707b..68fb67f7ec 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -605,7 +605,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { let subject_field = this.columns[0].df; let value = doc[subject_field.fieldname] || doc.name; let subject = strip_html(value); - let escaped_subject = frappe.utils.escape_html(value); + let escaped_subject = frappe.utils.escape_html(subject); const liked_by = JSON.parse(doc._liked_by || '[]'); let heart_class = liked_by.includes(user) ? diff --git a/frappe/public/js/frappe/ui/comment.js b/frappe/public/js/frappe/ui/comment.js deleted file mode 100644 index 1ba73a4349..0000000000 --- a/frappe/public/js/frappe/ui/comment.js +++ /dev/null @@ -1,340 +0,0 @@ -/** - * CommentArea: A small rich text editor with - * support for @mentions and :emojis: - * @example - * let comment_area = new frappe.ui.CommentArea({ - * parent: '.comment-area', - * mentions: ['john', 'mary', 'kate'], - * on_submit: (value) => save_to_database(value) - * }); - */ - -frappe.provide('frappe.ui'); -frappe.provide('frappe.chat'); - -frappe.ui.CommentArea = class CommentArea { - - constructor({ parent = null, mentions = [], on_submit = null, no_wrapper = false }) { - this.parent = $(parent); - this.mentions = mentions; - this.on_submit = on_submit; - this.no_wrapper = no_wrapper; - - this.make(); - - // Load emojis initially from https://git.io/frappe-emoji - frappe.chat.emoji(); - // All good. - } - - make() { - this.setup_dom(); - this.setup_summernote(); - this.bind_events(); - } - - setup_dom() { - const header = !this.no_wrapper ? - `
- ${__("Add a comment")} - -
` : ''; - - const footer = !this.no_wrapper ? - `
- ${__("Ctrl+Enter to add comment")} -
` : ''; - - this.wrapper = $(` -
- ${ header } -
-
- ${ footer } -
-
- `); - this.wrapper.appendTo(this.parent); - this.input = this.parent.find('.comment-input'); - this.button = this.parent.find('.btn-comment'); - } - - setup_summernote() { - const { input, button } = this; - - input.summernote({ - height: 100, - toolbar: false, - airMode: true, - hint: { - mentions: this.mentions, - match: /\B([@:]\w*)/, - search: function (keyword, callback) { - let items = []; - if (keyword.startsWith('@')) { - keyword = keyword.substr(1); - items = this.mentions; - } else if (keyword.startsWith(':')) { - frappe.chat.emoji(emojis => { // Returns cached, else fetch. - const query = keyword.slice(1); - const items = [ ]; - for (const emoji of emojis) - for (const alias of emoji.aliases) - if ( alias.indexOf(query) === 0 ) - items.push({ emoji: true, name: alias, value: emoji.emoji }); - callback(items); - }); - } - - callback($.grep(items, function (item) { - return item.indexOf(keyword) == 0; - })); - }, - template: function (item) { - if ( item.emoji ) { - return item.value + ' ' + item.name; - } else { - return item; - } - }, - content: function (item) { - if ( item.emoji ) { - return item.value; - } else { - return '@' + item; - } - } - }, - callbacks: { - onChange: () => { - this.set_state(); - }, - onKeydown: (e) => { - var key = frappe.ui.keys.get_key(e); - if(key === 'ctrl+enter') { - e.preventDefault(); - this.submit(); - } - e.stopPropagation(); - }, - }, - icons: { - 'align': 'fa fa-align', - 'alignCenter': 'fa fa-align-center', - 'alignJustify': 'fa fa-align-justify', - 'alignLeft': 'fa fa-align-left', - 'alignRight': 'fa fa-align-right', - 'indent': 'fa fa-indent', - 'outdent': 'fa fa-outdent', - 'arrowsAlt': 'fa fa-arrows-alt', - 'bold': 'fa fa-bold', - 'caret': 'caret', - 'circle': 'fa fa-circle', - 'close': 'fa fa-close', - 'code': 'fa fa-code', - 'eraser': 'fa fa-eraser', - 'font': 'fa fa-font', - 'frame': 'fa fa-frame', - 'italic': 'fa fa-italic', - 'link': 'fa fa-link', - 'unlink': 'fa fa-chain-broken', - 'magic': 'fa fa-magic', - 'menuCheck': 'fa fa-check', - 'minus': 'fa fa-minus', - 'orderedlist': 'fa fa-list-ol', - 'pencil': 'fa fa-pencil', - 'picture': 'fa fa-image', - 'question': 'fa fa-question', - 'redo': 'fa fa-redo', - 'square': 'fa fa-square', - 'strikethrough': 'fa fa-strikethrough', - 'subscript': 'fa fa-subscript', - 'superscript': 'fa fa-superscript', - 'table': 'fa fa-table', - 'textHeight': 'fa fa-text-height', - 'trash': 'fa fa-trash', - 'underline': 'fa fa-underline', - 'undo': 'fa fa-undo', - 'unorderedlist': 'fa fa-list-ul', - 'video': 'fa fa-video-camera' - } - }); - - this.note_editor = this.wrapper.find('.note-editor'); - this.note_editor.css({ - 'border': '1px solid #ebeff2', - 'border-radius': '3px', - 'padding': '10px', - 'margin-bottom': '10px', - 'min-height': '80px', - 'cursor': 'text' - }); - this.note_editor.on('click', () => input.summernote('focus')); - } - - check_state() { - return !(this.input.summernote('isEmpty')); - } - - set_state() { - if(this.check_state()) { - this.button - .removeClass('btn-default') - .addClass('btn-primary'); - } else { - this.button - .removeClass('btn-primary') - .addClass('btn-default'); - } - } - - reset() { - this.val(''); - } - - destroy() { - this.input.summernote('destroy'); - } - - bind_events() { - this.button.on('click', this.submit.bind(this)); - } - - val(value) { - // Return html if no value specified - if(value === undefined) { - return this.input.summernote('code'); - } - // Set html if value is specified - this.input.summernote('code', value); - } - - submit() { - // Pass comment's value (html) to submit handler - this.on_submit && this.on_submit(this.val()); - } -}; - -frappe.ui.ReviewArea = class ReviewArea extends frappe.ui.CommentArea { - setup_dom() { - const header = !this.no_wrapper ? - `
- ${__("Add your review")} - -
` : ''; - - const footer = !this.no_wrapper ? - `
- ${__("Ctrl+Enter to submit")} -
` : ''; - - const rating_area = !this.no_wrapper ? - `
- ${ __("Your rating: ") } - - - - - -
` : ''; - - this.wrapper = $(` -
- ${ header } -
- ${ rating_area } -
- - -
- ${ footer } -
-
-
- `); - this.wrapper.appendTo(this.parent); - this.input = this.parent.find('.comment-input'); - this.subject = this.parent.find('.review-subject'); - this.button = this.parent.find('.btn-comment'); - this.ratingArea = this.parent.find('.rating-area'); - - this.rating = 0; - } - - input_has_value() { - return !(this.input.summernote('isEmpty') || - this.rating === 0 || !this.subject.val().length); - } - - set_state() { - if (this.rating === 0) { - this.parent.find('.comment-input-body').hide(); - } else { - this.parent.find('.comment-input-body').show(); - } - - if(this.input_has_value()) { - this.button - .removeClass('btn-default disabled') - .addClass('btn-primary'); - } else { - this.button - .removeClass('btn-primary') - .addClass('btn-default disabled'); - } - } - - reset() { - this.set_rating(0); - this.subject.val(''); - this.input.summernote('code', ''); - } - - bind_events() { - super.bind_events(); - this.ratingArea.on('click', '.star-icon', (e) => { - let index = $(e.target).attr('data-index'); - this.set_rating(parseInt(index) + 1); - }) - - this.subject.on('change', () => { - this.set_state(); - }); - - this.set_state(); - } - - set_rating(rating) { - this.ratingArea.find('.star-icon').each((i, icon) => { - let star = $(icon); - if(i < rating) { - star.removeClass('fa-star-o'); - star.addClass('fa-star'); - } else { - star.removeClass('fa-star'); - star.addClass('fa-star-o'); - } - }) - - this.rating = rating; - this.set_state(); - } - - val(value) { - if(value === undefined) { - return { - rating: this.rating, - subject: this.subject.val(), - content: this.input.summernote('code') - } - } - // Set html if value is specified - this.input.summernote('code', value); - } -} diff --git a/frappe/public/less/desk.less b/frappe/public/less/desk.less index c0034af1fc..7cfa90073d 100644 --- a/frappe/public/less/desk.less +++ b/frappe/public/less/desk.less @@ -1,6 +1,7 @@ @import "variables.less"; @import "mixins.less"; @import "common.less"; +@import "quill.less"; .nav-pills a, .nav-pills a:hover { border-bottom: none; @@ -516,63 +517,6 @@ li.user-progress { margin-bottom: 24px; } -// summernote editor -.note-editor { - margin-top: 5px; - - &.note-frame { - border-color: @border-color; - } - - .btn { - outline: none !important; - } - - .dropdown-style > li > a > * { - margin: 0; - } - .fa.fa-check { - color: @text-color !important; - } - .dropdown-menu { - z-index: 100; - max-height: 300px; - overflow: auto; - } - .note-image-input { - height: auto; - } -} - -// hide some buttons in modal -.modal .note-editor { - .note-btn-italic, - .note-btn-underline, - [data-original-title="Font Size"], - [data-original-title="Video"], - [data-original-title="Table"] { - display: none; - } -} - -.note-hint-popover { - border-radius: 3px; - border-color: @border-color; - padding: 0; - - .popover-content { - padding: 0; - } - - .note-hint-item { - color: @text-color !important; - padding: 5px 8.8px !important; - } - .note-hint-item.active { - background-color: @btn-bg !important; - } -} - .search-dialog { .modal-dialog { width: 768px; @@ -812,10 +756,6 @@ li.user-progress { } } -.note-editor.note-frame .note-editing-area .note-editable { - color: @text-color; -} - .input-area input[type=checkbox] { margin-left: -20px; } diff --git a/frappe/public/less/quill.less b/frappe/public/less/quill.less new file mode 100644 index 0000000000..e6e45e6bb4 --- /dev/null +++ b/frappe/public/less/quill.less @@ -0,0 +1,69 @@ +@import "variables.less"; +@import (less) "quill/dist/quill.snow.css"; +@import (less) "quill/dist/quill.bubble.css"; +@import (less) "quill-mention/src/quill.mention.css"; + +.ql-toolbar.ql-snow, .ql-container.ql-snow { + border-color: @border-color; + font-family: inherit; +} + +.ql-toolbar.ql-snow { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + background-color: @panel-bg; + padding-bottom: 0; +} + +.ql-container.ql-snow { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} + +.ql-snow .ql-editor { + min-height: 400px; + max-height: 600px; +} + +.ql-snow .ql-picker-label { + outline: none; +} + +.ql-formats { + margin-bottom: 8px; +} + +.ql-bubble .ql-editor { + min-height: 100px; + max-height: 300px; + border: 1px solid @light-border-color; + border-radius: 4px; +} + +.ql-mention-list-container { + z-index: 1; +} + +.ql-mention-list { + border-radius: 4px; +} + +.ql-mention-list-item { + font-size: @text-small; + padding: 10px 12px; + height: initial; + line-height: initial; + + &.selected { + background-color: @btn-bg; + } +} + +.ql-editor .mention { + height: auto; + width: auto; + border-radius: 10px; + border: 1px solid @light-border-color; + padding: 2px 3px; + background-color: @btn-bg; +} \ No newline at end of file diff --git a/package.json b/package.json index 38f860ddea..76aff59f49 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "jsbarcode": "^3.9.0", "moment": "^2.20.1", "moment-timezone": "^0.5.21", + "quill": "^1.3.6", + "quill-image-drop-module": "^1.0.3", + "quill-mention": "^2.0.2", "redis": "^2.8.0", "showdown": "^1.8.6", "socket.io": "^2.0.4", diff --git a/rollup/config.js b/rollup/config.js index 6aba903462..72452b21b1 100644 --- a/rollup/config.js +++ b/rollup/config.js @@ -45,6 +45,8 @@ function get_rollup_options_for_js(output_file, input_files) { multi_entry(), // .html -> .js frappe_html(), + // ignore css imports + ignore_css(), // .vue -> .js vue.default(), // ES6 -> ES5 @@ -163,6 +165,21 @@ function get_options_for(app) { .filter(Boolean); } +function ignore_css() { + return { + name: 'ignore-css', + transform(code, id) { + if (!['.css', '.scss', '.sass', '.less'].some(ext => id.endsWith(ext))) { + return null; + } + + return ` + // ignored ${id} + `; + } + }; +}; + module.exports = { get_options_for }; diff --git a/yarn.lock b/yarn.lock index 30d4f2bf05..6c63b46bde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -449,6 +449,10 @@ clone@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.3.tgz#298d7e2231660f40c003c2ed3140decf3f53085f" +clone@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + clusterize.js@^0.18.0: version "0.18.1" resolved "https://registry.yarnpkg.com/clusterize.js/-/clusterize.js-0.18.1.tgz#a286a9749bd1fa9c2fe21b7fabd8780a590dd836" @@ -713,6 +717,10 @@ decamelize@^1.1.1, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" +deep-equal@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + defined@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" @@ -848,6 +856,10 @@ etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" +eventemitter3@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-2.0.3.tgz#b5e1079b59fb5e1ba2771c0a993be060a58c99ba" + execa@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" @@ -923,6 +935,10 @@ extend@^3.0.0, extend@~3.0.0, extend@~3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" +extend@^3.0.1, extend@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + extglob@^0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" @@ -937,6 +953,10 @@ fast-deep-equal@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614" +fast-diff@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.1.2.tgz#4b62c42b8e03de3f848460b639079920695d0154" + fast-json-stable-stringify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" @@ -2072,6 +2092,10 @@ p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" +parchment@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/parchment/-/parchment-1.1.4.tgz#aeded7ab938fe921d4c34bc339ce1168bc2ffde5" + parse-glob@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" @@ -2548,6 +2572,37 @@ querystring@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" +quill-delta@^3.6.2: + version "3.6.3" + resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-3.6.3.tgz#b19fd2b89412301c60e1ff213d8d860eac0f1032" + dependencies: + deep-equal "^1.0.1" + extend "^3.0.2" + fast-diff "1.1.2" + +quill-image-drop-module@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/quill-image-drop-module/-/quill-image-drop-module-1.0.3.tgz#0e5ec8329dd67a12126f166b191bf64d2057a7d3" + dependencies: + quill "^1.2.2" + +quill-mention@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/quill-mention/-/quill-mention-2.0.2.tgz#8e89e1d6b625d2df1b5a04af9338e35b18e91fce" + dependencies: + quill "^1.3.4" + +quill@^1.2.2, quill@^1.3.4, quill@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/quill/-/quill-1.3.6.tgz#99f4de1fee85925a0d7d4163b6d8328f23317a4d" + dependencies: + clone "^2.1.1" + deep-equal "^1.0.1" + eventemitter3 "^2.0.3" + extend "^3.0.1" + parchment "^1.1.4" + quill-delta "^3.6.2" + randomatic@^1.1.3: version "1.1.7" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.7.tgz#c7abe9cc8b87c0baa876b19fde83fd464797e38c"