mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
feat(web): enhance inline mention editor and thread components with suggestion trigger info and anchor rect
This commit is contained in:
parent
0d65a2e4e3
commit
d445974838
3 changed files with 162 additions and 39 deletions
|
|
@ -47,6 +47,17 @@ export type MentionChipInput = {
|
||||||
kind?: MentionKind;
|
kind?: MentionKind;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SuggestionAnchorRect = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
bottom: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SuggestionTriggerInfo = {
|
||||||
|
query: string;
|
||||||
|
anchorRect: SuggestionAnchorRect | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface InlineMentionEditorRef {
|
export interface InlineMentionEditorRef {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
|
@ -73,9 +84,9 @@ export interface InlineMentionEditorRef {
|
||||||
|
|
||||||
interface InlineMentionEditorProps {
|
interface InlineMentionEditorProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onMentionTrigger?: (query: string) => void;
|
onMentionTrigger?: (trigger: SuggestionTriggerInfo) => void;
|
||||||
onMentionClose?: () => void;
|
onMentionClose?: () => void;
|
||||||
onActionTrigger?: (query: string) => void;
|
onActionTrigger?: (trigger: SuggestionTriggerInfo) => void;
|
||||||
onActionClose?: () => void;
|
onActionClose?: () => void;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
||||||
|
|
@ -299,6 +310,36 @@ function scanActiveTrigger(text: string, cursor: number) {
|
||||||
return { triggerChar, query };
|
return { triggerChar, query };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rectToAnchor(rect: DOMRect): SuggestionAnchorRect {
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectionAnchorRect(root: HTMLElement | null): SuggestionAnchorRect | null {
|
||||||
|
if (!root || typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0 || !selection.anchorNode) return null;
|
||||||
|
if (!root.contains(selection.anchorNode)) return null;
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0).cloneRange();
|
||||||
|
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
|
||||||
|
if (rect.width > 0 || rect.height > 0) return rectToAnchor(rect);
|
||||||
|
|
||||||
|
if (range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) {
|
||||||
|
const fallbackRange = range.cloneRange();
|
||||||
|
fallbackRange.setStart(range.startContainer, range.startOffset - 1);
|
||||||
|
fallbackRange.setEnd(range.startContainer, range.startOffset);
|
||||||
|
const fallbackRect = fallbackRange.getClientRects()[0] ?? fallbackRange.getBoundingClientRect();
|
||||||
|
if (fallbackRect.width > 0 || fallbackRect.height > 0) return rectToAnchor(fallbackRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
|
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|
@ -360,14 +401,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerInfo: SuggestionTriggerInfo = {
|
||||||
|
query: trigger.query,
|
||||||
|
anchorRect: getSelectionAnchorRect(editableRef.current),
|
||||||
|
};
|
||||||
|
|
||||||
if (trigger.triggerChar === "@") {
|
if (trigger.triggerChar === "@") {
|
||||||
onMentionTrigger?.(trigger.query);
|
|
||||||
onActionClose?.();
|
onActionClose?.();
|
||||||
|
onMentionTrigger?.(triggerInfo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onActionTrigger?.(trigger.query);
|
|
||||||
onMentionClose?.();
|
onMentionClose?.();
|
||||||
|
onActionTrigger?.(triggerInfo);
|
||||||
},
|
},
|
||||||
[editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger]
|
[editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ import {
|
||||||
InlineMentionEditor,
|
InlineMentionEditor,
|
||||||
type InlineMentionEditorRef,
|
type InlineMentionEditorRef,
|
||||||
type MentionedDocument,
|
type MentionedDocument,
|
||||||
|
type SuggestionAnchorRect,
|
||||||
|
type SuggestionTriggerInfo,
|
||||||
} from "@/components/assistant-ui/inline-mention-editor";
|
} from "@/components/assistant-ui/inline-mention-editor";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||||
|
|
@ -112,6 +114,34 @@ import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
|
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
|
||||||
|
|
||||||
|
type ComposerSuggestionAnchorPoint = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ComposerSuggestionAnchor({ point }: { point: ComposerSuggestionAnchorPoint }) {
|
||||||
|
return (
|
||||||
|
<PopoverAnchor
|
||||||
|
className="pointer-events-none fixed size-0"
|
||||||
|
style={{
|
||||||
|
left: point.left,
|
||||||
|
top: point.top,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComposerSuggestionAnchorPoint(
|
||||||
|
triggerRect: SuggestionAnchorRect | null,
|
||||||
|
side: "top" | "bottom"
|
||||||
|
): ComposerSuggestionAnchorPoint | null {
|
||||||
|
if (!triggerRect) return null;
|
||||||
|
return {
|
||||||
|
left: triggerRect.left,
|
||||||
|
top: side === "bottom" ? triggerRect.bottom : triggerRect.top,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const Thread: FC = () => {
|
export const Thread: FC = () => {
|
||||||
return <ThreadContent />;
|
return <ThreadContent />;
|
||||||
};
|
};
|
||||||
|
|
@ -411,6 +441,8 @@ const Composer: FC = () => {
|
||||||
const [showPromptPicker, setShowPromptPicker] = useState(false);
|
const [showPromptPicker, setShowPromptPicker] = useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = useState("");
|
const [mentionQuery, setMentionQuery] = useState("");
|
||||||
const [actionQuery, setActionQuery] = useState("");
|
const [actionQuery, setActionQuery] = useState("");
|
||||||
|
const [suggestionAnchorPoint, setSuggestionAnchorPoint] =
|
||||||
|
useState<ComposerSuggestionAnchorPoint | null>(null);
|
||||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||||
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
|
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
|
||||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||||
|
|
@ -491,6 +523,7 @@ const Composer: FC = () => {
|
||||||
lastSeenSlideoutTickRef.current = slideoutOpenedTick;
|
lastSeenSlideoutTickRef.current = slideoutOpenedTick;
|
||||||
setShowDocumentPopover(false);
|
setShowDocumentPopover(false);
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
}, [slideoutOpenedTick]);
|
}, [slideoutOpenedTick]);
|
||||||
|
|
||||||
// Sync editor text into assistant-ui's composer and mirror the chip
|
// Sync editor text into assistant-ui's composer and mirror the chip
|
||||||
|
|
@ -523,38 +556,65 @@ const Composer: FC = () => {
|
||||||
[aui, setMentionedDocuments]
|
[aui, setMentionedDocuments]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMentionTrigger = useCallback((query: string) => {
|
const handleMentionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
|
||||||
|
const anchorPoint = getComposerSuggestionAnchorPoint(trigger.anchorRect, "top");
|
||||||
|
if (!anchorPoint) {
|
||||||
|
setShowDocumentPopover(false);
|
||||||
|
setMentionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSuggestionAnchorPoint(anchorPoint);
|
||||||
setShowDocumentPopover(true);
|
setShowDocumentPopover(true);
|
||||||
setMentionQuery(query);
|
setMentionQuery(trigger.query);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMentionClose = useCallback(() => {
|
const handleMentionClose = useCallback(() => {
|
||||||
if (showDocumentPopover) {
|
if (showDocumentPopover) {
|
||||||
setShowDocumentPopover(false);
|
setShowDocumentPopover(false);
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
}
|
}
|
||||||
}, [showDocumentPopover]);
|
}, [showDocumentPopover]);
|
||||||
|
|
||||||
const handleDocumentPopoverOpenChange = useCallback((open: boolean) => {
|
const handleDocumentPopoverOpenChange = useCallback((open: boolean) => {
|
||||||
setShowDocumentPopover(open);
|
setShowDocumentPopover(open);
|
||||||
if (!open) setMentionQuery("");
|
if (!open) {
|
||||||
|
setMentionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleActionTrigger = useCallback((query: string) => {
|
const handleActionTrigger = useCallback((trigger: SuggestionTriggerInfo) => {
|
||||||
|
const anchorPoint = getComposerSuggestionAnchorPoint(
|
||||||
|
trigger.anchorRect,
|
||||||
|
clipboardInitialText ? "bottom" : "top"
|
||||||
|
);
|
||||||
|
if (!anchorPoint) {
|
||||||
|
setShowPromptPicker(false);
|
||||||
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSuggestionAnchorPoint(anchorPoint);
|
||||||
setShowPromptPicker(true);
|
setShowPromptPicker(true);
|
||||||
setActionQuery(query);
|
setActionQuery(trigger.query);
|
||||||
}, []);
|
}, [clipboardInitialText]);
|
||||||
|
|
||||||
const handleActionClose = useCallback(() => {
|
const handleActionClose = useCallback(() => {
|
||||||
if (showPromptPicker) {
|
if (showPromptPicker) {
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
}
|
}
|
||||||
}, [showPromptPicker]);
|
}, [showPromptPicker]);
|
||||||
|
|
||||||
const handlePromptPickerOpenChange = useCallback((open: boolean) => {
|
const handlePromptPickerOpenChange = useCallback((open: boolean) => {
|
||||||
setShowPromptPicker(open);
|
setShowPromptPicker(open);
|
||||||
if (!open) setActionQuery("");
|
if (!open) {
|
||||||
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleActionSelect = useCallback(
|
const handleActionSelect = useCallback(
|
||||||
|
|
@ -573,6 +633,7 @@ const Composer: FC = () => {
|
||||||
aui.composer().setText(finalPrompt);
|
aui.composer().setText(finalPrompt);
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
},
|
},
|
||||||
[actionQuery, aui]
|
[actionQuery, aui]
|
||||||
);
|
);
|
||||||
|
|
@ -588,6 +649,7 @@ const Composer: FC = () => {
|
||||||
aui.composer().setText(finalPrompt);
|
aui.composer().setText(finalPrompt);
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
setClipboardInitialText(undefined);
|
setClipboardInitialText(undefined);
|
||||||
},
|
},
|
||||||
[clipboardInitialText, electronAPI, aui]
|
[clipboardInitialText, electronAPI, aui]
|
||||||
|
|
@ -616,6 +678,7 @@ const Composer: FC = () => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -639,6 +702,7 @@ const Composer: FC = () => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowDocumentPopover(false);
|
setShowDocumentPopover(false);
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -699,6 +763,7 @@ const Composer: FC = () => {
|
||||||
// Atom is reconciled by ``handleEditorChange`` via the editor's
|
// Atom is reconciled by ``handleEditorChange`` via the editor's
|
||||||
// onChange — no second write path here.
|
// onChange — no second write path here.
|
||||||
setMentionQuery("");
|
setMentionQuery("");
|
||||||
|
setSuggestionAnchorPoint(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -736,34 +801,44 @@ const Composer: FC = () => {
|
||||||
members={members ?? []}
|
members={members ?? []}
|
||||||
/>
|
/>
|
||||||
<Popover open={showDocumentPopover} onOpenChange={handleDocumentPopoverOpenChange}>
|
<Popover open={showDocumentPopover} onOpenChange={handleDocumentPopoverOpenChange}>
|
||||||
<PopoverAnchor className="pointer-events-none absolute inset-0" />
|
{suggestionAnchorPoint ? (
|
||||||
<ComposerSuggestionPopoverContent side="top">
|
<>
|
||||||
<DocumentMentionPicker
|
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
|
||||||
ref={documentPickerRef}
|
<ComposerSuggestionPopoverContent side="top">
|
||||||
searchSpaceId={Number(search_space_id)}
|
<DocumentMentionPicker
|
||||||
onSelectionChange={handleDocumentsMention}
|
ref={documentPickerRef}
|
||||||
onDone={() => {
|
searchSpaceId={Number(search_space_id)}
|
||||||
setShowDocumentPopover(false);
|
onSelectionChange={handleDocumentsMention}
|
||||||
setMentionQuery("");
|
onDone={() => {
|
||||||
}}
|
setShowDocumentPopover(false);
|
||||||
initialSelectedDocuments={mentionedDocuments}
|
setMentionQuery("");
|
||||||
externalSearch={mentionQuery}
|
setSuggestionAnchorPoint(null);
|
||||||
/>
|
}}
|
||||||
</ComposerSuggestionPopoverContent>
|
initialSelectedDocuments={mentionedDocuments}
|
||||||
|
externalSearch={mentionQuery}
|
||||||
|
/>
|
||||||
|
</ComposerSuggestionPopoverContent>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover open={showPromptPicker} onOpenChange={handlePromptPickerOpenChange}>
|
<Popover open={showPromptPicker} onOpenChange={handlePromptPickerOpenChange}>
|
||||||
<PopoverAnchor className="pointer-events-none absolute inset-0" />
|
{suggestionAnchorPoint ? (
|
||||||
<ComposerSuggestionPopoverContent side={clipboardInitialText ? "bottom" : "top"}>
|
<>
|
||||||
<PromptPicker
|
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
|
||||||
ref={promptPickerRef}
|
<ComposerSuggestionPopoverContent side={clipboardInitialText ? "bottom" : "top"}>
|
||||||
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
<PromptPicker
|
||||||
onDone={() => {
|
ref={promptPickerRef}
|
||||||
setShowPromptPicker(false);
|
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
||||||
setActionQuery("");
|
onDone={() => {
|
||||||
}}
|
setShowPromptPicker(false);
|
||||||
externalSearch={actionQuery}
|
setActionQuery("");
|
||||||
/>
|
setSuggestionAnchorPoint(null);
|
||||||
</ComposerSuggestionPopoverContent>
|
}}
|
||||||
|
externalSearch={actionQuery}
|
||||||
|
/>
|
||||||
|
</ComposerSuggestionPopoverContent>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Popover>
|
</Popover>
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@ import { cn } from "@/lib/utils";
|
||||||
function ComposerSuggestionPopoverContent({
|
function ComposerSuggestionPopoverContent({
|
||||||
className,
|
className,
|
||||||
align = "start",
|
align = "start",
|
||||||
sideOffset = 8,
|
sideOffset = 6,
|
||||||
|
collisionPadding = 12,
|
||||||
onOpenAutoFocus,
|
onOpenAutoFocus,
|
||||||
onCloseAutoFocus,
|
onCloseAutoFocus,
|
||||||
...props
|
...props
|
||||||
|
|
@ -19,6 +20,7 @@ function ComposerSuggestionPopoverContent({
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
|
collisionPadding={collisionPadding}
|
||||||
onOpenAutoFocus={(event) => {
|
onOpenAutoFocus={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onOpenAutoFocus?.(event);
|
onOpenAutoFocus?.(event);
|
||||||
|
|
@ -28,7 +30,7 @@ function ComposerSuggestionPopoverContent({
|
||||||
onCloseAutoFocus?.(event);
|
onCloseAutoFocus?.(event);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-[280px] overflow-hidden rounded-xl border border-popover-border bg-popover p-0 text-popover-foreground shadow-2xl sm:w-[320px]",
|
"w-[280px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[320px]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue