2025-12-25 13:44:18 +05:30
|
|
|
"use client";
|
|
|
|
|
|
2026-05-12 20:57:15 +05:30
|
|
|
import { Folder as FolderIcon, X as XIcon } from "lucide-react";
|
2026-05-12 23:18:45 +05:30
|
|
|
import type { NodeEntry, TElement } from "platejs";
|
2026-05-01 04:23:59 +05:30
|
|
|
import type { PlateElementProps } from "platejs/react";
|
2026-04-30 18:42:38 -07:00
|
|
|
import {
|
|
|
|
|
createPlatePlugin,
|
|
|
|
|
ParagraphPlugin,
|
|
|
|
|
Plate,
|
|
|
|
|
PlateContent,
|
|
|
|
|
usePlateEditor,
|
|
|
|
|
} from "platejs/react";
|
2026-05-12 20:57:15 +05:30
|
|
|
import {
|
|
|
|
|
createContext,
|
|
|
|
|
type FC,
|
|
|
|
|
forwardRef,
|
|
|
|
|
useCallback,
|
|
|
|
|
useContext,
|
|
|
|
|
useImperativeHandle,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
} from "react";
|
2026-05-09 22:15:51 -07:00
|
|
|
import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom";
|
2026-01-17 22:25:40 +05:30
|
|
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
2026-01-20 00:32:31 -08:00
|
|
|
import type { Document } from "@/contracts/types/document.types";
|
2026-04-29 04:12:42 +05:30
|
|
|
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
2025-12-25 13:44:18 +05:30
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
2026-05-09 22:15:51 -07:00
|
|
|
export type MentionKind = "doc" | "folder";
|
|
|
|
|
|
2025-12-25 13:44:18 +05:30
|
|
|
export interface MentionedDocument {
|
|
|
|
|
id: number;
|
|
|
|
|
title: string;
|
|
|
|
|
document_type?: string;
|
2026-05-09 22:15:51 -07:00
|
|
|
kind: MentionKind;
|
2025-12-25 13:44:18 +05:30
|
|
|
}
|
|
|
|
|
|
2026-05-09 22:15:51 -07:00
|
|
|
/**
|
2026-05-12 23:24:01 +05:30
|
|
|
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"``.
|
|
|
|
|
* Folder chips default ``document_type`` to ``FOLDER_MENTION_DOCUMENT_TYPE``
|
|
|
|
|
* so the dedup key never collides with a doc chip sharing the same id.
|
2026-05-09 22:15:51 -07:00
|
|
|
*/
|
|
|
|
|
export type MentionChipInput = {
|
|
|
|
|
id: number;
|
|
|
|
|
title: string;
|
|
|
|
|
document_type?: string;
|
|
|
|
|
kind?: MentionKind;
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-25 13:44:18 +05:30
|
|
|
export interface InlineMentionEditorRef {
|
|
|
|
|
focus: () => void;
|
|
|
|
|
clear: () => void;
|
2026-04-07 00:43:40 -07:00
|
|
|
setText: (text: string) => void;
|
2025-12-25 13:44:18 +05:30
|
|
|
getText: () => string;
|
|
|
|
|
getMentionedDocuments: () => MentionedDocument[];
|
2026-05-12 23:18:45 +05:30
|
|
|
insertMentionChip: (mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => void;
|
2026-05-09 22:15:51 -07:00
|
|
|
/**
|
|
|
|
|
* @deprecated Use ``insertMentionChip``. Kept for one transition
|
|
|
|
|
* cycle so we don't break ad-hoc callers; prefer the new name.
|
|
|
|
|
*/
|
2026-04-28 17:50:21 +05:30
|
|
|
insertDocumentChip: (
|
|
|
|
|
doc: Pick<Document, "id" | "title" | "document_type">,
|
|
|
|
|
options?: { removeTriggerText?: boolean }
|
|
|
|
|
) => void;
|
2026-03-06 15:35:58 +05:30
|
|
|
removeDocumentChip: (docId: number, docType?: string) => void;
|
2026-02-09 16:46:54 -08:00
|
|
|
setDocumentChipStatus: (
|
|
|
|
|
docId: number,
|
|
|
|
|
docType: string | undefined,
|
|
|
|
|
statusLabel: string | null,
|
|
|
|
|
statusKind?: "pending" | "processing" | "ready" | "failed"
|
|
|
|
|
) => void;
|
2025-12-25 13:44:18 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface InlineMentionEditorProps {
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
onMentionTrigger?: (query: string) => void;
|
|
|
|
|
onMentionClose?: () => void;
|
2026-03-28 23:12:33 +02:00
|
|
|
onActionTrigger?: (query: string) => void;
|
|
|
|
|
onActionClose?: () => void;
|
2025-12-25 13:44:18 +05:30
|
|
|
onSubmit?: () => void;
|
|
|
|
|
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
2026-01-13 06:14:58 +02:00
|
|
|
onDocumentRemove?: (docId: number, docType?: string) => void;
|
2025-12-25 13:44:18 +05:30
|
|
|
onKeyDown?: (e: React.KeyboardEvent) => void;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
className?: string;
|
2026-03-24 19:26:13 +02:00
|
|
|
initialText?: string;
|
2025-12-25 13:44:18 +05:30
|
|
|
}
|
|
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
type MentionStatusKind = "pending" | "processing" | "ready" | "failed";
|
|
|
|
|
type ComposerTextNode = { text: string };
|
|
|
|
|
type MentionElementNode = {
|
|
|
|
|
type: "mention";
|
|
|
|
|
id: number;
|
|
|
|
|
title: string;
|
|
|
|
|
document_type?: string;
|
2026-05-12 23:24:01 +05:30
|
|
|
/** Discriminator; defaults to ``"doc"`` for legacy nodes. */
|
2026-05-09 22:15:51 -07:00
|
|
|
kind?: MentionKind;
|
2026-05-01 04:23:59 +05:30
|
|
|
statusLabel?: string | null;
|
|
|
|
|
statusKind?: MentionStatusKind;
|
|
|
|
|
children: [{ text: "" }];
|
|
|
|
|
};
|
|
|
|
|
type ComposerNode = ComposerTextNode | MentionElementNode;
|
|
|
|
|
type ComposerParagraph = { type: "p"; children: ComposerNode[] };
|
|
|
|
|
type ComposerValue = ComposerParagraph[];
|
|
|
|
|
|
|
|
|
|
const MENTION_TYPE = "mention";
|
|
|
|
|
const MENTION_CHIP_CLASSNAME =
|
2026-05-12 20:57:15 +05:30
|
|
|
"group inline-flex h-5 items-center gap-1 mx-0.5 rounded bg-primary/10 px-1 text-xs font-bold text-primary/60 select-none align-middle leading-none";
|
2026-05-01 04:23:59 +05:30
|
|
|
const MENTION_CHIP_ICON_CLASSNAME = "flex items-center text-muted-foreground leading-none";
|
|
|
|
|
const MENTION_CHIP_TITLE_CLASSNAME = "max-w-[120px] truncate leading-none";
|
|
|
|
|
const COMPOSER_TEXT_METRICS_CLASSNAME = "text-sm leading-6";
|
|
|
|
|
|
|
|
|
|
const EMPTY_VALUE: ComposerValue = [{ type: "p", children: [{ text: "" }] }];
|
|
|
|
|
|
2026-05-12 20:57:15 +05:30
|
|
|
/**
|
2026-05-12 23:24:01 +05:30
|
|
|
* Lets ``MentionElement`` reach the editor's chip-removal helper so
|
|
|
|
|
* the X button and Backspace go through the same call site.
|
2026-05-12 20:57:15 +05:30
|
|
|
*/
|
|
|
|
|
type MentionEditorContextValue = {
|
|
|
|
|
removeChip: (docId: number, docType: string | undefined) => void;
|
|
|
|
|
};
|
|
|
|
|
const MentionEditorContext = createContext<MentionEditorContextValue | null>(null);
|
|
|
|
|
|
2026-04-30 18:42:38 -07:00
|
|
|
const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
|
|
|
|
|
attributes,
|
|
|
|
|
children,
|
|
|
|
|
element,
|
|
|
|
|
}) => {
|
2026-05-01 04:23:59 +05:30
|
|
|
const statusClass =
|
|
|
|
|
element.statusKind === "failed"
|
|
|
|
|
? "text-destructive"
|
|
|
|
|
: element.statusKind === "ready"
|
|
|
|
|
? "text-emerald-700"
|
|
|
|
|
: "text-amber-700";
|
2025-12-25 13:44:18 +05:30
|
|
|
|
2026-05-09 22:15:51 -07:00
|
|
|
const isFolder = element.kind === "folder";
|
2026-05-12 20:57:15 +05:30
|
|
|
const ctx = useContext(MentionEditorContext);
|
2026-05-09 22:15:51 -07:00
|
|
|
|
2025-12-26 00:41:14 +05:30
|
|
|
return (
|
2026-05-01 04:23:59 +05:30
|
|
|
<span {...attributes} className="inline-flex align-middle">
|
|
|
|
|
<span contentEditable={false} className={`${MENTION_CHIP_CLASSNAME} cursor-default`}>
|
|
|
|
|
<span className={MENTION_CHIP_ICON_CLASSNAME}>
|
2026-05-12 20:57:15 +05:30
|
|
|
<span className="relative flex h-3 w-3 items-center justify-center">
|
|
|
|
|
<span className="flex items-center justify-center transition-opacity group-hover:opacity-0">
|
|
|
|
|
{isFolder ? (
|
|
|
|
|
<FolderIcon className="h-3 w-3" />
|
|
|
|
|
) : (
|
|
|
|
|
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
{ctx ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={`Remove mention ${element.title}`}
|
|
|
|
|
title={`Remove ${element.title}`}
|
|
|
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
ctx.removeChip(element.id, element.document_type);
|
|
|
|
|
}}
|
|
|
|
|
className="absolute inset-0 flex items-center justify-center rounded-sm opacity-0 transition-opacity hover:text-primary focus-visible:opacity-100 focus-visible:outline-none group-hover:opacity-100"
|
|
|
|
|
>
|
|
|
|
|
<XIcon className="h-3 w-3" />
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
2026-05-01 04:23:59 +05:30
|
|
|
</span>
|
|
|
|
|
<span className={MENTION_CHIP_TITLE_CLASSNAME} title={element.title}>
|
|
|
|
|
{element.title}
|
|
|
|
|
</span>
|
|
|
|
|
{element.statusLabel ? (
|
|
|
|
|
<span className={cn("text-[10px] font-semibold opacity-80", statusClass)}>
|
|
|
|
|
{element.statusLabel}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
{children}
|
|
|
|
|
</span>
|
2025-12-26 00:41:14 +05:30
|
|
|
);
|
2026-05-01 04:23:59 +05:30
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MentionPlugin = createPlatePlugin({
|
|
|
|
|
key: MENTION_TYPE,
|
|
|
|
|
node: {
|
|
|
|
|
isElement: true,
|
|
|
|
|
isInline: true,
|
|
|
|
|
isVoid: true,
|
|
|
|
|
type: MENTION_TYPE,
|
|
|
|
|
component: MentionElement,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function isMentionNode(node: ComposerNode): node is MentionElementNode {
|
|
|
|
|
return typeof node === "object" && "type" in node && node.type === MENTION_TYPE;
|
2025-12-26 00:41:14 +05:30
|
|
|
}
|
|
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
function getTextNode(node: ComposerNode): ComposerTextNode | null {
|
|
|
|
|
if (typeof node === "object" && "text" in node && typeof node.text === "string") return node;
|
|
|
|
|
return null;
|
2025-12-26 00:41:14 +05:30
|
|
|
}
|
|
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
function toValueFromText(text: string): ComposerValue {
|
|
|
|
|
const lines = text.split("\n");
|
|
|
|
|
if (lines.length === 0) return EMPTY_VALUE;
|
|
|
|
|
return lines.map((line) => ({ type: "p", children: [{ text: line }] })) as ComposerValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPlainText(value: ComposerValue): string {
|
|
|
|
|
const lines = value.map((block) =>
|
|
|
|
|
block.children
|
|
|
|
|
.map((node) => {
|
|
|
|
|
if (isMentionNode(node)) return `@${node.title}`;
|
|
|
|
|
return getTextNode(node)?.text ?? "";
|
|
|
|
|
})
|
|
|
|
|
.join("")
|
|
|
|
|
);
|
|
|
|
|
return lines.join("\n").trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getMentionedDocuments(value: ComposerValue): MentionedDocument[] {
|
|
|
|
|
const map = new Map<string, MentionedDocument>();
|
|
|
|
|
for (const block of value) {
|
|
|
|
|
for (const node of block.children) {
|
|
|
|
|
if (!isMentionNode(node)) continue;
|
2026-05-09 22:15:51 -07:00
|
|
|
const kind: MentionKind = node.kind ?? "doc";
|
2026-05-01 04:23:59 +05:30
|
|
|
const doc: MentionedDocument = {
|
|
|
|
|
id: node.id,
|
|
|
|
|
title: node.title,
|
|
|
|
|
document_type: node.document_type,
|
2026-05-09 22:15:51 -07:00
|
|
|
kind,
|
2026-05-01 04:23:59 +05:30
|
|
|
};
|
|
|
|
|
map.set(getMentionDocKey(doc), doc);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return Array.from(map.values());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type EditorSelection = {
|
|
|
|
|
anchor: { path: number[]; offset: number };
|
|
|
|
|
focus: { path: number[]; offset: number };
|
|
|
|
|
} | null;
|
|
|
|
|
|
|
|
|
|
function getCursorTextContext(value: ComposerValue, selection: EditorSelection) {
|
|
|
|
|
if (!selection || !selection.anchor || !selection.focus) return null;
|
|
|
|
|
if (
|
|
|
|
|
selection.anchor.path.length < 2 ||
|
|
|
|
|
selection.focus.path.length < 2 ||
|
|
|
|
|
selection.anchor.path[0] !== selection.focus.path[0] ||
|
|
|
|
|
selection.anchor.path[1] !== selection.focus.path[1]
|
|
|
|
|
) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const block = value[selection.anchor.path[0]];
|
|
|
|
|
if (!block) return null;
|
|
|
|
|
const child = block.children[selection.anchor.path[1]];
|
|
|
|
|
const textNode = getTextNode(child);
|
|
|
|
|
if (!textNode) return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
blockIndex: selection.anchor.path[0],
|
|
|
|
|
childIndex: selection.anchor.path[1],
|
|
|
|
|
text: textNode.text,
|
|
|
|
|
cursor: selection.anchor.offset,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scanActiveTrigger(text: string, cursor: number) {
|
|
|
|
|
let wordStart = 0;
|
|
|
|
|
for (let i = cursor - 1; i >= 0; i--) {
|
|
|
|
|
if (text[i] === " " || text[i] === "\n") {
|
|
|
|
|
wordStart = i + 1;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let triggerChar: "@" | "/" | null = null;
|
|
|
|
|
let triggerIndex = -1;
|
|
|
|
|
for (let i = wordStart; i < cursor; i++) {
|
|
|
|
|
if (text[i] === "@" || text[i] === "/") {
|
|
|
|
|
triggerChar = text[i] as "@" | "/";
|
|
|
|
|
triggerIndex = i;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!triggerChar || triggerIndex === -1) return null;
|
|
|
|
|
|
|
|
|
|
const query = text.slice(triggerIndex + 1, cursor);
|
|
|
|
|
if (query.startsWith(" ")) return null;
|
|
|
|
|
if (
|
|
|
|
|
triggerChar === "/" &&
|
|
|
|
|
triggerIndex > 0 &&
|
|
|
|
|
text[triggerIndex - 1] !== " " &&
|
|
|
|
|
text[triggerIndex - 1] !== "\n"
|
|
|
|
|
) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { triggerChar, query };
|
2026-01-13 06:14:58 +02:00
|
|
|
}
|
|
|
|
|
|
2025-12-25 13:44:18 +05:30
|
|
|
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
|
|
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
placeholder = "Type @ to mention documents...",
|
|
|
|
|
onMentionTrigger,
|
|
|
|
|
onMentionClose,
|
2026-03-28 23:12:33 +02:00
|
|
|
onActionTrigger,
|
|
|
|
|
onActionClose,
|
2025-12-25 13:44:18 +05:30
|
|
|
onSubmit,
|
|
|
|
|
onChange,
|
|
|
|
|
onDocumentRemove,
|
|
|
|
|
onKeyDown,
|
|
|
|
|
disabled = false,
|
|
|
|
|
className,
|
2026-03-24 19:26:13 +02:00
|
|
|
initialText,
|
2025-12-25 13:44:18 +05:30
|
|
|
},
|
|
|
|
|
ref
|
|
|
|
|
) => {
|
2026-05-01 04:23:59 +05:30
|
|
|
const editableRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const editor = usePlateEditor({
|
|
|
|
|
readOnly: disabled,
|
|
|
|
|
plugins: [ParagraphPlugin, MentionPlugin],
|
|
|
|
|
value: initialText ? toValueFromText(initialText) : EMPTY_VALUE,
|
|
|
|
|
});
|
2026-04-28 17:50:21 +05:30
|
|
|
|
2026-05-12 23:24:01 +05:30
|
|
|
// Move the caret to end-of-doc and focus the editor. Falls back
|
|
|
|
|
// to DOM focus if Plate's API throws (transient unmount race).
|
2025-12-25 13:44:18 +05:30
|
|
|
const focusAtEnd = useCallback(() => {
|
2026-05-12 23:18:45 +05:30
|
|
|
try {
|
|
|
|
|
editor.tf.select(editor.api.end([]));
|
|
|
|
|
editor.tf.focus();
|
|
|
|
|
} catch {
|
|
|
|
|
editableRef.current?.focus();
|
|
|
|
|
}
|
|
|
|
|
}, [editor]);
|
2025-12-25 13:44:18 +05:30
|
|
|
|
2026-04-30 18:42:38 -07:00
|
|
|
const getCurrentValue = useCallback(
|
|
|
|
|
() => (editor.children as ComposerValue) ?? EMPTY_VALUE,
|
|
|
|
|
[editor]
|
|
|
|
|
);
|
2025-12-25 14:29:44 +05:30
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
const emitState = useCallback(
|
|
|
|
|
(nextValue: ComposerValue) => {
|
|
|
|
|
const text = getPlainText(nextValue);
|
|
|
|
|
const docs = getMentionedDocuments(nextValue);
|
|
|
|
|
onChange?.(text, docs);
|
2026-02-16 01:34:36 -08:00
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
const cursorCtx = getCursorTextContext(nextValue, editor.selection);
|
|
|
|
|
if (!cursorCtx) {
|
|
|
|
|
onMentionClose?.();
|
|
|
|
|
onActionClose?.();
|
|
|
|
|
return;
|
2025-12-25 13:44:18 +05:30
|
|
|
}
|
|
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
const trigger = scanActiveTrigger(cursorCtx.text, cursorCtx.cursor);
|
|
|
|
|
if (!trigger) {
|
|
|
|
|
onMentionClose?.();
|
|
|
|
|
onActionClose?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-16 01:34:36 -08:00
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
if (trigger.triggerChar === "@") {
|
|
|
|
|
onMentionTrigger?.(trigger.query);
|
|
|
|
|
onActionClose?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-12-25 13:44:18 +05:30
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
onActionTrigger?.(trigger.query);
|
|
|
|
|
onMentionClose?.();
|
2026-04-28 18:47:57 +05:30
|
|
|
},
|
2026-05-01 04:23:59 +05:30
|
|
|
[editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger]
|
2026-04-28 18:47:57 +05:30
|
|
|
);
|
|
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
const setValue = useCallback(
|
|
|
|
|
(nextValue: ComposerValue) => {
|
|
|
|
|
const tf = editor.tf as { setValue: (value: ComposerValue) => void };
|
|
|
|
|
tf.setValue(nextValue);
|
|
|
|
|
emitState(nextValue);
|
2025-12-25 14:29:44 +05:30
|
|
|
},
|
2026-05-01 04:23:59 +05:30
|
|
|
[editor, emitState]
|
2025-12-25 14:29:44 +05:30
|
|
|
);
|
2025-12-25 13:44:18 +05:30
|
|
|
|
2026-05-12 23:24:01 +05:30
|
|
|
// Insert chip + trailing space as a single ``insertNodes`` call.
|
|
|
|
|
// The chip is a void inline; ``select: true`` on it alone would
|
|
|
|
|
// land the caret inside its empty children (an unrenderable
|
|
|
|
|
// point). With the space as the last inserted node, the caret
|
|
|
|
|
// resolves to that text node and stays visible. The
|
|
|
|
|
// ``withoutNormalizing`` wrapper batches the optional trigger
|
|
|
|
|
// delete + insert into a single undo step.
|
2026-05-09 22:15:51 -07:00
|
|
|
const insertMentionChip = useCallback(
|
|
|
|
|
(mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => {
|
|
|
|
|
if (typeof mention.id !== "number" || typeof mention.title !== "string") return;
|
2025-12-26 00:41:14 +05:30
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
const removeTriggerText = options?.removeTriggerText ?? true;
|
2026-05-09 22:15:51 -07:00
|
|
|
const kind: MentionKind = mention.kind ?? "doc";
|
|
|
|
|
const document_type =
|
2026-05-12 23:18:45 +05:30
|
|
|
mention.document_type ?? (kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined);
|
2026-05-01 04:23:59 +05:30
|
|
|
const mentionNode: MentionElementNode = {
|
|
|
|
|
type: MENTION_TYPE,
|
2026-05-09 22:15:51 -07:00
|
|
|
id: mention.id,
|
|
|
|
|
title: mention.title,
|
|
|
|
|
document_type,
|
|
|
|
|
kind,
|
2026-05-01 04:23:59 +05:30
|
|
|
children: [{ text: "" }],
|
2025-12-25 13:44:18 +05:30
|
|
|
};
|
|
|
|
|
|
2026-05-12 23:18:45 +05:30
|
|
|
editor.tf.withoutNormalizing(() => {
|
|
|
|
|
const selection = editor.selection;
|
|
|
|
|
|
2026-05-12 23:24:01 +05:30
|
|
|
// No active selection (focus moved to a picker) — snap
|
|
|
|
|
// to end-of-doc so the chip appends cleanly.
|
2026-05-12 23:18:45 +05:30
|
|
|
if (!selection) {
|
|
|
|
|
editor.tf.select(editor.api.end([]));
|
|
|
|
|
} else if (removeTriggerText) {
|
2026-05-12 23:24:01 +05:30
|
|
|
// Delete the in-progress "@query" so the chip stands in for it.
|
2026-05-12 23:18:45 +05:30
|
|
|
const cursorCtx = getCursorTextContext(getCurrentValue(), selection);
|
|
|
|
|
if (cursorCtx) {
|
|
|
|
|
const text = cursorCtx.text;
|
|
|
|
|
let triggerIndex = -1;
|
|
|
|
|
for (let i = cursorCtx.cursor - 1; i >= 0; i--) {
|
|
|
|
|
if (text[i] === "@") {
|
|
|
|
|
triggerIndex = i;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (text[i] === " " || text[i] === "\n") break;
|
|
|
|
|
}
|
|
|
|
|
if (triggerIndex >= 0 && triggerIndex < cursorCtx.cursor) {
|
|
|
|
|
const path = [cursorCtx.blockIndex, cursorCtx.childIndex];
|
|
|
|
|
editor.tf.delete({
|
|
|
|
|
at: {
|
|
|
|
|
anchor: { path, offset: triggerIndex },
|
|
|
|
|
focus: { path, offset: cursorCtx.cursor },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-12-25 13:44:18 +05:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 23:18:45 +05:30
|
|
|
editor.tf.insertNodes([mentionNode, { text: " " }] as unknown as TElement[], {
|
|
|
|
|
select: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
editor.tf.focus();
|
2025-12-25 13:44:18 +05:30
|
|
|
},
|
2026-05-12 23:18:45 +05:30
|
|
|
[editor, getCurrentValue]
|
2025-12-25 13:44:18 +05:30
|
|
|
);
|
|
|
|
|
|
2026-05-12 23:24:01 +05:30
|
|
|
// Doc-only shim that routes through ``insertMentionChip``.
|
2026-05-09 22:15:51 -07:00
|
|
|
const insertDocumentChip = useCallback(
|
|
|
|
|
(
|
|
|
|
|
doc: Pick<Document, "id" | "title" | "document_type">,
|
|
|
|
|
options?: { removeTriggerText?: boolean }
|
|
|
|
|
) => {
|
|
|
|
|
insertMentionChip({ ...doc, kind: "doc" }, options);
|
|
|
|
|
},
|
|
|
|
|
[insertMentionChip]
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-12 23:24:01 +05:30
|
|
|
// Remove chip(s) matching (id, document_type). Iterates in
|
|
|
|
|
// descending path order so removing one entry can't invalidate
|
|
|
|
|
// later paths. Chips are deduped today, so this typically runs
|
|
|
|
|
// at most once.
|
2026-05-01 04:23:59 +05:30
|
|
|
const removeDocumentChip = useCallback(
|
|
|
|
|
(docId: number, docType?: string) => {
|
2026-05-12 23:18:45 +05:30
|
|
|
const match = (n: unknown) => {
|
|
|
|
|
if (!n || typeof n !== "object" || !("type" in n)) return false;
|
|
|
|
|
const node = n as MentionElementNode;
|
|
|
|
|
if (node.type !== MENTION_TYPE) return false;
|
|
|
|
|
if (node.id !== docId) return false;
|
|
|
|
|
return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const entries = Array.from(editor.api.nodes({ at: [], match })) as NodeEntry[];
|
|
|
|
|
if (entries.length === 0) return;
|
|
|
|
|
editor.tf.withoutNormalizing(() => {
|
|
|
|
|
for (const [, path] of entries.reverse()) {
|
|
|
|
|
editor.tf.removeNodes({ at: path });
|
|
|
|
|
}
|
2026-05-01 04:23:59 +05:30
|
|
|
});
|
2026-04-07 00:43:40 -07:00
|
|
|
},
|
2026-05-12 23:18:45 +05:30
|
|
|
[editor]
|
2026-04-07 00:43:40 -07:00
|
|
|
);
|
|
|
|
|
|
2026-05-12 23:24:01 +05:30
|
|
|
// Single removal call site for Backspace and the X button so the
|
|
|
|
|
// two can never diverge (e.g. one forgetting to notify the parent).
|
2026-05-12 20:57:15 +05:30
|
|
|
const removeChip = useCallback(
|
|
|
|
|
(docId: number, docType: string | undefined) => {
|
|
|
|
|
removeDocumentChip(docId, docType);
|
|
|
|
|
onDocumentRemove?.(docId, docType);
|
|
|
|
|
},
|
|
|
|
|
[onDocumentRemove, removeDocumentChip]
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-12 23:24:01 +05:30
|
|
|
// Update chip status in place via ``tf.setNodes`` so the user's
|
|
|
|
|
// selection survives backend status events arriving mid-typing.
|
2026-02-09 16:46:54 -08:00
|
|
|
const setDocumentChipStatus = useCallback(
|
|
|
|
|
(
|
|
|
|
|
docId: number,
|
|
|
|
|
docType: string | undefined,
|
|
|
|
|
statusLabel: string | null,
|
2026-05-01 04:23:59 +05:30
|
|
|
statusKind: MentionStatusKind = "pending"
|
2026-02-09 16:46:54 -08:00
|
|
|
) => {
|
2026-05-12 23:18:45 +05:30
|
|
|
const match = (n: unknown) => {
|
|
|
|
|
if (!n || typeof n !== "object" || !("type" in n)) return false;
|
|
|
|
|
const node = n as MentionElementNode;
|
|
|
|
|
if (node.type !== MENTION_TYPE) return false;
|
|
|
|
|
if (node.id !== docId) return false;
|
|
|
|
|
return (node.document_type ?? "UNKNOWN") === (docType ?? "UNKNOWN");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
editor.tf.setNodes(
|
|
|
|
|
{
|
|
|
|
|
statusLabel,
|
|
|
|
|
statusKind: statusLabel ? statusKind : undefined,
|
|
|
|
|
} as Partial<TElement>,
|
|
|
|
|
{ at: [], match }
|
|
|
|
|
);
|
2026-02-09 16:46:54 -08:00
|
|
|
},
|
2026-05-12 23:18:45 +05:30
|
|
|
[editor]
|
2026-02-09 16:46:54 -08:00
|
|
|
);
|
|
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
const clear = useCallback(() => {
|
|
|
|
|
setValue(EMPTY_VALUE);
|
2026-05-12 23:24:01 +05:30
|
|
|
// ``tf.setValue`` wipes the selection — refocus so the caret
|
|
|
|
|
// returns after Enter-to-submit.
|
2026-05-12 23:18:45 +05:30
|
|
|
requestAnimationFrame(focusAtEnd);
|
|
|
|
|
}, [focusAtEnd, setValue]);
|
2026-05-01 04:23:59 +05:30
|
|
|
|
|
|
|
|
const setText = useCallback(
|
|
|
|
|
(text: string) => {
|
|
|
|
|
setValue(toValueFromText(text));
|
|
|
|
|
requestAnimationFrame(focusAtEnd);
|
2026-03-06 15:35:58 +05:30
|
|
|
},
|
2026-05-01 04:23:59 +05:30
|
|
|
[focusAtEnd, setValue]
|
2026-03-06 15:35:58 +05:30
|
|
|
);
|
|
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
const getText = useCallback(() => getPlainText(getCurrentValue()), [getCurrentValue]);
|
|
|
|
|
const getMentionedDocs = useCallback(
|
|
|
|
|
() => getMentionedDocuments(getCurrentValue()),
|
|
|
|
|
[getCurrentValue]
|
|
|
|
|
);
|
2026-03-28 23:12:33 +02:00
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
useImperativeHandle(
|
|
|
|
|
ref,
|
|
|
|
|
() => ({
|
2026-05-12 23:24:01 +05:30
|
|
|
// Preserve existing selection if any; otherwise seed one
|
|
|
|
|
// at end-of-doc so the contentEditable shows a caret.
|
2026-05-12 23:18:45 +05:30
|
|
|
focus: () => {
|
|
|
|
|
try {
|
|
|
|
|
if (!editor.selection) {
|
|
|
|
|
editor.tf.select(editor.api.end([]));
|
|
|
|
|
}
|
|
|
|
|
editor.tf.focus();
|
|
|
|
|
} catch {
|
|
|
|
|
editableRef.current?.focus();
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-05-01 04:23:59 +05:30
|
|
|
clear,
|
|
|
|
|
setText,
|
|
|
|
|
getText,
|
|
|
|
|
getMentionedDocuments: getMentionedDocs,
|
2026-05-09 22:15:51 -07:00
|
|
|
insertMentionChip,
|
2026-05-01 04:23:59 +05:30
|
|
|
insertDocumentChip,
|
|
|
|
|
removeDocumentChip,
|
|
|
|
|
setDocumentChipStatus,
|
|
|
|
|
}),
|
2026-04-30 18:42:38 -07:00
|
|
|
[
|
|
|
|
|
clear,
|
2026-05-12 23:18:45 +05:30
|
|
|
editor,
|
2026-04-30 18:42:38 -07:00
|
|
|
getMentionedDocs,
|
|
|
|
|
getText,
|
2026-05-09 22:15:51 -07:00
|
|
|
insertMentionChip,
|
2026-04-30 18:42:38 -07:00
|
|
|
insertDocumentChip,
|
|
|
|
|
removeDocumentChip,
|
|
|
|
|
setDocumentChipStatus,
|
|
|
|
|
setText,
|
|
|
|
|
]
|
2026-05-01 04:23:59 +05:30
|
|
|
);
|
2025-12-25 13:44:18 +05:30
|
|
|
|
|
|
|
|
const handleKeyDown = useCallback(
|
|
|
|
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
2026-05-01 04:23:59 +05:30
|
|
|
onKeyDown?.(e);
|
|
|
|
|
if (e.defaultPrevented) return;
|
2025-12-25 13:44:18 +05:30
|
|
|
|
|
|
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
onSubmit?.();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
if (e.key !== "Backspace") return;
|
|
|
|
|
const selection = editor.selection;
|
|
|
|
|
if (!selection || !selection.anchor || !selection.focus) return;
|
|
|
|
|
if (
|
|
|
|
|
selection.anchor.path.length < 2 ||
|
|
|
|
|
selection.focus.path.length < 2 ||
|
|
|
|
|
selection.anchor.path[0] !== selection.focus.path[0]
|
|
|
|
|
) {
|
|
|
|
|
return;
|
2025-12-25 13:44:18 +05:30
|
|
|
}
|
2026-05-01 04:23:59 +05:30
|
|
|
if (selection.anchor.offset !== 0 || selection.focus.offset !== 0) return;
|
|
|
|
|
|
|
|
|
|
const value = getCurrentValue();
|
|
|
|
|
const block = value[selection.anchor.path[0]];
|
|
|
|
|
if (!block) return;
|
|
|
|
|
const childIndex = selection.anchor.path[1];
|
|
|
|
|
if (childIndex <= 0) return;
|
|
|
|
|
const prev = block.children[childIndex - 1];
|
|
|
|
|
if (!isMentionNode(prev)) return;
|
|
|
|
|
|
|
|
|
|
e.preventDefault();
|
2026-05-12 20:57:15 +05:30
|
|
|
removeChip(prev.id, prev.document_type);
|
2025-12-25 13:44:18 +05:30
|
|
|
},
|
2026-05-12 20:57:15 +05:30
|
|
|
[editor.selection, getCurrentValue, onKeyDown, onSubmit, removeChip]
|
2025-12-25 13:44:18 +05:30
|
|
|
);
|
|
|
|
|
|
2026-05-01 04:23:59 +05:30
|
|
|
const editableProps = useMemo(
|
|
|
|
|
() => ({
|
|
|
|
|
placeholder,
|
|
|
|
|
onPaste: (e: React.ClipboardEvent<HTMLDivElement>) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const text = e.clipboardData.getData("text/plain");
|
|
|
|
|
const tf = editor.tf as { insertText: (value: string) => void };
|
|
|
|
|
tf.insertText(text);
|
|
|
|
|
},
|
|
|
|
|
onKeyDown: handleKeyDown,
|
|
|
|
|
}),
|
|
|
|
|
[editor, handleKeyDown, placeholder]
|
|
|
|
|
);
|
2025-12-25 13:44:18 +05:30
|
|
|
|
2026-05-12 20:57:15 +05:30
|
|
|
const mentionEditorContextValue = useMemo<MentionEditorContextValue>(
|
|
|
|
|
() => ({ removeChip }),
|
|
|
|
|
[removeChip]
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-25 13:44:18 +05:30
|
|
|
return (
|
|
|
|
|
<div className="relative w-full">
|
2026-05-12 20:57:15 +05:30
|
|
|
<MentionEditorContext.Provider value={mentionEditorContextValue}>
|
|
|
|
|
<Plate
|
|
|
|
|
editor={editor}
|
|
|
|
|
onChange={({ value }) => {
|
|
|
|
|
emitState(value as ComposerValue);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<PlateContent
|
|
|
|
|
ref={editableRef}
|
|
|
|
|
readOnly={disabled}
|
|
|
|
|
{...editableProps}
|
|
|
|
|
className={cn(
|
|
|
|
|
"min-h-[24px] max-h-32 overflow-y-auto outline-none whitespace-pre-wrap wrap-break-word",
|
|
|
|
|
COMPOSER_TEXT_METRICS_CLASSNAME,
|
|
|
|
|
disabled && "opacity-50 cursor-not-allowed",
|
|
|
|
|
className
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</Plate>
|
|
|
|
|
</MentionEditorContext.Provider>
|
2025-12-25 13:44:18 +05:30
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
InlineMentionEditor.displayName = "InlineMentionEditor";
|