@atlaskit/editor-core
Version:
A package contains Atlassian editor core functionality
273 lines (226 loc) • 7.19 kB
text/typescript
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;