@atlaskit/editor-core
Version:
A package contains Atlassian editor core functionality
217 lines (182 loc) • 7.17 kB
text/typescript
import { EmojiProvider, EmojiDescription } from '@atlaskit/emoji';
import { EditorState, Transaction, Plugin, PluginKey, inputRules, Schema, Node } from '../../prosemirror';
import { createInputRule, leafNodeReplacementCharacter } from '../utils';
import { isMarkTypeAllowedAtCurrentPosition } from '../../utils';
import ProviderFactory from '../../providerFactory';
let matcher: AsciiEmojiMatcher;
export function inputRulePlugin(schema: Schema<any, any>, providerFactory?: ProviderFactory): Plugin | undefined {
if (schema.nodes.emoji && providerFactory) {
initMatcher(providerFactory);
const asciiEmojiRule = createInputRule(AsciiEmojiMatcher.REGEX, inputRuleHandler);
return inputRules({
rules: [asciiEmojiRule]
});
}
}
function initMatcher(providerFactory: ProviderFactory) {
const handleProvider = (name: string, provider?: Promise<EmojiProvider>) => {
if (!provider) {
return;
}
provider.then(emojiProvider => {
emojiProvider.getAsciiMap().then(map => {
matcher = new RecordingAsciiEmojiMatcher(emojiProvider, map);
});
});
};
providerFactory.subscribe('emojiProvider', handleProvider);
}
function inputRuleHandler(state: EditorState<Schema<any, any>>, matchParts: [string], start: number, end: number): Transaction | undefined {
if (!matcher) { return undefined; }
if (!isEnabled(state)) { return undefined; }
const match = matcher.match(matchParts);
if (match) {
const transactionCreator = new AsciiEmojiTransactionCreator(state, match, start, end);
return transactionCreator.create();
}
return undefined;
}
function isEnabled(state: EditorState<Schema<any, any>>) {
const emojiQuery = state.schema.marks.emojiQuery;
const isEmojiQueryActive = state.selection.$from.marks().some(mark => mark.type === emojiQuery);
return isEmojiQueryActive || isMarkTypeAllowedAtCurrentPosition(emojiQuery, state);
}
type AsciiEmojiMatch = {
emoji: EmojiDescription;
leadingString: string;
trailingString: string;
};
class AsciiEmojiMatcher {
/**
* This regex matches 2 scenarios:
* 1. an emoticon starting with a colon character (e.g. :D => 😃)
* 2. an emoticon not starting with a colon character (e.g. 8-D => 😎)
*
* Explanation (${leafNodeReplacementCharacter} is replaced with character \ufffc)
*
* 1st Capturing Group ((?:^|[\s\ufffc])(?:\(*?))
* Non-capturing group (?:^|[\s\ufffc])
* 1st Alternative ^
* ^ asserts position at start of the string
* 2nd Alternative [\s\ufffc]
* matches a single character present in [\s\ufffc]
* Non-capturing group (?:\(*?)
* matches the character ( literally between zero and unlimited times, as few times as possible, expanding as needed (lazy)
* 2nd Capturing Group (\(y\)|[^:\s\ufffc\(]\S{1,3}|:\S{1,3}( ))
* 1st Alternative \(y\)
* matches the string (y) literally (case sensitive)
* 2nd Alternative [^:\s\ufffc\(]\S{1,3}
* matches a single character not present in [^:\s\ufffc\(] between 1 and 3 times, as many times as possible, giving back as needed (greedy)
* 3rd Alternative :\S{1,3}( )
* : matches the character : literally
* \S{1,3} matches any non-whitespace character between 1 and 3 times, as many times as possible, giving back as needed (greedy)
* 3rd Capturing Group ( )
* matches the character [space] literally
* $ asserts position at the end of the string
*
* See https://regex101.com/r/HRS9O2/2
*/
static REGEX = new RegExp(`((?:^|[\\s${leafNodeReplacementCharacter}])(?:\\(*?))(\\(y\\)|[^:\\s${leafNodeReplacementCharacter}\\(]\\S{1,3}|:\\S{1,3}( ))$`);
private static REGEX_LEADING_CAPTURE_INDEX = 1;
private static REGEX_EMOJI_ASCII_CAPTURE_INDEX = 2;
private static REGEX_TRAILING_CAPTURE_INDEX = 3;
private asciiToEmojiMap: Map<string, EmojiDescription>;
constructor(asciiToEmojiMap: Map<string, EmojiDescription>) {
this.asciiToEmojiMap = asciiToEmojiMap;
}
match(matchParts: string[]): AsciiEmojiMatch | undefined {
let emoji = this.getEmoji(matchParts);
if (emoji) {
return {
emoji: emoji,
leadingString: AsciiEmojiMatcher.getLeadingString(matchParts),
trailingString: AsciiEmojiMatcher.getTrailingString(matchParts),
};
}
}
private getEmoji(match: string[]): EmojiDescription | undefined {
let ascii = match[AsciiEmojiMatcher.REGEX_EMOJI_ASCII_CAPTURE_INDEX].trim();
if (ascii) {
return this.asciiToEmojiMap.get(ascii);
}
return undefined;
}
private static getLeadingString(match: string[]): string {
return match[AsciiEmojiMatcher.REGEX_LEADING_CAPTURE_INDEX];
}
private static getTrailingString(match: string[]): string {
return match[AsciiEmojiMatcher.REGEX_TRAILING_CAPTURE_INDEX] || '';
}
}
/**
* A matcher that will record ascii matches as usages of the matched emoji.
*/
class RecordingAsciiEmojiMatcher extends AsciiEmojiMatcher {
private emojiProvider: EmojiProvider;
constructor(emojiProvider: EmojiProvider, asciiToEmojiMap: Map<string, EmojiDescription>) {
super(asciiToEmojiMap);
this.emojiProvider = emojiProvider;
}
match(matchParts: string[]): AsciiEmojiMatch | undefined {
const match = super.match(matchParts);
if (match && this.emojiProvider.recordSelection) {
this.emojiProvider.recordSelection(match.emoji);
}
return match;
}
}
class AsciiEmojiTransactionCreator {
private state: EditorState<Schema<any, any>>;
private match: AsciiEmojiMatch;
private start: number;
private end: number;
constructor(state: EditorState<Schema<any, any>>, match: AsciiEmojiMatch, start: number, end: number) {
this.state = state;
this.match = match;
this.start = start;
this.end = end;
}
create(): Transaction {
return this.state.tr.replaceWith(
this.from,
this.to,
this.createNodes()
);
}
private get from(): number {
return this.start + this.match.leadingString.length;
}
private get to(): number {
return this.end;
}
private createNodes(): Node[] {
const nodes = [this.createEmojiNode()];
if (this.trailingTextNodeRequired()) {
nodes.push(this.createTrailingTextNode());
}
return nodes;
}
private createEmojiNode(): Node {
const { emoji: emojiTypeNode } = this.state.schema.nodes;
return emojiTypeNode.create(this.getEmojiNodeAttrs());
}
private getEmojiNodeAttrs() {
const emoji = this.match.emoji;
return {
id: emoji.id,
shortName: emoji.shortName,
text: emoji.fallback || emoji.shortName
};
}
private trailingTextNodeRequired(): boolean {
return this.match.trailingString.length > 0;
}
private createTrailingTextNode(): Node {
return this.state.schema.text(this.match.trailingString);
}
}
export const stateKey = new PluginKey('asciiEmojiPlugin');
const plugins = (schema: Schema<any, any>, providerFactory?: ProviderFactory) => {
return [inputRulePlugin(schema, providerFactory)].filter((plugin) => !!plugin) as Plugin[];
};
export default plugins;