EventMention.ts5.3 KBView on GitHub 'use client';
import { Extension, type Editor, type Range } from '@tiptap/core';
import { PluginKey } from '@tiptap/pm/state';
import { Suggestion, type SuggestionOptions } from '@tiptap/suggestion';
import { ReactRenderer } from '@tiptap/react';
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
type Placement,
type VirtualElement,
} from '@floating-ui/dom';
import {
EventMentionList,
type EventMentionItem,
type EventMentionListRef,
} from './EventMentionList';
const EVENT_MENTION_PLUGIN_KEY = new PluginKey('eventMentionSuggestion');
/**
* Match a `{{query` typed by the user. Custom matcher because Tiptap's
* default findSuggestionMatch treats `char` as a single trigger and would
* otherwise fire after just one `{`.
*/
const findEventMentionMatch: SuggestionOptions['findSuggestionMatch'] = ({ $position }) => {
const text = $position.nodeBefore?.isText ? $position.nodeBefore.text ?? '' : '';
// The text we look at runs up to the cursor; the trigger is the most
// recent `{{` not separated from the cursor by whitespace.
const match = /\{\{([^{}\n]*)$/.exec(text);
if (!match) return null;
const fullLen = match[0].length;
const from = $position.pos - fullLen;
const to = $position.pos;
return {
range: { from, to },
query: match[1] ?? '',
text: match[0],
};
};
export interface EventMentionOptions {
search: (query: string) => Promise<EventMentionItem[]>;
onSelect: (event: EventMentionItem, editor: Editor, range: Range) => void;
}
/**
* `{{` Suggestion plugin for inserting event chips into any document. Global
* search — matches across the current user's full event stream.
*/
export function createEventMention(opts: EventMentionOptions) {
return Extension.create({
name: 'eventMention',
addProseMirrorPlugins() {
return [
Suggestion({
pluginKey=[redacted],
editor: this.editor,
char: '{{',
allowSpaces: true,
startOfLine: false,
findSuggestionMatch: findEventMentionMatch,
items: async ({ query }) => {
try {
return await opts.search(query);
} catch (error) {
console.warn('[EventMention] search failed', error);
return [];
}
},
command: ({ editor, range, props }) => {
const event = props as EventMentionItem;
opts.onSelect(event, editor, range);
},
render: () => {
let component: ReactRenderer<EventMentionListRef> | null = null;
let popupElement: HTMLDivElement | null = null;
let cleanup: (() => void) | null = null;
const ensurePopup = (clientRect: (() => DOMRect | null) | null | undefined) => {
if (!clientRect || !component) return;
if (!popupElement) {
popupElement = document.createElement('div');
popupElement.style.position = 'absolute';
popupElement.style.zIndex = '99999';
popupElement.appendChild(component.element);
document.body.appendChild(popupElement);
}
const virtualElement: VirtualElement = {
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
};
const updatePosition = async () => {
if (!popupElement) return;
const { x, y } = await computePosition(virtualElement, popupElement, {
placement: 'bottom-start' as Placement,
middleware: [offset(6), flip(), shift({ padding: 5 })],
});
Object.assign(popupElement.style, { left: `${x}px`, top: `${y}px` });
};
cleanup?.();
cleanup = autoUpdate(virtualElement, popupElement, updatePosition);
};
const teardown = () => {
cleanup?.();
cleanup = null;
if (popupElement?.parentNode) popupElement.parentNode.removeChild(popupElement);
popupElement = null;
component?.destroy();
component = null;
};
return {
onStart: (props) => {
component = new ReactRenderer(EventMentionList, {
props: {
items: (props.items ?? []) as EventMentionItem[],
command: (item: EventMentionItem) => props.command(item),
loading: false,
},
editor: props.editor,
});
ensurePopup(props.clientRect);
},
onUpdate: (props) => {
component?.updateProps({
items: (props.items ?? []) as EventMentionItem[],
command: (item: EventMentionItem) => props.command(item),
loading: false,
});
ensurePopup(props.clientRect);
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
teardown();
return true;
}
return component?.ref?.onKeyDown({ event: props.event }) ?? false;
},
onExit: teardown,
};
},
}),
];
},
});
}