HashMention.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 {
HashMentionList,
HASH_MENTION_OPTIONS,
type HashMentionItem,
type HashMentionListRef,
} from './HashMentionList';
// Distinct PluginKey — the editor already mounts the slash-command and other
// mention Suggestion plugins, all of which default to `suggestion$` and would
// otherwise collide ("Adding different instances of a keyed plugin").
const HASH_MENTION_PLUGIN_KEY = new PluginKey('hashMentionSuggestion');
export interface HashMentionOptions {
/** Dispatched when the user picks an integration. Caller owns the editor mutation. */
onSelect: (item: HashMentionItem, editor: Editor, range: Range) => void;
}
/**
* `#` Suggestion plugin for Config documents. The menu is a fixed list of
* integrations ({@link HASH_MENTION_OPTIONS}); typing after `#` filters by
* label. The caller injects `onSelect` so node insertion stays in the surface.
*/
export function createHashMention(opts: HashMentionOptions) {
return Extension.create({
name: 'hashMention',
addProseMirrorPlugins() {
return [
Suggestion({
pluginKey=[redacted],
editor: this.editor,
char: '#',
allowSpaces: false,
startOfLine: false,
items: ({ query }) => {
const q = query.trim().toLowerCase();
const matches = q
? HASH_MENTION_OPTIONS.filter((o) => o.label.toLowerCase().includes(q))
: HASH_MENTION_OPTIONS;
return matches.map((o) => ({ kind: o.kind }));
},
command: ({ editor, range, props }) => {
opts.onSelect(props as HashMentionItem, editor, range);
},
render: () => {
let component: ReactRenderer<HashMentionListRef> | 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(HashMentionList, {
props: {
items: (props.items ?? []) as HashMentionItem[],
command: (item: HashMentionItem) => props.command(item),
},
editor: props.editor,
});
ensurePopup(props.clientRect);
},
onUpdate: (props) => {
component?.updateProps({
items: (props.items ?? []) as HashMentionItem[],
command: (item: HashMentionItem) => 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,
};
},
}),
];
},
});
}