Path: blob/master/src/packages/frontend/editors/slate/slate-mentions/hook.ts
1698 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*/45/* Adapted from6https://github.com/ianstormtaylor/slate/blob/master/site/examples/mentions.tsx7One thing that makes this implementation more complicated is that if you just type8the @ symbol and nothing else, it immediately pops up the mentions dialog. In9the demo above, it does not, which is EXTREMELY disconcerting.10*/1112import { Editor, Range, Text, Transforms } from "slate";13import { ReactEditor } from "../slate-react";14import React from "react";15import { useIsMountedRef } from "@cocalc/frontend/app-framework";16import { useCallback, useEffect, useMemo, useState } from "react";17import {18Complete,19Item,20} from "@cocalc/frontend/editors/markdown-input/complete";21import { debounce } from "lodash";2223interface Options {24editor: ReactEditor;25insertMention: (Editor, string) => void;26matchingUsers: (search: string) => (string | React.JSX.Element)[];27isVisible?: boolean;28}2930interface MentionsControl {31onChange: () => void;32onKeyDown: (event) => void;33Mentions: React.JSX.Element | undefined;34}3536export const useMentions: (Options) => MentionsControl = ({37isVisible,38editor,39insertMention,40matchingUsers,41}) => {42const [target, setTarget] = useState<Range | undefined>();43const [search, setSearch] = useState("");44const isMountedRef = useIsMountedRef();4546useEffect(() => {47if (!isVisible && target) {48setTarget(undefined);49}50}, [isVisible]);5152const items: Item[] = useMemo(() => {53return matchingUsers(search.toLowerCase());54}, [search]);5556const onKeyDown = useCallback(57(event) => {58if (target == null) return;59switch (event.key) {60case "ArrowDown":61case "ArrowUp":62case "ArrowLeft":63case "ArrowRight":64case "Tab":65case "Enter":66event.preventDefault();67break;68case "Escape":69event.preventDefault();70setTarget(undefined);71break;72}73},74[target],75);7677// we debounce this onChange, since it is VERY expensive and can make typing feel78// very laggy on a large document!79const onChange = useCallback(80debounce(() => {81try {82if (!isMountedRef.current) return;83const { selection } = editor;84if (selection && Range.isCollapsed(selection)) {85const { focus } = selection;86let current;87try {88[current] = Editor.node(editor, focus);89} catch (_err) {90// I think due to debounce, somehow this Editor.node above is91// often invalid while user is typing.92return;93}94if (Text.isText(current)) {95const charBeforeCursor = current.text[focus.offset - 1];96// keep use of this consistent with before stuff in frontend/editors/markdown-input/component.tsx97const charBeforeBefore = current.text[focus.offset - 2]?.trim();98let afterMatch, beforeMatch, beforeRange, search;99if (charBeforeCursor == "@") {100beforeRange = {101focus: editor.selection.focus,102anchor: {103path: editor.selection.anchor.path,104offset: editor.selection.anchor.offset - 1,105},106};107search = "";108afterMatch = beforeMatch = null;109} else {110const wordBefore = Editor.before(editor, focus, { unit: "word" });111const before = wordBefore && Editor.before(editor, wordBefore);112beforeRange = before && Editor.range(editor, before, focus);113const beforeText =114beforeRange && Editor.string(editor, beforeRange);115beforeMatch = beforeText && beforeText.match(/^@(\w*)$/);116search = beforeMatch?.[1];117const after = Editor.after(editor, focus);118const afterRange = Editor.range(editor, focus, after);119const afterText = Editor.string(editor, afterRange);120afterMatch = afterText.match(/^(\s|$)/);121}122if (123(charBeforeCursor == "@" &&124(!charBeforeBefore ||125charBeforeBefore == "(" ||126charBeforeBefore == "[")) ||127(beforeMatch && afterMatch)128) {129setTarget(beforeRange);130setSearch(search);131return;132}133}134}135136setTarget(undefined);137} catch (err) {138console.log("WARNING -- slate.mentions", err);139}140}, 250),141[editor],142);143144const renderMentions = useCallback(() => {145if (target == null) return;146let domRange;147try {148domRange = ReactEditor.toDOMRange(editor, target);149} catch (_err) {150// target gets set by the onChange handler above, so editor could151// have changed by the time we call toDOMRange here, making152// the target no longer meaningful. Thus this try/catch is153// completely reasonable (alternatively, when we deduce the target,154// we also immediately set the domRange in a ref).155return;156}157158const onSelect = (value) => {159Transforms.select(editor, target);160insertMention(editor, value);161setTarget(undefined);162ReactEditor.focus(editor);163// Move the cursor forward 2 spaces:164Transforms.move(editor, { distance: 2, unit: "character" });165};166167const rect = domRange.getBoundingClientRect();168return React.createElement(Complete, {169items,170onSelect,171onCancel: () => setTarget(undefined),172position: {173top: rect.bottom,174left: rect.left + rect.width,175},176});177}, [search, target]);178179return {180onChange,181onKeyDown,182Mentions: renderMentions(),183};184};185186187