ReferenceMention.ts4.7 KBView on GitHub 'use client';
import { Extension, type Editor, type Range } from '@tiptap/core';
import { PluginKey } from '@tiptap/pm/state';
import { Suggestion } from '@tiptap/suggestion';
import { ReactRenderer } from '@tiptap/react';
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
type Placement,
type VirtualElement,
} from '@floating-ui/dom';
import {
ReferenceMentionList,
type ReferenceMentionListRef,
} from './ReferenceMentionList';
import { REFERENCE_OPTIONS, type ReferenceOption } from './references';
// Distinct PluginKey — avoids colliding with the slash-command and `#` mention
// Suggestion plugins (all default to `suggestion$`).
const REFERENCE_MENTION_PLUGIN_KEY = new PluginKey('referenceMentionSuggestion');
export interface ReferenceMentionOptions {
/** Allow callers (the seed-time custom paths) to extend the static option list. */
options?: ReferenceOption[];
onSelect: (item: ReferenceOption, editor: Editor, range: Range) => void;
}
/**
* `@` Suggestion plugin for playbook documents. The menu is the fixed list of
* playbook references ({@link REFERENCE_OPTIONS}); typing after `@` filters by
* path. Picking an option inserts a `referenceNode` chip via the caller.
*/
export function createReferenceMention(opts: ReferenceMentionOptions) {
const options = opts.options ?? REFERENCE_OPTIONS;
return Extension.create({
name: 'referenceMention',
addProseMirrorPlugins() {
return [
Suggestion({
pluginKey=[redacted],
editor: this.editor,
char: '@',
allowSpaces: false,
startOfLine: false,
items: ({ query }) => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.path.toLowerCase().includes(q));
},
command: ({ editor, range, props }) => {
opts.onSelect(props as ReferenceOption, editor, range);
},
render: () => {
let component: ReactRenderer<ReferenceMentionListRef> | 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(ReferenceMentionList, {
props: {
items: (props.items ?? []) as ReferenceOption[],
command: (item: ReferenceOption) => props.command(item),
},
editor: props.editor,
});
ensurePopup(props.clientRect);
},
onUpdate: (props) => {
component?.updateProps({
items: (props.items ?? []) as ReferenceOption[],
command: (item: ReferenceOption) => props.command(item),
});
ensurePopup(props.clientRect);
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
teardown();
return true;
}
return component?.ref?.onKeyDown({ event: props.event }) ?? false;
},
onExit: teardown,
};
},
}),
];
},
});
}