FileLinkSuggestion.ts6.3 KBView on GitHub
'use client';

import { ReactRenderer } from '@tiptap/react';
import { Suggestion } from '@tiptap/suggestion';
import { Extension, type Editor, type Range } from '@tiptap/core';
import { PluginKey } from '@tiptap/pm/state';
import {
  autoUpdate,
  computePosition,
  flip,
  offset,
  shift,
  type Placement,
  type VirtualElement,
} from '@floating-ui/dom';
import { trpcClient } from '@/providers/query-provider';
import { FileLinkMenu, type FileLinkMenuHandle } from './FileLinkMenu';

// Distinct PluginKey is required because MarkdownEditor mounts another
// Suggestion plugin (the slash-command menu) — both default to `suggestion$`,
// which causes ProseMirror to throw or loop trying to reconcile two plugins
// under the same key.
const FILE_LINK_SUGGESTION_PLUGIN_KEY = new PluginKey('fileLinkSuggestion');

export interface FileLinkSuggestionItem {
  id: string;
  label: string;
  /** Full canonical virtual path. `scopeType` and `scopeId` are derived from this. */
  path: string;
  documentType: string;
  description: string | null;
  lastOpened: Date | string | null;
  updatedAt: Date | string;
  /** For conversation-scoped files, the name of the owning conversation (e.g. "Campfire"). */
  conversationName?: string | null;
  /**
   * `documents.metadata`, when the search route returns it. A `table` row carries
   * `TableDocumentMetadata` here, which is how the popover can show "38 rows × 6 columns"
   * without loading content. Optional because `files.searchForLink` does not select it yet.
   */
  metadata?: unknown;
}

interface FileLinkSuggestionOptions {
  search?: (query: string) => Promise<FileLinkSuggestionItem[]>;
  /**
   * Chance for the host editor to insert something other than an inline chip.
   *
   * Return `true` to claim the insert (the caller then does nothing more), `false` to fall
   * through to the default inline `fileLink`. The playbook uses it for one case: a ref
   * dropped INSIDE a `<trigger>` block becomes a `triggerRef` panel, because a ref in a
   * trigger is an agent the trigger runs and needs a body to carry its instruction —
   * everywhere else in every other document type, a ref is a chip.
   */
  insert?: (editor: Editor, range: Range, item: FileLinkSuggestionItem) => boolean;
}

interface SuggestionProps {
  editor: Editor;
  range: Range;
  query: string;
  items: FileLinkSuggestionItem[];
  command: (item: FileLinkSuggestionItem) => void;
  clientRect?: (() => DOMRect | null) | null;
}

function defaultFileLinkSearch(query: string): Promise<FileLinkSuggestionItem[]> {
  return trpcClient.files.searchForLink.query({
    query,
    limit: 8,
  });
}

function createFileLinkSuggestion(
  editor: Editor,
  search: (query: string) => Promise<FileLinkSuggestionItem[]>,
  insert: FileLinkSuggestionOptions['insert'],
) {
  return Suggestion<FileLinkSuggestionItem>({
    pluginKey=[redacted],
    char: '[[',
    startOfLine: false,
    allowSpaces: true,
    editor,
    command: ({
      editor,
      range,
      props,
    }: {
      editor: Editor;
      range: Range;
      props: FileLinkSuggestionItem;
    }) => {
      if (insert?.(editor, range, props)) return;
      editor
        .chain()
        .focus()
        .deleteRange(range)
        .insertContent({
          type: 'fileLink',
          attrs: { documentId: props.id },
        })
        .insertContent(' ')
        .run();
    },
    items: async ({ query }: { query: string }) => {
      return await search(query);
    },
    render: () => {
      let component: ReactRenderer<FileLinkMenuHandle>;
      let popupElement: HTMLDivElement | null = null;
      let cleanup: (() => void) | null = null;

      return {
        onStart: (props: SuggestionProps) => {
          component = new ReactRenderer(FileLinkMenu, {
            props,
            editor: props.editor,
          });

          if (!props.clientRect) return;

          popupElement = document.createElement('div');
          popupElement.style.position = 'absolute';
          popupElement.style.zIndex = '9999';
          document.body.appendChild(popupElement);
          popupElement.appendChild(component.element);

          const virtualElement: VirtualElement = {
            getBoundingClientRect: () => props.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 = autoUpdate(virtualElement, popupElement, updatePosition);
        },

        onUpdate: (props: SuggestionProps) => {
          component.updateProps(props);
          if (!props.clientRect || !popupElement) return;

          const virtualElement: VirtualElement = {
            getBoundingClientRect: () => props.clientRect?.() ?? new DOMRect(),
          };

          computePosition(virtualElement, popupElement, {
            placement: 'bottom-start' as Placement,
            middleware: [offset(6), flip(), shift({ padding: 5 })],
          }).then(({ x, y }) => {
            if (popupElement) {
              Object.assign(popupElement.style, { left: `${x}px`, top: `${y}px` });
            }
          });
        },

        onKeyDown: ({ event }: { event: KeyboardEvent }) => {
          if (event.key === 'Escape') {
            if (popupElement) popupElement.style.display = 'none';
            return true;
          }

          const handled = component.ref?.onKeyDown({ event }) ?? false;
          if (handled) {
            event.preventDefault();
            event.stopPropagation();
          }
          return handled;
        },

        onExit: () => {
          if (cleanup) cleanup();
          if (popupElement?.parentNode) popupElement.parentNode.removeChild(popupElement);
          component.destroy();
          popupElement = null;
        },
      };
    },
  });
}

export function createFileLinkSuggestionExtension(options: FileLinkSuggestionOptions = {}) {
  const search = options.search ?? defaultFileLinkSearch;

  return Extension.create({
    name: 'fileLinkSuggestion',
    addProseMirrorPlugins() {
      return [createFileLinkSuggestion(this.editor, search, options.insert)];
    },
  });
}