UNPKG

@atlaskit/editor-plugin-emoji

Version:

Emoji plugin for @atlaskit/editor-core

524 lines (518 loc) 21.4 kB
import React, { useEffect } from 'react'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics'; import { ToolTipContent } from '@atlaskit/editor-common/keymaps'; import { annotationMessages, toolbarInsertBlockMessages as messages } from '@atlaskit/editor-common/messages'; import { IconEmoji } from '@atlaskit/editor-common/quick-insert'; import { SafePlugin } from '@atlaskit/editor-common/safe-plugin'; import { TypeAheadAvailableNodes } from '@atlaskit/editor-common/type-ahead'; import { calculateToolbarPositionAboveSelection } from '@atlaskit/editor-common/utils'; import { PluginKey } from '@atlaskit/editor-prosemirror/state'; import { EmojiTypeAheadItem, preloadEmojiPicker, recordSelectionFailedSli, recordSelectionSucceededSli, SearchSort } from '@atlaskit/emoji'; import CommentIcon from '@atlaskit/icon/core/comment'; import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments'; import { createEmojiFragment, insertEmoji } from './editor-commands/insert-emoji'; import { emojiNodeSpec } from './nodeviews/emojiNodeSpec'; import { EmojiNodeView } from './nodeviews/EmojiNodeView'; import { ACTIONS, openTypeAhead as openTypeAheadAction, setAsciiMap as setAsciiMapAction, setInlineEmojiPopupOpen, setProvider as setProviderAction } from './pm-plugins/actions'; import { inputRulePlugin as asciiInputRulePlugin } from './pm-plugins/ascii-input-rules'; import { InlineEmojiPopup } from './ui/InlineEmojiPopup'; export const emojiToTypeaheadItem = (emoji, emojiProvider) => ({ title: emoji.shortName || '', key: emoji.id || emoji.shortName, render({ isSelected, onClick, onHover }) { return /*#__PURE__*/React.createElement(EmojiTypeAheadItem, { emoji: emoji, selected: isSelected, onMouseMove: onHover, onSelection: onClick, emojiProvider: emojiProvider }); }, emoji }); export function memoize(fn) { // Cache results here const seen = new Map(); function memoized(emoji, emojiProvider) { // Check cache for hits const hit = seen.get(emoji.id || emoji.shortName); if (hit) { return hit; } // Generate new result and cache it const result = fn(emoji, emojiProvider); seen.set(emoji.id || emoji.shortName, result); return result; } return { call: memoized, clear: seen.clear.bind(seen) }; } const memoizedToItem = memoize(emojiToTypeaheadItem); const defaultListLimit = 50; const isFullShortName = query => query && query.length > 1 && query.charAt(0) === ':' && query.charAt(query.length - 1) === ':'; const TRIGGER = ':'; function delayUntilIdle(cb) { if (typeof window === 'undefined') { return; } // eslint-disable-next-line compat/compat if (window.requestIdleCallback !== undefined) { // eslint-disable-next-line compat/compat return window.requestIdleCallback(() => cb(), { timeout: 500 }); } return window.requestAnimationFrame(() => cb()); } /** * Emoji plugin to be added to an `EditorPresetBuilder` and used with `ComposableEditor` * from `@atlaskit/editor-core`. */ export const emojiPlugin = ({ config: options, api }) => { var _api$base, _api$analytics5; let previousEmojiProvider; const typeAhead = { id: TypeAheadAvailableNodes.EMOJI, trigger: TRIGGER, // Custom regex must have a capture group around trigger // so it's possible to use it without needing to scan through all triggers again customRegex: '\\(?(:)', headless: options ? options.headless : undefined, getItems({ query, editorState }) { const pluginState = getEmojiPluginState(editorState); const emojiProvider = pluginState.emojiProvider; if (!emojiProvider) { return Promise.resolve([]); } return new Promise(resolve => { const emojiProviderChangeHandler = { result(emojiResult) { if (!emojiResult || !emojiResult.emojis) { resolve([]); } else { const emojiItems = emojiResult.emojis.map(emoji => memoizedToItem.call(emoji, emojiProvider)); resolve(emojiItems); } emojiProvider.unsubscribe(emojiProviderChangeHandler); } }; emojiProvider.subscribe(emojiProviderChangeHandler); emojiProvider.filter(TRIGGER.concat(query), { limit: defaultListLimit, skinTone: emojiProvider.getSelectedTone(), sort: !query.length ? SearchSort.UsageFrequency : SearchSort.Default }); }); }, forceSelect({ query, items, editorState }) { const { asciiMap } = emojiPluginKey.getState(editorState) || {}; const normalizedQuery = TRIGGER.concat(query); // if the query has space at the end // check the ascii map for emojis if (!(options !== null && options !== void 0 && options.disableAutoformat) && asciiMap && normalizedQuery.length >= 3 && normalizedQuery.endsWith(' ') && asciiMap.has(normalizedQuery.trim())) { const emoji = asciiMap.get(normalizedQuery.trim()); return { title: (emoji === null || emoji === void 0 ? void 0 : emoji.name) || '', emoji }; } const matchedItem = isFullShortName(normalizedQuery) ? items.find(item => item.title.toLowerCase() === normalizedQuery) : undefined; return matchedItem; }, selectItem(state, item, insert, { mode }) { var _api$analytics3; const emojiPluginState = emojiPluginKey.getState(state); if (emojiPluginState.emojiProvider && emojiPluginState.emojiProvider.recordSelection && item.emoji) { var _api$analytics$shared, _api$analytics, _api$analytics$shared2, _api$analytics$shared3, _api$analytics2, _api$analytics2$share; emojiPluginState.emojiProvider.recordSelection(item.emoji).then(recordSelectionSucceededSli(item.emoji, { createAnalyticsEvent: (_api$analytics$shared = api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : (_api$analytics$shared2 = _api$analytics.sharedState.currentState()) === null || _api$analytics$shared2 === void 0 ? void 0 : _api$analytics$shared2.createAnalyticsEvent) !== null && _api$analytics$shared !== void 0 ? _api$analytics$shared : undefined })).catch(recordSelectionFailedSli(item.emoji, { createAnalyticsEvent: (_api$analytics$shared3 = api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : (_api$analytics2$share = _api$analytics2.sharedState.currentState()) === null || _api$analytics2$share === void 0 ? void 0 : _api$analytics2$share.createAnalyticsEvent) !== null && _api$analytics$shared3 !== void 0 ? _api$analytics$shared3 : undefined })); } const fragment = createEmojiFragment(state.doc, state.selection.$head, item.emoji); const tr = insert(fragment); api === null || api === void 0 ? void 0 : (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 ? void 0 : _api$analytics3.actions.attachAnalyticsEvent({ action: ACTION.INSERTED, actionSubject: ACTION_SUBJECT.DOCUMENT, actionSubjectId: ACTION_SUBJECT_ID.EMOJI, attributes: { inputMethod: INPUT_METHOD.TYPEAHEAD }, eventType: EVENT_TYPE.TRACK })(tr); return tr; } }; api === null || api === void 0 ? void 0 : (_api$base = api.base) === null || _api$base === void 0 ? void 0 : _api$base.actions.registerMarks(({ tr, node, pos }) => { const { doc } = tr; const { schema } = doc.type; const { emoji: emojiNodeType } = schema.nodes; if (node.type === emojiNodeType) { const newText = node.attrs.text; const currentPos = tr.mapping.map(pos); tr.replaceWith(currentPos, currentPos + node.nodeSize, schema.text(newText, node.marks)); } }); return { name: 'emoji', nodes() { return [{ name: 'emoji', node: emojiNodeSpec() }]; }, usePluginHook() { useEffect(() => { delayUntilIdle(() => { preloadEmojiPicker(); }); }, []); }, pmPlugins() { return [{ name: 'emoji', plugin: pmPluginFactoryParams => { return createEmojiPlugin(pmPluginFactoryParams, options, api); } }, { name: 'emojiAsciiInputRule', plugin: ({ schema }) => { var _api$analytics4; return asciiInputRulePlugin(schema, api === null || api === void 0 ? void 0 : (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 ? void 0 : _api$analytics4.actions, api, options === null || options === void 0 ? void 0 : options.disableAutoformat); } }]; }, getSharedState(editorState) { var _emojiPluginKey$getSt; if (!editorState) { return undefined; } const { emojiResourceConfig, asciiMap, emojiProvider, inlineEmojiPopupOpen, emojiProviderPromise } = (_emojiPluginKey$getSt = emojiPluginKey.getState(editorState)) !== null && _emojiPluginKey$getSt !== void 0 ? _emojiPluginKey$getSt : {}; return { emojiResourceConfig, asciiMap, typeAheadHandler: typeAhead, emojiProvider, emojiProviderPromise, inlineEmojiPopupOpen }; }, actions: { openTypeAhead: openTypeAheadAction(typeAhead, api), setProvider: async providerPromise => { var _api$core$actions$exe; const provider = await providerPromise; // Prevent someone trying to set the exact same provider twice for performance reasons if (previousEmojiProvider === provider || (options === null || options === void 0 ? void 0 : options.emojiProvider) === providerPromise) { return false; } previousEmojiProvider = provider; return (_api$core$actions$exe = api === null || api === void 0 ? void 0 : api.core.actions.execute(({ tr }) => setProviderTr(provider)(tr))) !== null && _api$core$actions$exe !== void 0 ? _api$core$actions$exe : false; } }, commands: { insertEmoji: insertEmoji(api === null || api === void 0 ? void 0 : (_api$analytics5 = api.analytics) === null || _api$analytics5 === void 0 ? void 0 : _api$analytics5.actions) }, contentComponent({ editorView, popupsBoundariesElement, popupsMountPoint, popupsScrollableElement }) { if (!api || editorExperiment('platform_editor_controls', 'control') || !editorView) { return null; } return /*#__PURE__*/React.createElement(InlineEmojiPopup, { api: api, editorView: editorView, popupsBoundariesElement: popupsBoundariesElement, popupsMountPoint: popupsMountPoint, popupsScrollableElement: popupsScrollableElement }); }, pluginsOptions: { quickInsert: ({ formatMessage }) => [{ id: 'emoji', title: formatMessage(messages.emoji), description: formatMessage(messages.emojiDescription), priority: 500, keyshortcut: ':', isDisabledOffline: false, icon: () => /*#__PURE__*/React.createElement(IconEmoji, null), action(insert) { var _api$typeAhead; if (editorExperiment('platform_editor_controls', 'variant1', { exposure: true })) { // Clear slash let tr = insert(''); tr = setInlineEmojiPopupOpen(true)(tr); return tr; } const tr = insert(undefined); api === null || api === void 0 ? void 0 : (_api$typeAhead = api.typeAhead) === null || _api$typeAhead === void 0 ? void 0 : _api$typeAhead.actions.openAtTransaction({ triggerHandler: typeAhead, inputMethod: INPUT_METHOD.QUICK_INSERT })(tr); return tr; } }], typeAhead, floatingToolbar: (state, intl) => { const isViewMode = () => { var _api$editorViewMode, _api$editorViewMode$s; return (api === null || api === void 0 ? void 0 : (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 ? void 0 : (_api$editorViewMode$s = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode$s === void 0 ? void 0 : _api$editorViewMode$s.mode) === 'view'; }; if (!isViewMode()) { return undefined; } const onClick = (stateFromClickEvent, dispatch) => { var _api$analytics6, _api$annotation, _api$annotation$actio; if (!(api !== null && api !== void 0 && api.annotation)) { return true; } if (api !== null && api !== void 0 && (_api$analytics6 = api.analytics) !== null && _api$analytics6 !== void 0 && _api$analytics6.actions) { var _api$analytics7, _api$analytics7$actio; api === null || api === void 0 ? void 0 : (_api$analytics7 = api.analytics) === null || _api$analytics7 === void 0 ? void 0 : (_api$analytics7$actio = _api$analytics7.actions) === null || _api$analytics7$actio === void 0 ? void 0 : _api$analytics7$actio.fireAnalyticsEvent({ action: ACTION.CLICKED, actionSubject: ACTION_SUBJECT.BUTTON, actionSubjectId: ACTION_SUBJECT_ID.CREATE_INLINE_COMMENT_FROM_HIGHLIGHT_ACTIONS_MENU, eventType: EVENT_TYPE.UI, attributes: { source: 'highlightActionsMenu', pageMode: 'edit', sourceNode: 'emoji' } }); } const command = (_api$annotation = api.annotation) === null || _api$annotation === void 0 ? void 0 : (_api$annotation$actio = _api$annotation.actions) === null || _api$annotation$actio === void 0 ? void 0 : _api$annotation$actio.setInlineCommentDraftState(true, INPUT_METHOD.TOOLBAR); return command(stateFromClickEvent, dispatch); }; return { title: 'Emoji floating toolbar', nodeType: [state.schema.nodes.emoji], onPositionCalculated: calculateToolbarPositionAboveSelection('Emoji floating toolbar'), items: node => { var _api$annotation2; const annotationState = api === null || api === void 0 ? void 0 : (_api$annotation2 = api.annotation) === null || _api$annotation2 === void 0 ? void 0 : _api$annotation2.sharedState.currentState(); const activeCommentMark = node.marks.find(mark => mark.type.name === 'annotation' && (annotationState === null || annotationState === void 0 ? void 0 : annotationState.annotations[mark.attrs.id]) === false); const showAnnotation = annotationState && annotationState.isVisible && !annotationState.bookmark && !annotationState.mouseData.isSelecting && !activeCommentMark && isViewMode(); if (showAnnotation) { return [{ type: 'button', showTitle: true, testId: 'add-comment-emoji-button', icon: CommentIcon, title: intl.formatMessage(annotationMessages.createComment), onClick, tooltipContent: /*#__PURE__*/React.createElement(ToolTipContent, { description: intl.formatMessage(annotationMessages.createComment) }), supportsViewMode: true }]; } return []; } }; } } }; }; /** * Actions */ const setAsciiMap = asciiMap => (state, dispatch) => { if (dispatch) { const tr = setAsciiMapAction(asciiMap)(state.tr); dispatch(tr); } return true; }; /** * * Wrapper to call `onLimitReached` when a specified number of calls of that function * have been made within a time period. * * Note: It does not rate limit * * @param fn Function to wrap * @param limitTime Time limit in milliseconds * @param limitCount Number of function calls before `onRateReached` is called (per time period) * @returns Wrapped function */ export function createRateLimitReachedFunction(fn, limitTime, limitCount, onLimitReached) { let lastCallTime = 0; let callCount = 0; return function wrappedFn(...args) { const now = Date.now(); if (now - lastCallTime < limitTime) { if (++callCount > limitCount) { onLimitReached === null || onLimitReached === void 0 ? void 0 : onLimitReached(); } } else { lastCallTime = now; callCount = 1; } return fn(...args); }; } // At this stage console.error only const logRateWarning = () => { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.error('The emoji provider injected in the Editor is being reloaded frequently, this will cause a slow Editor experience.'); } }; const setProviderTr = createRateLimitReachedFunction(provider => tr => setProviderAction(provider)(tr), // If we change the emoji provider more than three times every 5 seconds we should warn. // This seems like a really long time but the performance can be that laggy that we don't // even get 3 events in 3 seconds and miss this indicator. 5000, 3, logRateWarning); const setProvider = provider => (state, dispatch) => { if (dispatch) { const tr = setProviderTr(provider)(state.tr); dispatch(tr); } return true; }; export const emojiPluginKey = new PluginKey('emojiPlugin'); function getEmojiPluginState(state) { return emojiPluginKey.getState(state) || {}; } function createEmojiPlugin(pmPluginFactoryParams, options, api) { return new SafePlugin({ key: emojiPluginKey, state: { init() { if (options !== null && options !== void 0 && options.emojiProvider && editorExperiment('platform_editor_prevent_toolbar_layout_shifts', true)) { return { emojiProviderPromise: options.emojiProvider }; } return {}; }, apply(tr, pluginState) { const { action, params } = tr.getMeta(emojiPluginKey) || { action: null, params: null }; let newPluginState = pluginState; switch (action) { case ACTIONS.SET_PROVIDER: newPluginState = { ...pluginState, emojiProvider: params.provider, emojiProviderPromise: editorExperiment('platform_editor_prevent_toolbar_layout_shifts', true) ? Promise.resolve(params.provider) : undefined }; pmPluginFactoryParams.dispatch(emojiPluginKey, newPluginState); return newPluginState; case ACTIONS.SET_ASCII_MAP: newPluginState = { ...pluginState, asciiMap: params.asciiMap }; pmPluginFactoryParams.dispatch(emojiPluginKey, newPluginState); return newPluginState; case ACTIONS.SET_INLINE_POPUP: newPluginState = { ...pluginState, inlineEmojiPopupOpen: params.open }; pmPluginFactoryParams.dispatch(emojiPluginKey, newPluginState); return newPluginState; } return newPluginState; } }, props: { nodeViews: { emoji: node => { return new EmojiNodeView(node, { intl: pmPluginFactoryParams.getIntl(), api, emojiNodeDataProvider: options === null || options === void 0 ? void 0 : options.emojiNodeDataProvider }); } } }, view(editorView) { const providerHandler = (name, providerPromise) => { switch (name) { case 'emojiProvider': if (!providerPromise) { var _setProvider; return setProvider === null || setProvider === void 0 ? void 0 : (_setProvider = setProvider(undefined)) === null || _setProvider === void 0 ? void 0 : _setProvider(editorView.state, editorView.dispatch); } providerPromise.then(provider => { var _setProvider2; setProvider === null || setProvider === void 0 ? void 0 : (_setProvider2 = setProvider(provider)) === null || _setProvider2 === void 0 ? void 0 : _setProvider2(editorView.state, editorView.dispatch); provider.getAsciiMap().then(asciiMap => { setAsciiMap(asciiMap)(editorView.state, editorView.dispatch); }); }).catch(() => { var _setProvider3; return setProvider === null || setProvider === void 0 ? void 0 : (_setProvider3 = setProvider(undefined)) === null || _setProvider3 === void 0 ? void 0 : _setProvider3(editorView.state, editorView.dispatch); }); break; } return; }; if (options !== null && options !== void 0 && options.emojiProvider) { providerHandler('emojiProvider', options.emojiProvider); } return { destroy() { if (pmPluginFactoryParams.providerFactory) { pmPluginFactoryParams.providerFactory.unsubscribe('emojiProvider', providerHandler); } } }; } }); }