@atlaskit/editor-plugin-emoji
Version:
Emoji plugin for @atlaskit/editor-core
524 lines (518 loc) • 21.4 kB
JavaScript
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);
}
}
};
}
});
}