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