Path: blob/master/src/packages/frontend/editors/slate/slate-emojis/hook.ts
1697 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MIT (same as slate uses https://github.com/ianstormtaylor/slate/blob/master/License.md)3*/45import { debounce } from "lodash";6import React, { useCallback, useMemo, useState } from "react";7import { Editor, Range, Text, Transforms } from "slate";89import { useIsMountedRef } from "@cocalc/frontend/app-framework";10import {11Complete,12Item,13} from "@cocalc/frontend/editors/markdown-input/complete";14import { field_cmp } from "@cocalc/util/misc";15import emojis from "markdown-it-emoji/lib/data/full.json";16import lite from "markdown-it-emoji/lib/data/light.json";17import { ReactEditor } from "../slate-react";1819const MAX_MATCHES = 200;20const EMOJIS_ALL: Item[] = [];21const EMOJIS_LITE: Item[] = [];22function init() {23for (const value in emojis) {24EMOJIS_ALL.push({ label: `${emojis[value]}\t ${value}`, value });25}26EMOJIS_ALL.sort(field_cmp("value"));2728for (const value in lite) {29EMOJIS_LITE.push({ label: `${lite[value]}\t ${value}`, value });30}31EMOJIS_LITE.sort(field_cmp("value"));32EMOJIS_LITE.push({33label: "(type to search thousands of emojis)",34value: "",35});36}3738interface Options {39editor: ReactEditor;40insertEmoji: (editor: Editor, content: string, markup: string) => void;41}4243interface EmojisControl {44onChange: () => void;45onKeyDown: (event) => void;46Emojis: React.JSX.Element | undefined;47}4849export const useEmojis: (Options) => EmojisControl = ({50editor,51insertEmoji,52}) => {53if (EMOJIS_ALL.length == 0) {54init();55}56const [target, setTarget] = useState<Range | undefined>();57const [search, setSearch] = useState("");58const isMountedRef = useIsMountedRef();5960const items: Item[] = useMemo(() => {61if (!search) {62// just show most popular63return EMOJIS_LITE;64}65// actual search: show MAX_MATCHES matches66const v: Item[] = [];67for (const x of EMOJIS_ALL) {68if (x.value.includes(search)) {69v.push(x);70if (v.length > MAX_MATCHES) {71v.push({ label: "(type more to search emojis)", value: "" });72return v;73}74}75}76return v;77}, [search]);7879const onKeyDown = useCallback(80(event) => {81if (target == null) return;82switch (event.key) {83case "ArrowDown":84case "ArrowUp":85case "Tab":86case "Enter":87event.preventDefault();88break;89case "Escape":90event.preventDefault();91setTarget(undefined);92break;93}94},95[target],96);9798// we debounce this onChange, since it is VERY expensive and can make typing feel99// very laggy on a large document!100// Also, we only show the emoji dialog on :[something] rather than just :, since101// it is incredibly annoying and common to do the following: something here. See102// what I just did? For the @ mentions, there's no common use in english of @[space].103const onChange = useCallback(104debounce(() => {105try {106if (!isMountedRef.current) return;107const { selection } = editor;108if (!selection || !Range.isCollapsed(selection)) return;109const { focus } = selection;110let current;111try {112[current] = Editor.node(editor, focus);113} catch (_err) {114// I think due to debounce, somehow this Editor.node above is115// often invalid while user is typing.116return;117}118if (!Text.isText(current)) return;119120const charBeforeCursor = current.text[focus.offset - 1];121let afterMatch, beforeMatch, beforeRange, search;122if (charBeforeCursor == ":") {123return;124}125const wordBefore = Editor.before(editor, focus, { unit: "word" });126const before = wordBefore && Editor.before(editor, wordBefore);127beforeRange = before && Editor.range(editor, before, focus);128const beforeText = beforeRange && Editor.string(editor, beforeRange);129if (beforeText == ":") {130return;131}132beforeMatch = beforeText && beforeText.match(/^:(\w*)$/);133search = beforeMatch?.[1];134const after = Editor.after(editor, focus);135const afterRange = Editor.range(editor, focus, after);136const afterText = Editor.string(editor, afterRange);137afterMatch = afterText.match(/^(\s|$)/);138if (charBeforeCursor == ":" || (beforeMatch && afterMatch)) {139search = search.toLowerCase().trim();140setSearch(search);141setTarget(beforeRange);142return;143}144145setTarget(undefined);146} catch (err) {147console.log("WARNING -- slate.emojis", err);148}149}, 250),150[editor],151);152153const renderEmojis = useCallback(() => {154if (target == null) return;155let domRange;156try {157domRange = ReactEditor.toDOMRange(editor, target);158} catch (_err) {159// target gets set by the onChange handler above, so editor could160// have changed by the time we call toDOMRange here, making161// the target no longer meaningful. Thus this try/catch is162// completely reasonable (alternatively, when we deduce the target,163// we also immediately set the domRange in a ref).164return;165}166167const onSelect = (markup) => {168Transforms.select(editor, target);169insertEmoji(editor, emojis[markup] ?? "?", markup);170setTarget(undefined);171ReactEditor.focus(editor);172// Move the cursor forward 2 spaces:173Transforms.move(editor, { distance: 2, unit: "character" });174};175176const rect = domRange.getBoundingClientRect();177return React.createElement(Complete, {178items,179onSelect,180onCancel: () => setTarget(undefined),181position: {182top: rect.bottom,183left: rect.left + rect.width,184},185});186}, [search, target]);187188return {189onChange,190onKeyDown,191Emojis: renderEmojis(),192};193};194195196