ConversationMention.ts5.9 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 {
ConversationMentionList,
type ConversationMentionItem,
type ConversationMentionListRef,
} from './ConversationMentionList';
// Distinct PluginKey is required because the editor already mounts another
// Suggestion plugin (the slash-command menu) — both default to `suggestion$`,
// which causes ProseMirror to throw
// "Adding different instances of a keyed plugin (suggestion$)".
const CONVERSATION_MENTION_PLUGIN_KEY = new PluginKey('conversationMentionSuggestion');
export interface ConversationMentionOptions {
/**
* Search backend. Wraps `queryClient.fetchQuery(searchConversationsMinimal)`
* so the popup hits the same React Query cache as `ConversationSearchCommandBar`
* (Cmd+K). Should return cached results synchronously when available and fall
* back to the network only when the cache is missing or stale.
*/
search: (query: string) => Promise<ConversationMentionItem[]>;
/**
* Dispatch when the user picks a conversation from the popup. The caller owns
* the editor mutation — both the agenda and regular documents insert an
* inline conversationNode chip; the agenda additionally stamps the enclosing
* task's `conversationId` attr.
*/
onSelect: (conversation: ConversationMentionItem, editor: Editor, range: Range) => void;
/**
* Fired synchronously alongside onSelect, before the editor mutation. Used by
* the agenda to merge the picked conversation into the badge map so the chip
* / group header renders with the correct name + logo immediately.
*/
onPick?: (conversation: ConversationMentionItem) => void;
}
/**
* `@` Suggestion plugin shared by every document surface (regular docs, agenda,
* conversation agenda). The caller injects search + onSelect so the extension
* stays framework-free and unit-testable.
*/
export function createConversationMention(opts: ConversationMentionOptions) {
return Extension.create({
name: 'conversationMention',
addProseMirrorPlugins() {
return [
Suggestion({
pluginKey=[redacted],
editor: this.editor,
char: '@',
allowSpaces: false,
startOfLine: false,
items: async ({ query }) => {
try {
return await opts.search(query);
} catch (error) {
console.warn('[ConversationMention] search failed', error);
return [];
}
},
command: ({ editor, range, props }) => {
const conversation = props as ConversationMentionItem;
opts.onPick?.(conversation);
opts.onSelect(conversation, editor, range);
},
render: () => {
let component: ReactRenderer<ConversationMentionListRef> | 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(ConversationMentionList, {
props: {
items: (props.items ?? []) as ConversationMentionItem[],
command: (item: ConversationMentionItem) => props.command(item),
loading: false,
},
editor: props.editor,
});
ensurePopup(props.clientRect);
},
onUpdate: (props) => {
component?.updateProps({
items: (props.items ?? []) as ConversationMentionItem[],
command: (item: ConversationMentionItem) => 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,
};
},
}),
];
},
});
}