import React from 'react';
import { EditorState, Plugin, PluginKey, StateField } from 'prosemirror-state';
import { emoji } from '@atlaskit/adf-schema';
import { ProviderFactory } from '@atlaskit/editor-common/provider-factory';
import {
EmojiDescription,
EmojiProvider,
EmojiTypeAheadItem,
SearchSort,
recordSelectionSucceededSli,
recordSelectionFailedSli,
} from '@atlaskit/emoji';
import { Command, EditorPlugin } from '../../types';
import { Dispatch } from '../../event-dispatcher';
import { PortalProviderAPI } from '../../ui/PortalProvider';
import { inputRulePlugin as asciiInputRulePlugin } from './pm-plugins/ascii-input-rules';
import {
ACTION,
ACTION_SUBJECT,
ACTION_SUBJECT_ID,
addAnalytics,
EVENT_TYPE,
INPUT_METHOD,
} from '../analytics';
import { IconEmoji } from '../quick-insert/assets';
import emojiNodeView from './nodeviews/emoji';
import emojiNodeViewNext from './nodeviews/emoji-next';
import { TypeAheadItem } from '../type-ahead/types';
import { EmojiContextProvider } from './ui/EmojiContextProvider';
import { messages } from '../insert-block/ui/ToolbarInsertBlock/messages';
import { EmojiPluginOptions, EmojiPluginState } from './types';
import { EventDispatcher } from '../../event-dispatcher';
import { getFeatureFlags } from '../feature-flags-context';
export const emojiToTypeaheadItem = (
emoji: EmojiDescription,
emojiProvider?: EmojiProvider,
): TypeAheadItem => ({
title: emoji.shortName || '',
key: emoji.id || emoji.shortName,
render({ isSelected, onClick, onHover }) {
return (
// It's required to pass emojiProvider through the context for custom emojis to work
);
},
emoji,
});
export function memoize<
ResultFn extends (
emoji: EmojiDescription,
emojiProvider?: EmojiProvider,
) => TypeAheadItem
>(fn: ResultFn): { call: ResultFn; clear(): void } {
// Cache results here
const seen = new Map();
function memoized(
emoji: EmojiDescription,
emojiProvider?: EmojiProvider,
): TypeAheadItem {
// 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 as ResultFn,
clear: seen.clear.bind(seen),
};
}
const memoizedToItem = memoize(emojiToTypeaheadItem);
export const defaultListLimit = 50;
const isFullShortName = (query?: string) =>
query &&
query.length > 1 &&
query.charAt(0) === ':' &&
query.charAt(query.length - 1) === ':';
const emojiPlugin = (options?: EmojiPluginOptions): EditorPlugin => ({
name: 'emoji',
nodes() {
return [{ name: 'emoji', node: emoji }];
},
pmPlugins() {
return [
{
name: 'emoji',
plugin: ({
providerFactory,
dispatch,
portalProviderAPI,
eventDispatcher,
}) =>
emojiPluginFactory(
dispatch,
providerFactory,
portalProviderAPI,
eventDispatcher,
options,
),
},
{
name: 'emojiAsciiInputRule',
plugin: ({ schema, providerFactory, featureFlags }) =>
asciiInputRulePlugin(schema, providerFactory, featureFlags),
},
];
},
pluginsOptions: {
quickInsert: ({ formatMessage }) => [
{
id: 'emoji',
title: formatMessage(messages.emoji),
description: formatMessage(messages.emojiDescription),
priority: 500,
keyshortcut: ':',
icon: () => ,
action(insert, state) {
const mark = state.schema.mark('typeAheadQuery', {
trigger: ':',
});
const emojiText = state.schema.text(':', [mark]);
const tr = insert(emojiText);
return addAnalytics(state, tr, {
action: ACTION.INVOKED,
actionSubject: ACTION_SUBJECT.TYPEAHEAD,
actionSubjectId: ACTION_SUBJECT_ID.TYPEAHEAD_EMOJI,
attributes: { inputMethod: INPUT_METHOD.QUICK_INSERT },
eventType: EVENT_TYPE.UI,
});
},
},
],
typeAhead: {
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, state, _intl, { prevActive, queryChanged }) {
const pluginState = getEmojiPluginState(state);
const { emojiProvider, emojis: pluginEmojis } = pluginState;
const emojis = !prevActive && queryChanged ? [] : pluginEmojis || [];
if (queryChanged && emojiProvider) {
memoizedToItem.clear();
emojiProvider.filter(query ? `:${query}` : '', {
limit: defaultListLimit,
skinTone: emojiProvider.getSelectedTone(),
sort: !query.length
? SearchSort.UsageFrequency
: SearchSort.Default,
});
}
return emojis.map((emoji) => memoizedToItem.call(emoji, emojiProvider));
},
forceSelect(query: string, items: Array) {
const normalizedQuery = ':' + query;
const matchedItem = isFullShortName(normalizedQuery)
? items.find((item) => item.title.toLowerCase() === normalizedQuery)
: undefined;
return matchedItem;
},
selectItem(state, item, insert, { mode }) {
const { id = '', fallback, shortName } = item.emoji;
const text = fallback || shortName;
const emojiPluginState = emojiPluginKey.getState(
state,
) as EmojiPluginState;
if (
emojiPluginState.emojiProvider &&
emojiPluginState.emojiProvider.recordSelection &&
item.emoji
) {
emojiPluginState.emojiProvider
.recordSelection(item.emoji)
.then(recordSelectionSucceededSli(options))
.catch(recordSelectionFailedSli(options));
}
return addAnalytics(
state,
insert(
state.schema.nodes.emoji.createChecked({
shortName,
id,
text,
}),
),
{
action: ACTION.INSERTED,
actionSubject: ACTION_SUBJECT.DOCUMENT,
actionSubjectId: ACTION_SUBJECT_ID.EMOJI,
attributes: { inputMethod: INPUT_METHOD.TYPEAHEAD },
eventType: EVENT_TYPE.TRACK,
},
);
},
},
},
});
export default emojiPlugin;
/**
* Actions
*/
export const ACTIONS = {
SET_PROVIDER: 'SET_PROVIDER',
SET_RESULTS: 'SET_RESULTS',
};
export const setProvider = (provider?: EmojiProvider): Command => (
state,
dispatch,
) => {
if (dispatch) {
dispatch(
state.tr.setMeta(emojiPluginKey, {
action: ACTIONS.SET_PROVIDER,
params: { provider },
}),
);
}
return true;
};
export const setResults = (results: {
emojis: Array;
}): Command => (state, dispatch) => {
if (dispatch) {
dispatch(
state.tr.setMeta(emojiPluginKey, {
action: ACTIONS.SET_RESULTS,
params: { results },
}),
);
}
return true;
};
export const emojiPluginKey = new PluginKey('emojiPlugin');
export function getEmojiPluginState(state: EditorState) {
return (emojiPluginKey.getState(state) || {}) as EmojiPluginState;
}
export function emojiPluginFactory(
dispatch: Dispatch,
providerFactory: ProviderFactory,
portalProviderAPI: PortalProviderAPI,
eventDispatcher: EventDispatcher,
options?: EmojiPluginOptions,
) {
let emojiProvider: EmojiProvider;
let emojiProviderChangeHandler: {
result(res: { emojis: Array }): void;
};
return new Plugin({
key: emojiPluginKey,
state: {
init() {
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,
};
dispatch(emojiPluginKey, newPluginState);
return newPluginState;
case ACTIONS.SET_RESULTS:
newPluginState = {
...pluginState,
emojis: params.results.emojis,
};
dispatch(emojiPluginKey, newPluginState);
return newPluginState;
}
return newPluginState;
},
} as StateField,
props: {
nodeViews: {
emoji(node, view, getPos) {
const featureFlags = getFeatureFlags(view.state);
const createEmojiNodeView = featureFlags?.nextEmojiNodeView
? emojiNodeViewNext(providerFactory, options)
: emojiNodeView(
portalProviderAPI,
eventDispatcher,
providerFactory,
options,
);
return createEmojiNodeView(node, view, getPos);
},
},
},
view(editorView) {
const providerHandler = (
name: string,
providerPromise?: Promise,
) => {
switch (name) {
case 'emojiProvider':
if (!providerPromise) {
return setProvider(undefined)(
editorView.state,
editorView.dispatch,
);
}
providerPromise
.then((provider) => {
if (emojiProvider && emojiProviderChangeHandler) {
emojiProvider.unsubscribe(emojiProviderChangeHandler);
}
emojiProvider = provider;
setProvider(provider)(editorView.state, editorView.dispatch);
emojiProviderChangeHandler = {
result(emojis) {
// Emoji provider is synchronous and
// we need to make it async here to make PM happy
Promise.resolve().then(() => {
setResults(emojis)(editorView.state, editorView.dispatch);
});
},
};
provider.subscribe(emojiProviderChangeHandler);
})
.catch(() =>
setProvider(undefined)(editorView.state, editorView.dispatch),
);
break;
}
return;
};
providerFactory.subscribe('emojiProvider', providerHandler);
return {
destroy() {
if (providerFactory) {
providerFactory.unsubscribe('emojiProvider', providerHandler);
}
if (emojiProvider && emojiProviderChangeHandler) {
emojiProvider.unsubscribe(emojiProviderChangeHandler);
}
},
};
},
});
}