UNPKG

react-tinymce-mention

Version:

@Mention functionality for TinyMCE, built with React and Redux.

364 lines (299 loc) 8.53 kB
import invariant from 'invariant'; import removeNode from 'dom-remove'; import findWhere from 'lodash.findwhere'; import getKeyCode from './utils/getKeyCode'; import extractMentions from './utils/extractMentions'; import { collectMentionIds, getLastChar, } from './utils/tinyMCEUtils'; import { moveDown, moveUp, query, remove, resetMentions, resetQuery, select, syncEditorState } from './actions/mentionActions'; const keyMap = { BACKSPACE: 8, DELETE: 46, DOWN: 40, ENTER: 13, TAB: 9, UP: 38, ESC: 27 }; const defaultDelimiter = '@'; const delimiterOptions = ['#', '@']; let delimiter, editor, store; const focus = { active: false, toggle() { this.active = !this.active; return this.active; } }; /** * Tracks typed characters after `@ment|`. Allows us to determine if we * are within a mention when `focus.active` */ const typedMention = { value: '', update(str) { this.value = (this.value + str).trim(); return this.value; }, backspace() { const val = this.value; this.value = val.substring(0, val.length - 1).trim(); return this.value; }, clear() { this.value = ''; } }; export function initializePlugin(reduxStore, dataSource, delimiterValue = defaultDelimiter) { if (typeof window.tinymce === 'undefined') { throw new Error('Error initializing Mention plugin: `tinymce` is undefined.'); } return new Promise((resolve, reject) => { if (pluginInitialized()) { loadMentions(dataSource, resolve, reject); } else { window.tinymce.PluginManager.add('mention', (activeEditor) => { invariant(reduxStore, 'Plugin must be initialized with a Redux store.' ); invariant(dataSource, 'Plugin must be initialized with a `dataSource` that is an array or promise.' ); invariant(isValidDelimiter(delimiterValue), `Plugin must be initialized with a valid delimiter (${delimiterOptions.toString()})` ); store = reduxStore; delimiter = delimiterValue; editor = activeEditor; loadMentions(dataSource, resolve, reject); }); } }); } function loadMentions(dataSource, resolve) { setTimeout(() => { if (typeof dataSource.then === 'function') { dataSource.then(response => { start(); resolve({ editor, resolvedDataSource: response }); }); if (dataSource.catch === 'function') { dataSource.catch(error => { throw new Error(error); }); } else if (dataSource.fail === 'function') { dataSource.fail(error => { throw new Error(error); }); } } else { start(); resolve({ editor, resolvedDataSource: dataSource }); } }, 100); } function start() { // FireFox fix setTimeout(() => { stop(); editor.on('keypress', handleTopLevelEditorInput); editor.on('keydown', handleTopLevelActionKeys); editor.on('keyup', handleBackspace); }, 50); } function stop() { editor.off(); } function handleTopLevelEditorInput(event) { const keyCode = getKeyCode(event); const character = String.fromCharCode(keyCode); const foundDelimiter = delimiter.indexOf(character) > -1; normalizeEditorInput(editor); if (!focus.active && foundDelimiter) { startListeningForInput(); } else if (!focus.active || character === ' ') { stopListeningAndCleanup(); } } function handleTopLevelActionKeys(event) { const keyCode = getKeyCode(event); if (focus.active && keyCode === keyMap.BACKSPACE || keyCode === keyMap.DELETE) { if (getLastChar(editor) === delimiter){ stopListeningAndCleanup(); } else { const mentionText = updateMentionText(keyCode); store.dispatch(query(mentionText)); } } } function handleActionKeys(event) { const keyCode = getKeyCode(event); if (isFetching(keyMap, keyCode, store) || shouldSelectOrMove(keyCode, event)) { event.preventDefault(); return false; } } function handleKeyPress(event) { const keyCode = getKeyCode(event); setTimeout(() => { const mentionText = updateMentionText(keyCode); if (mentionText !== '') { const content = editor.getContent(); const { mentions, prop } = extractMentions(content, delimiter); const mention = findWhere(mentions, { [prop]: mentionText }); if (mention) { store.dispatch(query(mention[prop])); } } }, 0); } function handleBackspace(event) { const keyCode = getKeyCode(event); const mentionClassName = '.tinymce-mention'; const $ = window.tinymce.dom.DomQuery; if (keyCode === keyMap.BACKSPACE || keyCode === keyMap.DELETE) { const node = editor.selection.getNode(); const foundMentionNode = $(node).closest(mentionClassName)[0]; if (foundMentionNode) { const mention = removeMentionFromEditor(foundMentionNode); store.dispatch(remove(mention)); } else if (!editor.getContent({format: 'html'}).trim().length) { store.dispatch(resetMentions()); stopListeningAndCleanup(); } else { const mentionIds = collectMentionIds(editor, mentionClassName); store.dispatch(syncEditorState(mentionIds)); } } } function shouldSelectOrMove(keyCode, event) { const { matchedSources } = store.getState().mention; if (matchedSources.length) { if (keyCode === keyMap.BACKSPACE || keyCode === keyCode.DELETE) { typedMention.update(keyCode); return handleKeyPress(event); } switch(keyCode) { case keyMap.TAB: selectMention(); return true; case keyMap.ENTER: selectMention(); return true; case keyMap.DOWN: store.dispatch(moveDown()); return true; case keyMap.UP: store.dispatch(moveUp()); return true; case keyMap.ESC: stopListeningAndCleanup(); return true; default: return false; } } } function startListeningForInput() { if (!focus.active) { focus.toggle(); editor.on('keydown', handleActionKeys); editor.on('keypress', handleKeyPress); } } function stopListeningAndCleanup() { if (focus.active) { focus.toggle(); typedMention.clear(); store.dispatch(resetQuery()); editor.off('keydown', handleActionKeys); editor.off('keypress', handleKeyPress); } } function updateMentionText(keyCode) { const mentionText = keyCode !== keyMap.BACKSPACE && keyCode !== keyMap.DELETE ? typedMention.update(getLastChar(editor)) : typedMention.backspace(); return mentionText; } function selectMention() { store.dispatch(select()); typedMention.clear(); stopListeningAndCleanup(); return true; } function extractMentionFromNode(mentionNode, delimiter) { const re = new RegExp('(?:' + delimiter + '|_)'); return mentionNode .innerHTML .replace(re, '') .trim(); } function removeMentionFromEditor(mentionNode) { removeNode(mentionNode); return extractMentionFromNode(mentionNode, delimiter); } // TODO: Cleanup // Force a root element in case one doesn't exist. function normalizeEditorInput() { if (editor.getContent() === '' || editor.getContent({ format: 'raw' }) === '<br>') { editor.insertContent(' '); } } function pluginInitialized() { const ed = window.tinymce.activeEditor; const plugins = ed && ed.plugins; const mention = plugins && plugins.mention; return mention ? true : false; } function isValidDelimiter(delimiter) { return delimiterOptions.some(d => d === delimiter); } function isFetching(keyMap, keyCode, store) { const { fetching } = store.getState().mention; const shouldCancelEvent = Object.keys(keyMap).some(key => { const actionKeyCode = keyMap[key]; const inWhitelist = [ keyMap.ESC, keyMap.BACKSPACE, keyMap.DELETE ].some(k => k === actionKeyCode); return fetching && actionKeyCode === keyCode && !inWhitelist ? true : false; }); return shouldCancelEvent; } // Export methods for testing export const testExports = { _typedMention: typedMention, _focus: focus, _loadMentions: loadMentions, _shouldSelectOrMove: shouldSelectOrMove, _updateMentionText: updateMentionText, _normalizeEditorInput: normalizeEditorInput, _isValidDelimiter: isValidDelimiter, _handleKeyPress: handleKeyPress, _handleEditorBackspace: handleBackspace, _removeMentionFromEditor: removeMentionFromEditor, _extractMentionFromNode: extractMentionFromNode };