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