UNPKG

@atlaskit/editor-core

Version:

A package contains Atlassian editor core functionality

273 lines (226 loc) • 7.19 kB
import { EmojiId, EmojiProvider, EmojiSearchResult, EmojiDescription } from '@atlaskit/emoji'; import { EditorState, EditorView, Schema, Plugin, } from '../../prosemirror'; import { isMarkTypeAllowedAtCurrentPosition } from '../../utils'; import { inputRulePlugin } from './input-rules'; import keymapPlugin from './keymap'; import ProviderFactory from '../../providerFactory'; import emojiNodeView from './../../nodeviews/ui/emoji'; import nodeViewFactory from '../../nodeviews/factory'; import stateKey from './plugin-key'; export { stateKey }; export type StateChangeHandler = (state: EmojiState) => any; export type ProviderChangeHandler = (provider?: EmojiProvider) => any; export interface Options { emojiProvider: Promise<EmojiProvider>; } export class EmojiState { emojiProvider?: EmojiProvider; query?: string; enabled = true; queryActive = false; anchorElement?: HTMLElement; onSelectPrevious = (): boolean => false; onSelectNext = (): boolean => false; onSelectCurrent = (): boolean => false; private changeHandlers: StateChangeHandler[] = []; private state: EditorState<any>; private view: EditorView; private queryResult: EmojiDescription[] = []; constructor(state: EditorState<any>, providerFactory: ProviderFactory) { this.changeHandlers = []; this.state = state; providerFactory.subscribe('emojiProvider', this.handleProvider); } subscribe(cb: StateChangeHandler) { this.changeHandlers.push(cb); cb(this); } unsubscribe(cb: StateChangeHandler) { this.changeHandlers = this.changeHandlers.filter(ch => ch !== cb); } update(state: EditorState<any>) { this.state = state; if (!this.emojiProvider) { return; } const { emojiQuery } = state.schema.marks; const { doc, selection } = state; const { from, to } = selection; let dirty = false; const newEnabled = this.isEnabled(); if (newEnabled !== this.enabled) { this.enabled = newEnabled; dirty = true; } if (doc.rangeHasMark(from - 1, to, emojiQuery)) { if (!this.queryActive) { dirty = true; this.queryActive = true; } const { nodeBefore, /*nodeAfter*/ } = selection.$from; const newQuery = (nodeBefore && nodeBefore.textContent || ''); if (this.query !== newQuery) { dirty = true; this.query = newQuery; } } else if (this.queryActive) { dirty = true; this.dismiss(); return; } const newAnchorElement = this.view.dom.querySelector('[data-emoji-query]') as HTMLElement; if (newAnchorElement !== this.anchorElement) { dirty = true; this.anchorElement = newAnchorElement; } if (dirty) { this.changeHandlers.forEach(cb => cb(this)); } } dismiss(): boolean { this.queryActive = false; this.query = undefined; const { state, view } = this; if (state) { const { schema } = state; const { tr } = state; const markType = schema.mark('emojiQuery'); view.dispatch( tr .removeMark(0, state.doc.nodeSize - 2, markType) .removeStoredMark(markType) ); } return true; } isEnabled() { const { schema } = this.state; const { emojiQuery } = schema.marks; return isMarkTypeAllowedAtCurrentPosition(emojiQuery, this.state); } private findEmojiQueryMark() { const { state } = this; const { doc, schema, selection } = state; const { to, from } = selection; const { emojiQuery } = schema.marks; let start = from; let node = doc.nodeAt(start); while (start > 0 && (!node || !emojiQuery.isInSet(node.marks))) { start--; node = doc.nodeAt(start); } let end = start; if (node && emojiQuery.isInSet(node.marks)) { const resolvedPos = doc.resolve(start); // -1 is to include : in replacement // resolvedPos.depth + 1 to make emoji work inside other blocks e.g. "list item" or "blockquote" start = resolvedPos.start(resolvedPos.depth + 1) - 1; end = start + node.nodeSize; } // Emoji inserted via picker if (start === 0 && end === 0) { start = from; end = to; } return { start, end }; } insertEmoji(emojiId?: EmojiId) { const { state, view } = this; const { emoji } = state.schema.nodes; if (emoji && emojiId) { const { start, end } = this.findEmojiQueryMark(); const node = emoji.create({ ...emojiId, text: emojiId.fallback || emojiId.shortName }); const textNode = state.schema.text(' '); view.dispatch( state.tr.replaceWith(start, end, [node, textNode]) ); view.focus(); this.queryActive = false; this.query = undefined; } else { this.dismiss(); } } handleProvider = (name: string, provider: Promise<any>): void => { switch (name) { case 'emojiProvider': provider.then((emojiProvider: EmojiProvider) => { this.emojiProvider = emojiProvider; if (this.emojiProvider) { this.emojiProvider.subscribe(this.onProviderChange); } }).catch(() => { if (this.emojiProvider) { this.emojiProvider.unsubscribe(this.onProviderChange); } this.emojiProvider = undefined; }); break; } } trySelectCurrent = (): boolean => { const emojisCount = this.getEmojisCount(); if (emojisCount === 1) { this.insertEmoji(this.queryResult[0]); return true; } else if (emojisCount === 0 || this.isEmptyQuery()) { this.dismiss(); } return false; } private getEmojisCount = (): number => { return (this.queryResult && this.queryResult.length) || 0; } private isEmptyQuery = (): boolean => { return !this.query || this.query === ':'; } onSearchResult = (searchResults: EmojiSearchResult): void => { this.queryResult = searchResults.emojis; } private onProviderChange = { result: this.onSearchResult, }; setView(view: EditorView) { this.view = view; } } export function createPlugin(providerFactory: ProviderFactory) { return new Plugin({ state: { init(config, state) { return new EmojiState(state, providerFactory); }, apply(tr, pluginState, oldState, newState) { // NOTE: Don't call pluginState.update here. return pluginState; } }, props: { nodeViews: { emoji: nodeViewFactory(providerFactory, { emoji: emojiNodeView }) } }, key: stateKey, view: (view: EditorView) => { const pluginState: EmojiState = stateKey.getState(view.state); pluginState.setView(view); return { update(view: EditorView, prevState: EditorState<any>) { pluginState.update(view.state); }, destroy() { providerFactory.unsubscribe('emojiProvider', pluginState.handleProvider); } }; } }); } const plugins = (schema: Schema<any, any>, providerFactory) => { return [createPlugin(providerFactory), inputRulePlugin(schema), keymapPlugin(schema)].filter(plugin => !!plugin) as Plugin[]; }; export default plugins;