Files
docmost/apps/client/src/features/editor/components/mention/mention-suggestion.ts
T
Philip Okugbe 57efb91bd3 feat(ee): ai chat (#2098)
* feat: ai chat

* feat: ai chat

* sync

* cleanup

* view space button
2026-04-10 19:23:47 +01:00

174 lines
4.6 KiB
TypeScript

import { ReactRenderer, useEditor } from "@tiptap/react";
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import MentionList from "@/features/editor/components/mention/mention-list.tsx";
function getWhitespaceCount(query: string) {
const matches = query?.match(/([\s]+)/g);
return matches?.length || 0;
}
const mentionRenderItems = () => {
let component: ReactRenderer | null = null;
let activeClientRect: (() => DOMRect) | null = null;
let updatePositionCleanup: (() => void) | null = null;
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
const destroy = () => {
if (outsideClickHandler) {
document.removeEventListener("pointerdown", outsideClickHandler);
outsideClickHandler = null;
}
updatePositionCleanup?.();
updatePositionCleanup = null;
component?.destroy();
if (component?.element?.parentNode) {
component.element.parentNode.removeChild(component.element);
}
component = null;
};
return {
onStart: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === " ") {
return;
}
// don't render component if space between the search query words is greater than 4
const whitespaceCount = getWhitespaceCount(props.query);
if (whitespaceCount > 4) {
return;
}
const editorDom = props.editor?.view?.dom;
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
const dialogEl = editorDom?.closest("[data-comment-dialog]");
const chatInput = editorDom?.closest("[data-chat-input]");
const isInCommentContext = !!(asideEl || dialogEl || chatInput);
component = new ReactRenderer(MentionList, {
props: { ...props, isInCommentContext },
editor: props.editor,
});
if (!props.clientRect) {
return;
}
activeClientRect = props.clientRect;
const { element } = component;
document.body.appendChild(element);
outsideClickHandler = (e: MouseEvent) => {
const target = e.target as Node;
if (element && !element.contains(target)) {
destroy();
}
};
document.addEventListener("pointerdown", outsideClickHandler);
const shiftMiddleware = asideEl
? shift({ boundary: asideEl, crossAxis: true, padding: 8 })
: shift();
updatePositionCleanup = autoUpdate(
{
getBoundingClientRect: () =>
activeClientRect ? activeClientRect() : new DOMRect(),
},
element,
() => {
if (!component?.element) return;
computePosition(
{
getBoundingClientRect: () => {
return activeClientRect ? activeClientRect() : new DOMRect();
},
},
element,
{
placement: "bottom-start",
middleware: [offset(4), flip(), shiftMiddleware],
},
).then(({ x, y }) => {
Object.assign(element.style, {
left: `${x}px`,
top: `${y}px`,
position: "absolute",
zIndex: "190",
});
});
},
);
},
onUpdate: (props: {
editor: ReturnType<typeof useEditor>;
clientRect: () => DOMRect;
query: string;
}) => {
// query must not start with a whitespace
if (props.query.charAt(0) === " ") {
destroy();
return;
}
// only update component if popup is not destroyed
if (component) {
component.updateProps(props);
}
if (!props || !props.clientRect) {
return;
}
activeClientRect = props.clientRect;
const whitespaceCount = getWhitespaceCount(props.query);
// destroy component if space is greater 3 without a match
if (
whitespaceCount > 4 &&
//@ts-ignore
props.editor.storage.mentionItems.length === 1
) {
destroy();
return;
}
// fallback exit
if (whitespaceCount > 7) {
destroy();
return;
}
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
destroy();
return true;
}
if (props.event.key === "Enter" && !component) {
destroy();
return false;
}
return (component?.ref as any)?.onKeyDown(props);
},
onExit: () => {
destroy();
},
};
};
export default mentionRenderItems;