Skip to content

Instantly share code, notes, and snippets.

@BrianHung
Created July 23, 2021 08:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save BrianHung/6657011b2daf937434f021613b08b882 to your computer and use it in GitHub Desktop.
Save BrianHung/6657011b2daf937434f021613b08b882 to your computer and use it in GitHub Desktop.
ProseMirror Autocomplete
import { ResolvedPos } from 'prosemirror-model';
import type { EditorState } from 'prosemirror-state'
import { Plugin, PluginKey, Transaction } from 'prosemirror-state'
import type { EditorView } from 'prosemirror-view'
import { Decoration, DecorationSet } from 'prosemirror-view'
import { TextSelection } from "prosemirror-state";
/**
* Create a matcher that matches when a specific character is typed.
* Used for @mentions and #tags.
* @param {Regex} regexp
* @returns {match}
*/
function positionMatcher(regexp: RegExp, pos: ResolvedPos) {
const text = pos.doc.textBetween(pos.before(), pos.end(), '\0', '\0');
let match = regexp.exec(text);
if (match) {
let from = pos.start() + match.index, to = from + match[0].length;
if (from < pos.pos && to >= pos.pos) {
return {range: {from, to}, query: match[1] || "", text: match[0]}
}
}
}
export type AutocompletePluginProps = {
regexp: RegExp;
style?: string;
pluginKey?: PluginKey;
onEnter?: Function;
onChange?: Function;
onLeave?: Function;
onKeyDown?: Function;
}
export type AutocompletePluginState = {
active: boolean;
range: {from: number, to: number} | null;
query: string | null;
text: string | null;
}
const initialPluginState: AutocompletePluginState = {active: false, range: null, query: null, text: null }
export function Autocomplete({regexp, style = '', pluginKey = new PluginKey('Autocomplete'), onEnter, onChange, onLeave, onKeyDown}: AutocompletePluginProps) {
return new Plugin({
key: pluginKey,
// this: PluginSpec
view () {
return {
update: (view: EditorView, prevState: EditorState) => {
// get plugin state from editor state
const prev = this.key.getState(prevState);
const next = this.key.getState(view.state);
// compute how plugin state has changed
const started = !prev.active && next.active;
const stopped = prev.active && !next.active;
const changed = prev.active && next.active && prev.query !== next.query;
// handler may not be set until after plugin is initialized with PluginSpec
let plugin = this.key.get(view.state);
onEnter = onEnter || plugin.onEnter;
onLeave = onLeave || plugin.onLeave;
onChange = onChange || plugin.onChange;
// call handler depending on state
const props = { view, range: next.range, query: next.query, text: next.text };
started && onEnter && onEnter(props);
stopped && onLeave && onLeave(props);
changed && onChange && onChange(props);
// PSA: If external autocomplete is initialized after this plugin (s.t. onEnter is null),
// external state should grab plugin state via key.getState(view.state).
},
}
},
// this: PluginInstance
state: {
init: (config, editorState) => initialPluginState,
apply: (tr: Transaction, prevState: AutocompletePluginState): AutocompletePluginState => {
const $from = tr.selection.$from;
// autocomplete only on text selection and in non-code textblocks
if (tr.selection instanceof TextSelection && !$from.parent.type.spec.code && $from.parent.isTextblock) {
let match = positionMatcher(regexp, $from);
if (match) {
return {active: true, ...match};
}
}
return initialPluginState;
},
},
// this: PluginInstance
props: {
// call the keydown hook if autocomplete is active
handleKeyDown(view, event) {
const state = this.getState(view.state)
if (!state.active) { return false; }
// handler may not be set until after plugin is initialized with PluginSpec
// @ts-ignore
onKeyDown = this.onKeyDown || onKeyDown;
return onKeyDown && onKeyDown({view, event, ...state});
},
// setup decorations on active autocomplete range
decorations(editorState) {
const state = this.getState(editorState)
if (!state.active) return DecorationSet.empty;
return DecorationSet.create(editorState.doc, [
Decoration.inline(state.range.from, state.range.to, {
nodeName: 'span', class: `ProseMirror-autocomplete ${style}`
})
]);
},
},
})
}
export default Autocomplete;
import { PluginKey } from "prosemirror-state"
import { Autocomplete } from "editor"
export const AutocompleteCommandsKey = new PluginKey("AutocompleteCommands")
export const AutocompleteCommands = Autocomplete({
regexp: /^\/([a-zA-Z0-9]*)?/,
pluginKey: AutocompleteCommandsKey,
})
import React, { useReducer, useState, useEffect } from 'react'
import type { Editor } from "editor"
import Fuse from 'fuse.js';
import Tippy from '@tippyjs/react';
import { Placement } from "tippy.js";
import 'tippy.js/animations/shift-away.css';
import sprite from "./sprite.svg"
import { AutocompleteCommandsKey } from './key-plugin';
export interface CommandItem {
command: Function;
group: string;
name: string;
description: string;
spriteId: string;
shortcut: string;
}
const BASIC_COMMANDS = [
{
command: (editor: Editor) => editor.commands.paragraph(),
group: "basic",
name: "Paragraph",
description: "Write with plain text",
spriteId: "text",
shortcut: "p",
},
{
command: (editor: Editor) => editor.commands.heading({level: 1}),
group: "basic",
name: "Heading 1",
description: "Large section heading",
spriteId: "heading1",
shortcut: "h1",
},
{
command: (editor: Editor) => editor.commands.heading({level: 2}),
group: "basic",
name: "Heading 2",
description: "Medium section heading",
spriteId: "heading2",
shortcut: "h2",
},
{
command: (editor: Editor) => editor.commands.heading({level: 3}),
group: "basic",
name: "Heading 3",
description: "Small section heading",
spriteId: "heading3",
shortcut: "h3",
},
{
command: (editor: Editor) => editor.commands.blockquote(),
group: "basic",
name: "Blockquote",
description: "Capture a quote",
spriteId: "blockquote",
shortcut: "quote",
},
{
command: (editor: Editor) => editor.commands.horizontalrule(),
group: "basic",
name: "Horizontal Rule",
description: "Separate your content",
spriteId: "divider",
shortcut: "rule",
},
{
command: (editor: Editor) => editor.commands.itemlist(),
group: "list",
name: "Item List",
description: "List anything with bullets",
spriteId: "item",
shortcut: "item",
},
{
command: (editor: Editor) => editor.commands.enumlist(),
group: "list",
name: "Enum List",
description: "Count with enumerated items",
spriteId: "enum",
shortcut: "enum",
},
{
command: (editor: Editor) => editor.commands.todolist(),
group: "list",
name: "Todo List",
description: "Check off items with todos",
spriteId: "todo",
shortcut: "todo",
},
{
command: (editor: Editor) => editor.commands.togglelist(),
group: "list",
name: "Toggle List",
description: "Hide and show content",
spriteId: "details",
shortcut: "details"
},
{
command: (editor: Editor) => editor.commands.codeblock(),
group: "advanced",
name: "Code Block",
description: "Highlight code snippets",
spriteId: "code",
shortcut: "code",
},
{
command: (editor: Editor) => editor.commands.mathblock(),
group: "advanced",
name: "Math Block",
description: "Render math formulas with KaTeX",
spriteId: "mathblock",
shortcut: "math",
},
] as CommandItem[];
const initMenuState = {items: BASIC_COMMANDS, index: 0, query: null, range: null}
const menuReducer = (prevState, action) => Object.assign({}, prevState, typeof action === 'function' ? action(prevState) : action)
export default function EditorCommandMenu({editor}: {editor: Editor}): JSX.Element {
const [state, dispatch] = useReducer(menuReducer, initMenuState);
const [fuse] = useState(new Fuse(BASIC_COMMANDS, { threshold: 0.10, keys: [{name: "shortcut", weight: 1.50}, {name: "name", weight: 1.50}, {name: "description", weight: 0.50}]}));
const onKeyDown = ({event}: {event: KeyboardEvent}) => {
switch (event.key) {
case "ArrowUp":
dispatch(state => ({index: (state.index + state.items.length - 1) % state.items.length}));
return true
case "ArrowDown":
dispatch(state => ({index: (state.index + state.items.length + 1) % state.items.length}))
return true
case "Enter":
dispatch(state => deleteAndDispatch(state, state.items[state.index]?.command))
return true
default:
return false
}
}
useEffect(() => {
// Register handlers between plugin and react state.
let plugin = AutocompleteCommandsKey.get(editor.view.state);
plugin.onEnter = ({query, range}) => dispatch({query, range, index: 0, items: !query ? BASIC_COMMANDS : fuse.search(query).map(result => result.item)});
plugin.onChange = ({query, range}) => dispatch({query, range, index: 0, items: !query ? BASIC_COMMANDS : fuse.search(query).map(result => result.item)});
plugin.onLeave = ({query, range}) => dispatch(initMenuState);
plugin.onKeyDown = onKeyDown;
// Get initial state from plugin.
let { active, query, range } = AutocompleteCommandsKey.getState(editor.view.state);
active && dispatch({query, range, index: 0, items: !query ? BASIC_COMMANDS : fuse.search(query).map(result => result.item)});
}, [editor]);
const deleteAndDispatch = (menuState, command: Function | undefined) => {
const view = editor.view;
view.dispatch(view.state.tr.delete(menuState.range.from, menuState.range.to));
command && command(editor);
return initMenuState;
}
const getReferenceClientRect = (): DOMRect => {
const refElement = editor.view.dom.querySelector(`.ProseMirror-autocomplete`);
// avoid shifts in transformX between onChange
if (refElement) return Object.assign(refElement.getBoundingClientRect().toJSON(), {width: 0})
return new DOMRect(-9999, -9999, 0, 0);
}
const tippyProps = {
getReferenceClientRect,
arrow: false,
className: "focus:outline-none",
interactive: true,
placement: "bottom-start" as Placement,
maxWidth: 'none',
animation: "shift-away",
duration: 50,
visible: state.query !== null,
reference: editor.view.dom,
zIndex: 10,
}
return (
<Tippy {...tippyProps}
content = {
<div className="border border-gray-200 shadow-lg rounded overflow-y-auto bg-white max-h-[calc(19.75rem+14px)] min-w-[18rem]">
{ state.items.length !== 0
? state.items.map((item: CommandItem, index) => {
const [ firstItemOfGroup ] = state.items.filter((i: CommandItem) => i.group === item.group);
const isFirst = item === firstItemOfGroup;
const menuItemProps = {
className: `flex w-full px-3 py-1.5 space-x-3 select-none cursor-pointer transition-colors duration-[25ms] hover:bg-sky-100 active:bg-sky-200`,
key: item.name,
onClick: () => dispatch(state => deleteAndDispatch(state, item.command)),
...(state.items[state.index] === item) && {
className: `flex w-full px-3 py-1.5 space-x-3 select-none cursor-pointer transition-colors duration-[25ms] bg-sky-100 hover:bg-sky-200`,
style: {scrollMarginTop: '1.75rem'}, // prevent sticky header from covering item on scrollIntoView
ref: (elem: HTMLElement) => elem && elem.scrollIntoView({behavior: "smooth", block: 'nearest'}),
}
}
return ([
isFirst && <div className="px-3 py-1.5 text-xs font-medium text-gray-700 uppercase select-none bg-white sticky top-0" key={item.group}>{item.group} blocks</div>,
<div {...menuItemProps}>
<div className="rounded-sm bg-white border border-gray-200">
<svg className="h-9 w-9">
<use href={`${sprite}#${item.spriteId}`}/>
</svg>
</div>
<div>
<div className="text-sm font-medium">{item.name}</div>
<div className="text-xs text-gray-500">{item.description}</div>
</div>
</div>
])
})
: <div className="px-3 py-1.5 text-xs font-medium text-gray-800 uppercase select-none bg-white"
onClick={() => dispatch(state => deleteAndDispatch(state, undefined))
}>
No results
</div>
}
</div>
}
/>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment