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