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,
            };
          },
        }),
      ];
    },
  });
}