UNPKG

medium-editor-tc-mention

Version:

MediumEditor extension for mention panels like @username or #tagging

382 lines (334 loc) 12.4 kB
import { default as MediumEditor, } from "medium-editor"; function last(text) { return text[text.length - 1]; } export const LEFT_ARROW_KEYCODE = 37; export function unwrapForTextNode(el, doc) { const parentNode = el.parentNode; MediumEditor.util.unwrap(el, doc); // Merge textNode let currentNode = parentNode.lastChild; let prevNode = currentNode.previousSibling; while (prevNode) { if (currentNode.nodeType === 3 && prevNode.nodeType === 3) { prevNode.textContent += currentNode.textContent; parentNode.removeChild(currentNode); } currentNode = prevNode; prevNode = currentNode.previousSibling; } } export const TCMention = MediumEditor.Extension.extend({ name: `mention`, /* @deprecated: use extraPanelClassName. Will remove in next major (3.0.0) release * extraClassName: [string] * * Extra className to be added with the `medium-editor-mention-panel` element. */ extraClassName: ``, /* @deprecated: use extraActivePanelClassName. Will remove in next major (3.0.0) release * extraActiveClassName: [string] * * Extra active className to be added with the `medium-editor-mention-panel-active` element. */ extraActiveClassName: ``, /* extraPanelClassName: [string] * * Extra className to be added with the `medium-editor-mention-panel` element. */ extraPanelClassName: ``, /* extraActivePanelClassName: [string] * * Extra active className to be added with the `medium-editor-mention-panel-active` element. */ extraActivePanelClassName: ``, extraTriggerClassNameMap: {}, extraActiveTriggerClassNameMap: {}, /* tagName: [string] * * Element tag name that would indicate that this mention. It will have * `medium-editor-mention-at` className applied on it. */ tagName: `strong`, /* renderPanelContent: [ * function (panelEl: dom, currentMentionText: string, selectMentionCallback: function) * ] * * Render function that used to create the content of the panel when panel is show. * * @params panelEl: DOM element of the panel. * * @params currentMentionText: Often used as query criteria. e.g. @medium * * @params selectMentionCallback: * callback used in customized panel content. * * When called with null, it tells the Mention plugin to close the panel. * e.g. selectMentionCallback(null); * * When called with text, it tells the Mention plugin that the text is selected by the user. * e.g. selectMentionCallback("@mediumrocks") */ renderPanelContent() {}, /* destroyPanelContent: [function (panelEl: dom)] * * Destroy function to remove any contents rendered by renderPanelContent * before panelEl being removed from the document. * * @params panelEl: DOM element of the panel. */ destroyPanelContent() {}, activeTriggerList: [`@`], triggerClassNameMap: { "#": `medium-editor-mention-hash`, "@": `medium-editor-mention-at`, }, activeTriggerClassNameMap: { "#": `medium-editor-mention-hash-active`, "@": `medium-editor-mention-at-active`, }, hideOnBlurDelay: 300, init() { this.initMentionPanel(); this.attachEventHandlers(); }, destroy() { this.detachEventHandlers(); this.destroyMentionPanel(); }, initMentionPanel() { const el = this.document.createElement(`div`); el.classList.add(`medium-editor-mention-panel`); if (this.extraPanelClassName || this.extraClassName) { el.classList.add(this.extraPanelClassName || this.extraClassName); } this.getEditorOption(`elementsContainer`).appendChild(el); this.mentionPanel = el; }, destroyMentionPanel() { if (this.mentionPanel) { if (this.mentionPanel.parentNode) { this.destroyPanelContent(this.mentionPanel); this.mentionPanel.parentNode.removeChild(this.mentionPanel); } delete this.mentionPanel; } }, attachEventHandlers() { this.unsubscribeCallbacks = []; const subscribeCallbackName = (eventName, callbackName) => { const boundCallback = ::this[callbackName]; this.subscribe(eventName, boundCallback); this.unsubscribeCallbacks.push(() => { // Bug: this.unsubscribe isn't exist! this.base.unsubscribe(eventName, boundCallback); }); }; if (this.hideOnBlurDelay !== null && this.hideOnBlurDelay !== undefined) { // for hideOnBlurDelay, the panel should hide after blur event subscribeCallbackName(`blur`, `handleBlur`); // and clear out hide timeout if focus again subscribeCallbackName(`focus`, `handleFocus`); } // if the editor changes its content, we have to show or hide the panel subscribeCallbackName(`editableKeyup`, `handleKeyup`); }, detachEventHandlers() { if (this.hideOnBlurDelayId) { clearTimeout(this.hideOnBlurDelayId); } if (this.unsubscribeCallbacks) { this.unsubscribeCallbacks.forEach(boundCallback => boundCallback()); this.unsubscribeCallbacks = null; } }, handleBlur() { if (this.hideOnBlurDelay !== null && this.hideOnBlurDelay !== undefined) { this.hideOnBlurDelayId = setTimeout(() => { this.hidePanel(false); }, this.hideOnBlurDelay); } }, handleFocus() { if (this.hideOnBlurDelayId) { clearTimeout(this.hideOnBlurDelayId); this.hideOnBlurDelayId = null; } }, handleKeyup(event) { const keyCode = MediumEditor.util.getKeyCode(event); const isSpace = keyCode === MediumEditor.util.keyCode.SPACE; this.getWordFromSelection(event.target, isSpace ? -1 : 0); if (!isSpace && this.activeTriggerList.indexOf(this.trigger) !== -1 && this.word.length > 1) { this.showPanel(); } else { this.hidePanel(keyCode === LEFT_ARROW_KEYCODE); } }, hidePanel(isArrowTowardsLeft) { this.mentionPanel.classList.remove(`medium-editor-mention-panel-active`); const extraActivePanelClassName = this.extraActivePanelClassName || this.extraActiveClassName; if (extraActivePanelClassName) { this.mentionPanel.classList.remove(extraActivePanelClassName); } if (this.activeMentionAt) { this.activeMentionAt.classList.remove(this.activeTriggerClassName); if (this.extraActiveTriggerClassName) { this.activeMentionAt.classList.remove(this.extraActiveTriggerClassName); } } if (this.activeMentionAt) { // http://stackoverflow.com/a/27004526/1458162 const { parentNode, previousSibling, nextSibling, firstChild } = this.activeMentionAt; const siblingNode = isArrowTowardsLeft ? previousSibling : nextSibling; let textNode; if (!siblingNode) { textNode = this.document.createTextNode(``); parentNode.appendChild(textNode); } else if (siblingNode.nodeType !== 3) { textNode = this.document.createTextNode(``); parentNode.insertBefore(textNode, siblingNode); } else { textNode = siblingNode; } const lastEmptyWord = last(firstChild.textContent); const hasLastEmptyWord = lastEmptyWord.trim().length === 0; if (hasLastEmptyWord) { const { textContent } = firstChild; firstChild.textContent = textContent.substr(0, textContent.length - 1); textNode.textContent = `${lastEmptyWord}${textNode.textContent}`; } else { if (textNode.textContent.length === 0 && firstChild.textContent.length > 1) { textNode.textContent = `\u00A0`; } } if (isArrowTowardsLeft) { MediumEditor.selection.select(this.document, textNode, textNode.length); } else { MediumEditor.selection.select(this.document, textNode, Math.min(textNode.length, 1)); } if (firstChild.textContent.length <= 1) { // LIKE core#execAction this.base.saveSelection(); unwrapForTextNode(this.activeMentionAt, this.document); this.base.restoreSelection(); } // this.activeMentionAt = null; } }, getWordFromSelection(target, initialDiff) { const { startContainer, startOffset, endContainer, } = MediumEditor.selection.getSelectionRange(this.document); if (startContainer !== endContainer) { return; } const { textContent } = startContainer; function getWordPosition(position, diff) { const prevText = textContent[position - 1]; if (prevText === null || prevText === undefined) { return position; } else if (prevText.trim().length === 0 || position <= 0 || textContent.length < position) { return position; } else { return getWordPosition(position + diff, diff); } } this.wordStart = getWordPosition(startOffset + initialDiff, -1); this.wordEnd = getWordPosition(startOffset + initialDiff, 1) - 1; this.word = textContent.slice(this.wordStart, this.wordEnd); this.trigger = this.word.slice(0, 1); this.triggerClassName = this.triggerClassNameMap[this.trigger]; this.activeTriggerClassName = this.activeTriggerClassNameMap[this.trigger]; // this.extraTriggerClassName = this.extraTriggerClassNameMap[this.trigger]; this.extraActiveTriggerClassName = this.extraActiveTriggerClassNameMap[this.trigger]; }, showPanel() { if (!this.mentionPanel.classList.contains(`medium-editor-mention-panel-active`)) { this.activatePanel(); this.wrapWordInMentionAt(); } this.positionPanel(); this.updatePanelContent(); }, activatePanel() { this.mentionPanel.classList.add(`medium-editor-mention-panel-active`); if (this.extraActivePanelClassName || this.extraActiveClassName) { this.mentionPanel.classList.add(this.extraActivePanelClassName || this.extraActiveClassName); } }, wrapWordInMentionAt() { const selection = this.document.getSelection(); if (!selection.rangeCount) { return; } // http://stackoverflow.com/a/6328906/1458162 const range = selection.getRangeAt(0).cloneRange(); if (range.startContainer.parentNode.classList.contains(this.triggerClassName)) { this.activeMentionAt = range.startContainer.parentNode; } else { const nextWordEnd = Math.min(this.wordEnd, range.startContainer.textContent.length); range.setStart(range.startContainer, this.wordStart); range.setEnd(range.startContainer, nextWordEnd); // Instead, insert our own version of it. // TODO: Not sure why, but using <span> tag doens't work here const element = this.document.createElement(this.tagName); element.classList.add(this.triggerClassName); if (this.extraTriggerClassName) { element.classList.add(this.extraTriggerClassName); } this.activeMentionAt = element; // range.surroundContents(element); selection.removeAllRanges(); selection.addRange(range); // MediumEditor.selection.select( this.document, this.activeMentionAt.firstChild, this.word.length ); } this.activeMentionAt.classList.add(this.activeTriggerClassName); if (this.extraActiveTriggerClassName) { this.activeMentionAt.classList.add(this.extraActiveTriggerClassName); } }, positionPanel() { const { bottom, left, width } = this.activeMentionAt.getBoundingClientRect(); const { pageXOffset, pageYOffset } = this.window; this.mentionPanel.style.top = `${pageYOffset + bottom}px`; this.mentionPanel.style.left = `${pageXOffset + left + width}px`; }, updatePanelContent() { this.renderPanelContent(this.mentionPanel, this.word, ::this.handleSelectMention); }, handleSelectMention(seletedText) { if (seletedText) { const textNode = this.activeMentionAt.firstChild; textNode.textContent = seletedText; MediumEditor.selection.select(this.document, textNode, seletedText.length); // // If one of our contenteditables currently has focus, we should // attempt to trigger the 'editableInput' event const target = this.base.getFocusedElement(); if (target) { this.base.events.updateInput(target, { target, currentTarget: target, }); } // this.hidePanel(false); } else { this.hidePanel(false); } }, }); export default TCMention;