From d1c5c263b55062bb9e5f7eaea2a2996566009db1 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 22 Oct 2018 16:12:17 +0530 Subject: [PATCH] fix(Quill): Use a local copy of quill-mention quill-mention's distribution copy is built with webpack so it doesn't work with rollup --- .../public/js/frappe/form/controls/comment.js | 5 +- .../controls/quill-mention/blots/mention.js | 37 ++ .../controls/quill-mention/constants/keys.js | 9 + .../controls/quill-mention/quill.mention.css | 40 ++ .../controls/quill-mention/quill.mention.js | 365 ++++++++++++++++++ .../js/frappe/form/controls/text_editor.js | 2 - frappe/public/js/frappe/ui/dialog.js | 2 +- frappe/public/less/quill.less | 2 +- package.json | 1 - rollup/config.js | 3 +- yarn.lock | 8 +- 11 files changed, 460 insertions(+), 14 deletions(-) create mode 100644 frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js create mode 100644 frappe/public/js/frappe/form/controls/quill-mention/constants/keys.js create mode 100644 frappe/public/js/frappe/form/controls/quill-mention/quill.mention.css create mode 100644 frappe/public/js/frappe/form/controls/quill-mention/quill.mention.js diff --git a/frappe/public/js/frappe/form/controls/comment.js b/frappe/public/js/frappe/form/controls/comment.js index c343e2f878..bd7189d5f3 100644 --- a/frappe/public/js/frappe/form/controls/comment.js +++ b/frappe/public/js/frappe/form/controls/comment.js @@ -1,4 +1,7 @@ -import 'quill-mention/dist/quill.mention.min'; +import Quill from 'quill'; +import Mention from './quill-mention/quill.mention'; + +Quill.register('modules/mention', Mention); frappe.ui.form.ControlComment = frappe.ui.form.ControlTextEditor.extend({ make_wrapper() { diff --git a/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js b/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js new file mode 100644 index 0000000000..4013e381d5 --- /dev/null +++ b/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js @@ -0,0 +1,37 @@ +import Quill from 'quill'; + +const Embed = Quill.import('blots/embed'); + + +class MentionBlot extends Embed { + static create(data) { + const node = super.create(); + const denotationChar = document.createElement('span'); + denotationChar.className = 'ql-mention-denotation-char'; + denotationChar.innerHTML = data.denotationChar; + node.appendChild(denotationChar); + node.innerHTML += data.value; + node.dataset.id = data.id; + node.dataset.value = data.value; + node.dataset.denotationChar = data.denotationChar; + if (data.link) { + node.dataset.link = data.link; + } + return node; + } + + static value(domNode) { + return { + id: domNode.dataset.id, + value: domNode.dataset.value, + link: domNode.dataset.link || null, + denotationChar: domNode.dataset.denotationChar, + }; + } +} + +MentionBlot.blotName = 'mention'; +MentionBlot.tagName = 'span'; +MentionBlot.className = 'mention'; + +Quill.register(MentionBlot); diff --git a/frappe/public/js/frappe/form/controls/quill-mention/constants/keys.js b/frappe/public/js/frappe/form/controls/quill-mention/constants/keys.js new file mode 100644 index 0000000000..bb3245de2b --- /dev/null +++ b/frappe/public/js/frappe/form/controls/quill-mention/constants/keys.js @@ -0,0 +1,9 @@ +const Keys = { + TAB: 'Tab', + ENTER: 'Enter', + ESCAPE: 27, + UP: 'ArrowUp', + DOWN: 'ArrowDown', +}; + +export default Keys; diff --git a/frappe/public/js/frappe/form/controls/quill-mention/quill.mention.css b/frappe/public/js/frappe/form/controls/quill-mention/quill.mention.css new file mode 100644 index 0000000000..2e79d97213 --- /dev/null +++ b/frappe/public/js/frappe/form/controls/quill-mention/quill.mention.css @@ -0,0 +1,40 @@ +.ql-mention-list-container { + width: 270px; + border: 1px solid #F0F0F0; + border-radius: 4px; + background-color: #FFFFFF; + box-shadow: 0 2px 12px 0 rgba(30, 30, 30, 0.08); +} + +.ql-mention-list { + list-style: none; + margin: 0; + padding: 0; + overflow: hidden; +} + +.ql-mention-list-item { + cursor: pointer; + height: 44px; + line-height: 44px; + font-size: 16px; + padding: 0 20px; + vertical-align: middle; +} + +.ql-mention-list-item.selected { + background-color: #D3E1EB; + text-decoration: none; +} + +.mention { + height: 24px; + width: 65px; + border-radius: 6px; + background-color: #D3E1EB; + padding: 3px 0; +} + +.mention>span { + margin: 0 3px; +} \ No newline at end of file diff --git a/frappe/public/js/frappe/form/controls/quill-mention/quill.mention.js b/frappe/public/js/frappe/form/controls/quill-mention/quill.mention.js new file mode 100644 index 0000000000..e7bd508889 --- /dev/null +++ b/frappe/public/js/frappe/form/controls/quill-mention/quill.mention.js @@ -0,0 +1,365 @@ +import Quill from 'quill'; +import Keys from './constants/keys'; +import './quill.mention.css'; +import './blots/mention'; + + +class Mention { + constructor(quill, options) { + this.isOpen = false; + this.itemIndex = 0; + this.mentionCharPos = null; + this.cursorPos = null; + this.values = []; + this.suspendMouseEnter = false; + + this.quill = quill; + + this.options = { + source: null, + renderItem(item, searchTerm) { + return `${item.value}`; + }, + mentionDenotationChars: ['@'], + allowedChars: /^[a-zA-Z0-9_]*$/, + minChars: 0, + maxChars: 31, + offsetTop: 2, + offsetLeft: 0, + isolateCharacter: false, + fixMentionsToQuill: false, + defaultMenuOrientation: 'bottom', + }; + + Object.assign(this.options, options); + + this.mentionContainer = document.createElement('div'); + this.mentionContainer.className = 'ql-mention-list-container'; + this.mentionContainer.style.cssText = 'display: none; position: absolute;'; + this.mentionContainer.onmousemove = this.onContainerMouseMove.bind(this); + + if (this.options.fixMentionsToQuill) { + this.mentionContainer.style.width = 'auto'; + } + + this.mentionList = document.createElement('ul'); + this.mentionList.className = 'ql-mention-list'; + this.mentionContainer.appendChild(this.mentionList); + + this.quill.container.appendChild(this.mentionContainer); + + quill.on('text-change', this.onTextChange.bind(this)); + quill.on('selection-change', this.onSelectionChange.bind(this)); + + quill.keyboard.addBinding({ + key: Keys.TAB, + }, this.selectHandler.bind(this)); + quill.keyboard.bindings[Keys.TAB].unshift(quill.keyboard.bindings[Keys.TAB].pop()); + + quill.keyboard.addBinding({ + key: Keys.ENTER, + }, this.selectHandler.bind(this)); + quill.keyboard.bindings[Keys.ENTER].unshift(quill.keyboard.bindings[Keys.ENTER].pop()); + + quill.keyboard.addBinding({ + key: Keys.ESCAPE, + }, this.escapeHandler.bind(this)); + + quill.keyboard.addBinding({ + key: Keys.UP, + }, this.upHandler.bind(this)); + + quill.keyboard.addBinding({ + key: Keys.DOWN, + }, this.downHandler.bind(this)); + } + + selectHandler() { + if (this.isOpen) { + this.selectItem(); + return false; + } + return true; + } + + escapeHandler() { + if (this.isOpen) { + this.hideMentionList(); + return false; + } + return true; + } + + upHandler() { + if (this.isOpen) { + this.prevItem(); + return false; + } + return true; + } + + downHandler() { + if (this.isOpen) { + this.nextItem(); + return false; + } + return true; + } + + showMentionList() { + this.mentionContainer.style.visibility = 'hidden'; + this.mentionContainer.style.display = ''; + this.setMentionContainerPosition(); + this.isOpen = true; + } + + hideMentionList() { + this.mentionContainer.style.display = 'none'; + this.isOpen = false; + } + + highlightItem(scrollItemInView = true) { + for (let i = 0; i < this.mentionList.childNodes.length; i += 1) { + this.mentionList.childNodes[i].classList.remove('selected'); + } + this.mentionList.childNodes[this.itemIndex].classList.add('selected'); + + if (scrollItemInView) { + const itemHeight = this.mentionList.childNodes[this.itemIndex].offsetHeight; + const itemPos = this.itemIndex * itemHeight; + const containerTop = this.mentionContainer.scrollTop; + const containerBottom = containerTop + this.mentionContainer.offsetHeight; + + if (itemPos < containerTop) { + // Scroll up if the item is above the top of the container + this.mentionContainer.scrollTop = itemPos; + } else if (itemPos > (containerBottom - itemHeight)) { + // scroll down if any part of the element is below the bottom of the container + this.mentionContainer.scrollTop += (itemPos - containerBottom) + itemHeight; + } + } + } + + getItemData() { + const itemLink = this.mentionList.childNodes[this.itemIndex].dataset.link; + return { + id: this.mentionList.childNodes[this.itemIndex].dataset.id, + value: itemLink ? + `${this.mentionList.childNodes[this.itemIndex].dataset.value}` : + this.mentionList.childNodes[this.itemIndex].dataset.value, + link: itemLink || null, + denotationChar: this.mentionList.childNodes[this.itemIndex].dataset.denotationChar, + }; + } + + onContainerMouseMove() { + this.suspendMouseEnter = false; + } + + selectItem() { + const data = this.getItemData(); + this.quill + .deleteText(this.mentionCharPos, this.cursorPos - this.mentionCharPos, Quill.sources.API); + this.quill.insertEmbed(this.mentionCharPos, 'mention', data, Quill.sources.API); + this.quill.insertText(this.mentionCharPos + 1, ' ', Quill.sources.API); + this.quill.setSelection(this.mentionCharPos + 2, Quill.sources.API); + this.hideMentionList(); + } + + onItemMouseEnter(e) { + if (this.suspendMouseEnter) { + return; + } + + const index = Number(e.target.dataset.index); + + if (!Number.isNaN(index) && index !== this.itemIndex) { + this.itemIndex = index; + this.highlightItem(false); + } + } + + onItemClick(e) { + e.stopImmediatePropagation(); + e.preventDefault(); + this.itemIndex = e.currentTarget.dataset.index; + this.highlightItem(); + this.selectItem(); + } + + renderList(mentionChar, data, searchTerm) { + if (data && data.length > 0) { + this.values = data; + this.mentionList.innerHTML = ''; + for (let i = 0; i < data.length; i += 1) { + const li = document.createElement('li'); + li.className = 'ql-mention-list-item'; + li.dataset.index = i; + li.dataset.id = data[i].id; + li.dataset.value = data[i].value; + li.dataset.denotationChar = mentionChar; + if (data[i].link) { + li.dataset.link = data[i].link; + } + li.innerHTML = this.options.renderItem(data[i], searchTerm); + li.onmouseenter = this.onItemMouseEnter.bind(this); + li.onclick = this.onItemClick.bind(this); + this.mentionList.appendChild(li); + } + this.itemIndex = 0; + this.highlightItem(); + this.showMentionList(); + } else { + this.hideMentionList(); + } + } + + nextItem() { + this.itemIndex = (this.itemIndex + 1) % this.values.length; + this.suspendMouseEnter = true; + this.highlightItem(); + } + + prevItem() { + this.itemIndex = ((this.itemIndex + this.values.length) - 1) % this.values.length; + this.suspendMouseEnter = true; + this.highlightItem(); + } + + hasValidChars(s) { + return this.options.allowedChars.test(s); + } + + containerBottomIsNotVisible(topPos, containerPos) { + const mentionContainerBottom = topPos + this.mentionContainer.offsetHeight + containerPos.top; + return mentionContainerBottom > window.pageYOffset + window.innerHeight; + } + + containerRightIsNotVisible(leftPos, containerPos) { + if (this.options.fixMentionsToQuill) { + return false; + } + + const rightPos = leftPos + this.mentionContainer.offsetWidth + containerPos.left; + const browserWidth = window.pageXOffset + document.documentElement.clientWidth; + return rightPos > browserWidth; + } + + setMentionContainerPosition() { + const containerPos = this.quill.container.getBoundingClientRect(); + const mentionCharPos = this.quill.getBounds(this.mentionCharPos); + const containerHeight = this.mentionContainer.offsetHeight; + + let topPos = this.options.offsetTop; + let leftPos = this.options.offsetLeft; + + // handle horizontal positioning + if (this.options.fixMentionsToQuill) { + const rightPos = 0; + this.mentionContainer.style.right = `${rightPos}px`; + } else { + leftPos += mentionCharPos.left; + } + + if (this.containerRightIsNotVisible(leftPos, containerPos)) { + const containerWidth = this.mentionContainer.offsetWidth + this.options.offsetLeft; + const quillWidth = containerPos.width; + leftPos = quillWidth - containerWidth; + } + + // handle vertical positioning + if (this.options.defaultMenuOrientation === 'top') { + // Attempt to align the mention container with the top of the quill editor + if (this.options.fixMentionsToQuill) { + topPos = -1 * (containerHeight + this.options.offsetTop); + } else { + topPos = mentionCharPos.top - (containerHeight + this.options.offsetTop); + } + + // default to bottom if the top is not visible + if (topPos + containerPos.top <= 0) { + let overMentionCharPos = this.options.offsetTop; + + if (this.options.fixMentionsToQuill) { + overMentionCharPos += containerPos.height; + } else { + overMentionCharPos += mentionCharPos.bottom; + } + + topPos = overMentionCharPos; + } + } else { + // Attempt to align the mention container with the bottom of the quill editor + if (this.options.fixMentionsToQuill) { + topPos += containerPos.height; + } else { + topPos += mentionCharPos.bottom; + } + + // default to the top if the bottom is not visible + if (this.containerBottomIsNotVisible(topPos, containerPos)) { + let overMentionCharPos = this.options.offsetTop * -1; + + if (!this.options.fixMentionsToQuill) { + overMentionCharPos += mentionCharPos.top; + } + + topPos = overMentionCharPos - containerHeight; + } + } + + this.mentionContainer.style.top = `${topPos}px`; + this.mentionContainer.style.left = `${leftPos}px`; + + this.mentionContainer.style.visibility = 'visible'; + } + + onSomethingChange() { + const range = this.quill.getSelection(); + if (range == null) return; + this.cursorPos = range.index; + const startPos = Math.max(0, this.cursorPos - this.options.maxChars); + const beforeCursorPos = this.quill.getText(startPos, this.cursorPos - startPos); + const mentionCharIndex = this.options.mentionDenotationChars.reduce((prev, cur) => { + const previousIndex = prev; + const mentionIndex = beforeCursorPos.lastIndexOf(cur); + + return mentionIndex > previousIndex ? mentionIndex : previousIndex; + }, -1); + if (mentionCharIndex > -1) { + if (this.options.isolateCharacter && !(mentionCharIndex == 0 || !!beforeCursorPos[mentionCharIndex - 1].match(/\s/g))) { + this.hideMentionList(); + return; + } + const mentionCharPos = this.cursorPos - (beforeCursorPos.length - mentionCharIndex); + this.mentionCharPos = mentionCharPos; + const textAfter = beforeCursorPos.substring(mentionCharIndex + 1); + if (textAfter.length >= this.options.minChars && this.hasValidChars(textAfter)) { + const mentionChar = beforeCursorPos[mentionCharIndex]; + this.options.source(textAfter, this.renderList.bind(this, mentionChar), mentionChar); + } else { + this.hideMentionList(); + } + } else { + this.hideMentionList(); + } + } + + onTextChange(delta, oldDelta, source) { + if (source === 'user') { + this.onSomethingChange(); + } + } + + onSelectionChange(range) { + if (range && range.length === 0) { + this.onSomethingChange(); + } else { + this.hideMentionList(); + } + } +} + +Quill.register('modules/mention', Mention); + +export default Mention; diff --git a/frappe/public/js/frappe/form/controls/text_editor.js b/frappe/public/js/frappe/form/controls/text_editor.js index af306d461a..910b54616f 100644 --- a/frappe/public/js/frappe/form/controls/text_editor.js +++ b/frappe/public/js/frappe/form/controls/text_editor.js @@ -1,8 +1,6 @@ import Quill from 'quill'; import { ImageDrop } from 'quill-image-drop-module'; -// required for quill-mention -window.Quill = Quill; Quill.register('modules/imageDrop', ImageDrop); diff --git a/frappe/public/js/frappe/ui/dialog.js b/frappe/public/js/frappe/ui/dialog.js index 2cb9185bc8..2815cbbda3 100644 --- a/frappe/public/js/frappe/ui/dialog.js +++ b/frappe/public/js/frappe/ui/dialog.js @@ -9,11 +9,11 @@ frappe.ui.open_dialogs = []; frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { constructor(opts) { + super(); this.display = false; this.is_dialog = true; $.extend(this, { animate: true, size: null }, opts); - super(); this.make(); } diff --git a/frappe/public/less/quill.less b/frappe/public/less/quill.less index 7379dc769f..29ad35ef89 100644 --- a/frappe/public/less/quill.less +++ b/frappe/public/less/quill.less @@ -1,7 +1,7 @@ @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"; +@import (less) "../js/frappe/form/controls/quill-mention/quill.mention.css"; .ql-toolbar.ql-snow, .ql-container.ql-snow { border-color: @border-color; diff --git a/package.json b/package.json index b2ebc2e309..b7b83f5cd3 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "moment-timezone": "^0.5.21", "quill": "2.0.0-dev.2", "quill-image-drop-module": "^1.0.3", - "quill-mention": "https://github.com/netchampfaris/quill-mention", "redis": "^2.8.0", "showdown": "^1.8.6", "socket.io": "^2.0.4", diff --git a/rollup/config.js b/rollup/config.js index 2f5ed27934..181fe876d1 100644 --- a/rollup/config.js +++ b/rollup/config.js @@ -53,7 +53,8 @@ function get_rollup_options_for_js(output_file, input_files) { buble({ objectAssign: 'Object.assign', transforms: { - dangerousForOf: true + dangerousForOf: true, + classes: false }, exclude: [path.resolve(bench_path, '**/*.css'), path.resolve(bench_path, '**/*.less')] }), diff --git a/yarn.lock b/yarn.lock index 0d8ffd7e3a..273ef77867 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3036,12 +3036,6 @@ quill-image-drop-module@^1.0.3: dependencies: quill "^1.2.2" -"quill-mention@https://github.com/netchampfaris/quill-mention": - version "2.0.4" - resolved "https://github.com/netchampfaris/quill-mention#c85d60ee8047bd6b15e319f410423dbb3f06b2e6" - dependencies: - quill "^1.3.4" - quill@2.0.0-dev.2: version "2.0.0-dev.2" resolved "https://registry.yarnpkg.com/quill/-/quill-2.0.0-dev.2.tgz#0f8bc962da28e3ebbb856f246200e7d32e39fc9f" @@ -3054,7 +3048,7 @@ quill@2.0.0-dev.2: parchment quilljs/parchment#487850f7eb030a6c4e750ba809e58b09444e0bdb quill-delta "^3.6.2" -quill@^1.2.2, quill@^1.3.4: +quill@^1.2.2: version "1.3.6" resolved "https://registry.yarnpkg.com/quill/-/quill-1.3.6.tgz#99f4de1fee85925a0d7d4163b6d8328f23317a4d" integrity sha512-K0mvhimWZN6s+9OQ249CH2IEPZ9JmkFuCQeHAOQax3EZ2nDJ3wfGh59mnlQaZV2i7u8eFarx6wAtvQKgShojug==