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