feat(web): allow custom prompts in task creation input (#1155)
* refactor(web): extract measureCaretRect into shared util Move the textarea caret-position measurement helper out of rich-text-input.tsx into apps/web/lib/utils/caret-position.ts so the task-create dialog's autocomplete wiring can reuse it without depending on the chat-only RichTextInput component. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(web): allow custom prompts in task creation input Add an @-mention autocomplete to the task-create dialog's prompt textarea so users can quickly insert one of their saved custom prompts (built-in + user-created) by typing @<name>. - Extend useInlineMention with a promptInsertMode option. The default "context" mode preserves chat behavior (attach the prompt to the context store). The new "inline" mode replaces the @query text with the prompt's full content, requiring no backend changes -- the resolved text just becomes part of the task description on submit. - Wire the hook into TaskFormInputs via a small useTaskCreatePromptMention helper that adapts the existing textarea ref to the RichTextInputHandle surface the hook expects (focus / selection / caret-rect / value). - Reuse the existing MentionMenu popup component for parity with chat. - Cover the new branches with unit tests for makePromptItem / detectMentionTrigger / filterItems, and an E2E spec exercising the menu open / inline-insertion / Enter-doesn't-submit / trigger-inside-word paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): make mention popup clickable inside Radix Dialog Radix Dialog sets pointer-events: none on document.body to enforce modal isolation. The mention/slash menu portals to document.body and inherits the disabled pointer events when opened from inside a Dialog (the task-create flow). The popup was visible but unclickable: keyboard select worked, mouse clicks were swallowed by the Dialog body. Restore pointerEvents: auto on the popup container so it stays clickable when invoked from within a modal. Bump z-index to 60 so the popup also wins the visual stacking race against the Dialog (overlay + content are both z-50). Chat continues to work unchanged since z-60 and pointer-events: auto are no-ops outside a modal. Add an adversarial QA spec covering mouse-click select, ArrowDown + Enter on the 2nd item, Escape close, multi-line content auto-grow, backspace-over-@ close, space-after-@ close, no-arrow-cursor-shift, and end-to-end submit. The mouse-click case is the one this fix unblocks; the rest pin behavior for future regressions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(web): address PR review feedback - caret-position.ts: wrap DOM measurement in try/finally so the mirror element is always removed even if getBoundingClientRect throws - useTextareaHandlers: destructure stable fn refs from mention before useCallback so deps are specific properties, not the whole object — avoids defeating memoization on every keystroke - QA spec: consolidate inline cleanupPrompts calls into a shared test.afterEach so cleanup always runs even when a test throws; update submit test comment to accurately reflect what is asserted Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(web): tighten ArrowDown selection assertion in QA E2E spec Equal filter scores sort by insertion order (stable), so qa-arr-1 lands at index 0 and qa-arr-2 at index 1. After one ArrowDown from the default the selected item is "SECOND" — assert that directly instead of accepting either value, matching the test title "selects the second prompt". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
type FileAttachment,
|
||||
} from "@/components/task/chat/file-attachment";
|
||||
import { ContextZone } from "@/components/task/chat/context-items/context-zone";
|
||||
import { MentionMenu } from "@/components/task/chat/mention-menu";
|
||||
import type { ContextItem, ImageContextItem, FileAttachmentContextItem } from "@/lib/types/context";
|
||||
import type { TaskFormInputsHandle } from "@/components/task-create-dialog-types";
|
||||
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
|
||||
@@ -23,6 +24,7 @@ import { JiraImportBar } from "@/components/jira/jira-import-bar";
|
||||
import { LinearImportBar } from "@/components/linear/linear-import-bar";
|
||||
import type { JiraTicket } from "@/lib/types/jira";
|
||||
import type { LinearIssue } from "@/lib/types/linear";
|
||||
import { useTaskCreatePromptMention } from "@/hooks/use-task-create-prompt-mention";
|
||||
|
||||
const CURSOR_POINTER_CLASS = "cursor-pointer";
|
||||
|
||||
@@ -489,9 +491,8 @@ function useDescriptionInput(
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
}, [autoFocus]);
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const setDescriptionValue = useCallback(
|
||||
(newValue: string) => {
|
||||
const hadContent = description.trim().length > 0;
|
||||
const hasContent = newValue.trim().length > 0;
|
||||
setDescription(newValue);
|
||||
@@ -500,7 +501,7 @@ function useDescriptionInput(
|
||||
[description, onDescriptionChange],
|
||||
);
|
||||
|
||||
return { description, textareaRef, handleDescriptionChange };
|
||||
return { description, textareaRef, setDescriptionValue };
|
||||
}
|
||||
|
||||
type FormInputsToolbarProps = {
|
||||
@@ -550,6 +551,88 @@ function FormInputsToolbar({
|
||||
);
|
||||
}
|
||||
|
||||
function PromptMentionPopover({
|
||||
mention,
|
||||
}: {
|
||||
mention: ReturnType<typeof useTaskCreatePromptMention>;
|
||||
}) {
|
||||
return (
|
||||
<MentionMenu
|
||||
isOpen={mention.isOpen}
|
||||
isLoading={mention.isLoading}
|
||||
position={mention.position}
|
||||
items={mention.items}
|
||||
query={mention.query}
|
||||
selectedIndex={mention.selectedIndex}
|
||||
onSelect={mention.handleSelect}
|
||||
onClose={mention.closeMenu}
|
||||
setSelectedIndex={mention.setSelectedIndex}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useTextareaHandlers(
|
||||
mention: ReturnType<typeof useTaskCreatePromptMention>,
|
||||
onKeyDown: TaskFormInputsProps["onKeyDown"],
|
||||
) {
|
||||
const { handleChange: mentionHandleChange, handleKeyDown: mentionHandleKeyDown } = mention;
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => mentionHandleChange(e.target.value),
|
||||
[mentionHandleChange],
|
||||
);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
mentionHandleKeyDown(e);
|
||||
if (e.defaultPrevented) return;
|
||||
onKeyDown?.(e);
|
||||
},
|
||||
[mentionHandleKeyDown, onKeyDown],
|
||||
);
|
||||
return { handleChange, handleKeyDown };
|
||||
}
|
||||
|
||||
function useFileInputClick(addFiles: (files: File[]) => Promise<void> | void) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []);
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) void addFiles(Array.from(files));
|
||||
e.target.value = "";
|
||||
},
|
||||
[addFiles],
|
||||
);
|
||||
return { fileInputRef, handleAttachClick, handleFileInputChange };
|
||||
}
|
||||
|
||||
function HiddenFileInput({
|
||||
inputRef,
|
||||
onChange,
|
||||
}: {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={onChange}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DraggingOverlay({ isDragging }: { isDragging: boolean }) {
|
||||
if (!isDragging) return null;
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary rounded-md pointer-events-none">
|
||||
<span className="text-sm text-primary font-medium">Drop files here</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
isSessionMode,
|
||||
autoFocus,
|
||||
@@ -565,7 +648,6 @@ export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
jiraImport,
|
||||
linearImport,
|
||||
}: TaskFormInputsProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { attachments, isDragging, setIsDragging, addFiles, handleRemoveAttachment } =
|
||||
useFileAttachments();
|
||||
const { handlePaste, handleDragOver, handleDragLeave, handleDrop } = useAttachmentHandlers(
|
||||
@@ -577,22 +659,20 @@ export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
() => toContextItems(attachments, handleRemoveAttachment),
|
||||
[attachments, handleRemoveAttachment],
|
||||
);
|
||||
const { description, textareaRef, handleDescriptionChange } = useDescriptionInput(
|
||||
const { description, textareaRef, setDescriptionValue } = useDescriptionInput(
|
||||
initialDescription,
|
||||
autoFocus,
|
||||
descriptionValueRef,
|
||||
onDescriptionChange,
|
||||
attachments,
|
||||
);
|
||||
const handleAttachClick = useCallback(() => fileInputRef.current?.click(), []);
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) void addFiles(Array.from(files));
|
||||
e.target.value = "";
|
||||
},
|
||||
[addFiles],
|
||||
);
|
||||
const mention = useTaskCreatePromptMention({
|
||||
textareaRef,
|
||||
value: description,
|
||||
onChange: setDescriptionValue,
|
||||
});
|
||||
const { handleChange, handleKeyDown } = useTextareaHandlers(mention, onKeyDown);
|
||||
const { fileInputRef, handleAttachClick, handleFileInputChange } = useFileInputClick(addFiles);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -610,12 +690,12 @@ export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
placeholder={
|
||||
placeholder ??
|
||||
(isSessionMode
|
||||
? "Describe what you want the agent to do..."
|
||||
: "Write a prompt for the agent...")
|
||||
? "Describe what you want the agent to do... (@ to insert a saved prompt)"
|
||||
: "Write a prompt for the agent... (@ to insert a saved prompt)")
|
||||
}
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
data-testid="task-description-input"
|
||||
rows={2}
|
||||
@@ -632,20 +712,10 @@ export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
jiraImport={jiraImport}
|
||||
linearImport={linearImport}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<HiddenFileInput inputRef={fileInputRef} onChange={handleFileInputChange} />
|
||||
</div>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary rounded-md pointer-events-none">
|
||||
<span className="text-sm text-primary font-medium">Drop files here</span>
|
||||
</div>
|
||||
)}
|
||||
<PromptMentionPopover mention={mention} />
|
||||
<DraggingOverlay isDragging={isDragging} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -63,13 +63,18 @@ export function PopupMenu({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate position with content-based width
|
||||
// z-index sits above Radix Dialog overlay/content (both z-50) so the popup
|
||||
// renders on top when it is invoked from within a modal (e.g. task-create).
|
||||
// pointer-events: auto restores click-handling on the popup itself when
|
||||
// Radix Dialog has set pointer-events: none on <body> for modal isolation;
|
||||
// without this, the popup is visible but unclickable inside a dialog.
|
||||
const menuStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: Math.max(8, resolvedPosition.x),
|
||||
maxWidth: Math.min(MENU_MAX_WIDTH, window.innerWidth - resolvedPosition.x - 8),
|
||||
maxHeight: MENU_HEIGHT,
|
||||
zIndex: 50,
|
||||
zIndex: 60,
|
||||
pointerEvents: "auto",
|
||||
...(placement === "below"
|
||||
? { top: resolvedPosition.y + 8 }
|
||||
: { bottom: window.innerHeight - resolvedPosition.y + 8 }),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { measureCaretRect } from "@/lib/utils/caret-position";
|
||||
|
||||
function isSubmitKeyPress(
|
||||
event: KeyboardEvent<HTMLTextAreaElement>,
|
||||
@@ -20,65 +21,6 @@ function isSubmitKeyPress(
|
||||
return event.key === "Enter" && (event.metaKey || event.ctrlKey);
|
||||
}
|
||||
|
||||
const MIRROR_STYLES = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"letterSpacing",
|
||||
"textTransform",
|
||||
"wordSpacing",
|
||||
"textIndent",
|
||||
"whiteSpace",
|
||||
"wordWrap",
|
||||
"wordBreak",
|
||||
"overflowWrap",
|
||||
"lineHeight",
|
||||
"padding",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"borderWidth",
|
||||
"boxSizing",
|
||||
] as const;
|
||||
|
||||
function measureCaretRect(textarea: HTMLTextAreaElement, value: string): DOMRect {
|
||||
const selectionStart = textarea.selectionStart;
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
const mirror = document.createElement("div");
|
||||
|
||||
mirror.style.position = "absolute";
|
||||
mirror.style.visibility = "hidden";
|
||||
mirror.style.whiteSpace = "pre-wrap";
|
||||
mirror.style.wordWrap = "break-word";
|
||||
mirror.style.width = `${textarea.clientWidth}px`;
|
||||
MIRROR_STYLES.forEach((prop) => {
|
||||
mirror.style[prop as unknown as number] = computed[prop];
|
||||
});
|
||||
|
||||
document.body.appendChild(mirror);
|
||||
|
||||
mirror.textContent = value.substring(0, selectionStart);
|
||||
const marker = document.createElement("span");
|
||||
marker.textContent = "\u200B";
|
||||
mirror.appendChild(marker);
|
||||
|
||||
const textareaRect = textarea.getBoundingClientRect();
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const mirrorRect = mirror.getBoundingClientRect();
|
||||
const scrollTop = textarea.scrollTop;
|
||||
|
||||
document.body.removeChild(mirror);
|
||||
|
||||
return new DOMRect(
|
||||
textareaRect.left + (markerRect.left - mirrorRect.left),
|
||||
textareaRect.top + (markerRect.top - mirrorRect.top) - scrollTop,
|
||||
0,
|
||||
parseInt(computed.lineHeight, 10) || parseInt(computed.fontSize, 10) * 1.2,
|
||||
);
|
||||
}
|
||||
|
||||
export type RichTextInputHandle = {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Adversarial QA probes for the @-mention prompt autocomplete in task creation.
|
||||
* Complements task-create-prompt-autocomplete.spec.ts with edge-case coverage.
|
||||
*/
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
const MENU_TITLE = /Mention tasks, files, prompts/i;
|
||||
|
||||
async function cleanupPrompts(
|
||||
apiClient: {
|
||||
listPrompts: () => Promise<{ prompts: Array<{ id: string; name: string; builtin: boolean }> }>;
|
||||
deletePrompt: (id: string) => Promise<void>;
|
||||
},
|
||||
names: string[],
|
||||
) {
|
||||
const { prompts } = await apiClient.listPrompts();
|
||||
for (const p of prompts) {
|
||||
if (!p.builtin && names.includes(p.name)) {
|
||||
await apiClient.deletePrompt(p.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_QA_PROMPTS = [
|
||||
"qa-alpha",
|
||||
"qa-esc",
|
||||
"qa-arr-1",
|
||||
"qa-arr-2",
|
||||
"qa-mouse",
|
||||
"qa-multi",
|
||||
"qa-space",
|
||||
"qa-back",
|
||||
"qa-arrow",
|
||||
"qa-submit",
|
||||
];
|
||||
|
||||
test.describe("@-mention autocomplete: adversarial QA", () => {
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
await cleanupPrompts(apiClient, ALL_QA_PROMPTS);
|
||||
});
|
||||
|
||||
test("bare @ opens menu with prompts visible", async ({ testPage, apiClient }) => {
|
||||
test.setTimeout(60_000);
|
||||
await apiClient.createPrompt("qa-alpha", "alpha-content");
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@");
|
||||
|
||||
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
|
||||
await expect(testPage.getByRole("button", { name: /qa-alpha/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Escape closes the menu without inserting the prompt", async ({ testPage, apiClient }) => {
|
||||
test.setTimeout(60_000);
|
||||
await apiClient.createPrompt("qa-esc", "ESC_CONTENT");
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@qa-es");
|
||||
|
||||
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
|
||||
await textarea.press("Escape");
|
||||
await expect(testPage.getByText(MENU_TITLE)).not.toBeVisible();
|
||||
|
||||
// The @query text is preserved (Esc just closes the menu, doesn't undo typing).
|
||||
await expect(textarea).toHaveValue("@qa-es");
|
||||
|
||||
// Dialog should still be open — Escape went to the menu, not the dialog.
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
test("ArrowDown + Enter selects the second prompt", async ({ testPage, apiClient }) => {
|
||||
test.setTimeout(60_000);
|
||||
// Both prompts begin with "qa-arr" so the filter narrows to both.
|
||||
await apiClient.createPrompt("qa-arr-1", "FIRST");
|
||||
await apiClient.createPrompt("qa-arr-2", "SECOND");
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@qa-arr");
|
||||
|
||||
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
|
||||
// Both should be visible.
|
||||
await expect(testPage.getByRole("button", { name: /qa-arr-1/ })).toBeVisible();
|
||||
await expect(testPage.getByRole("button", { name: /qa-arr-2/ })).toBeVisible();
|
||||
|
||||
await textarea.press("ArrowDown");
|
||||
await textarea.press("Enter");
|
||||
|
||||
const value = await textarea.inputValue();
|
||||
// Equal filter scores → insertion order (stable sort): qa-arr-1 at index 0,
|
||||
// qa-arr-2 at index 1. One ArrowDown moves from 0 → 1, so "SECOND" is selected.
|
||||
expect(value).toBe("SECOND");
|
||||
await expect(testPage.getByText(MENU_TITLE)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking a menu item with the mouse inlines the prompt", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await apiClient.createPrompt("qa-mouse", "MOUSE_CONTENT");
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@qa-mo");
|
||||
|
||||
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
|
||||
await testPage.getByRole("button", { name: /qa-mouse/ }).click();
|
||||
|
||||
await expect(textarea).toHaveValue("MOUSE_CONTENT");
|
||||
await expect(testPage.getByText(MENU_TITLE)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("inserted prompt with multi-line content auto-grows the textarea", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const lines = Array.from({ length: 8 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
await apiClient.createPrompt("qa-multi", lines);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@qa-mu");
|
||||
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect(textarea).toHaveValue(lines);
|
||||
|
||||
// Height should reflect content (8 lines should be taller than the default ~96px min-h).
|
||||
const height = await textarea.evaluate((el) => (el as HTMLTextAreaElement).scrollHeight);
|
||||
expect(height).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test("typing space after @ closes the menu", async ({ testPage, apiClient }) => {
|
||||
test.setTimeout(60_000);
|
||||
await apiClient.createPrompt("qa-space", "x");
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@");
|
||||
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
|
||||
await textarea.pressSequentially(" foo");
|
||||
// After a space immediately follows @, trigger detection should yield null.
|
||||
await expect(testPage.getByText(MENU_TITLE)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("backspacing past the @ closes the menu", async ({ testPage, apiClient }) => {
|
||||
test.setTimeout(60_000);
|
||||
await apiClient.createPrompt("qa-back", "x");
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@qa");
|
||||
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
|
||||
await textarea.press("Backspace");
|
||||
await textarea.press("Backspace");
|
||||
await textarea.press("Backspace"); // deletes the @
|
||||
await expect(textarea).toHaveValue("");
|
||||
await expect(testPage.getByText(MENU_TITLE)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("ArrowUp/ArrowDown don't propagate to native textarea cursor motion", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
// When the menu is open, the hook calls preventDefault on Arrow keys, so the
|
||||
// textarea's selection cursor should NOT move. Probe by verifying that
|
||||
// arrow-up does not change the textarea content/cursor in a way that
|
||||
// breaks the subsequent Enter selection.
|
||||
test.setTimeout(60_000);
|
||||
await apiClient.createPrompt("qa-arrow", "ARROW_CONTENT");
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@qa-arr");
|
||||
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
|
||||
|
||||
const before = await textarea.evaluate((el) => (el as HTMLTextAreaElement).selectionStart);
|
||||
await textarea.press("ArrowDown");
|
||||
await textarea.press("ArrowUp");
|
||||
const after = await textarea.evaluate((el) => (el as HTMLTextAreaElement).selectionStart);
|
||||
expect(after).toBe(before);
|
||||
|
||||
await textarea.press("Enter");
|
||||
await expect(textarea).toHaveValue("ARROW_CONTENT");
|
||||
});
|
||||
|
||||
test("description with inlined prompt is sent to backend on submit", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
// Acceptance criterion: the textarea contains the inlined prompt content before submit
|
||||
// and the form completes successfully. API-level task description verification is a
|
||||
// follow-up — it requires retrieving the created task by title after creation.
|
||||
test.setTimeout(60_000);
|
||||
const content = "INLINED_FROM_PROMPT_PAYLOAD";
|
||||
await apiClient.createPrompt("qa-submit", content);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
await testPage.getByTestId("task-title-input").fill("qa-submit-task");
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@qa-su");
|
||||
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
|
||||
await textarea.press("Enter");
|
||||
await expect(textarea).toHaveValue(content);
|
||||
|
||||
const start = testPage.getByTestId("submit-start-agent");
|
||||
await expect(start).toBeEnabled({ timeout: 30_000 });
|
||||
await start.click();
|
||||
|
||||
await expect(testPage.getByTestId("create-task-dialog")).not.toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
const PROMPT_NAME = "e2e-bug-template";
|
||||
const PROMPT_CONTENT = "Reproduce, isolate, fix with a regression test.";
|
||||
const MENU_TITLE = /Mention tasks, files, prompts/i;
|
||||
|
||||
test.describe("Task creation: custom prompt autocomplete", () => {
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
const { prompts } = await apiClient.listPrompts();
|
||||
for (const p of prompts) {
|
||||
if (!p.builtin && p.name === PROMPT_NAME) {
|
||||
await apiClient.deletePrompt(p.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("typing @<name> opens the menu and selecting inlines the prompt content", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await apiClient.createPrompt(PROMPT_NAME, PROMPT_CONTENT);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@e2e-bu");
|
||||
|
||||
const menu = testPage.getByText(MENU_TITLE);
|
||||
await expect(menu).toBeVisible({ timeout: 5_000 });
|
||||
await expect(testPage.getByRole("button", { name: new RegExp(PROMPT_NAME) })).toBeVisible();
|
||||
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect(textarea).toHaveValue(PROMPT_CONTENT);
|
||||
await expect(menu).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("typing @ inside a word does NOT open the menu", async ({ testPage, apiClient }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await apiClient.createPrompt(PROMPT_NAME, PROMPT_CONTENT);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
// No preceding whitespace before @ — the trigger is invalid.
|
||||
await textarea.pressSequentially("foo@bar");
|
||||
|
||||
await expect(textarea).toHaveValue("foo@bar");
|
||||
await expect(testPage.getByText(MENU_TITLE)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Enter inside the menu does NOT submit the form", async ({ testPage, apiClient }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await apiClient.createPrompt(PROMPT_NAME, PROMPT_CONTENT);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Fill title so the form would be otherwise submittable.
|
||||
await testPage.getByTestId("task-title-input").fill("autocomplete-enter-test");
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await textarea.click();
|
||||
await textarea.pressSequentially("@e2e-bu");
|
||||
|
||||
await expect(testPage.getByText(MENU_TITLE)).toBeVisible();
|
||||
await textarea.press("Enter");
|
||||
|
||||
// Dialog must still be open — Enter selected the menu item, not the form submit.
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(textarea).toHaveValue(PROMPT_CONTENT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
makePromptItem,
|
||||
detectMentionTrigger,
|
||||
filterItems,
|
||||
type MentionItem,
|
||||
} from "./use-inline-mention";
|
||||
import type { RichTextInputHandle } from "@/components/task/chat/rich-text-input";
|
||||
|
||||
function makeFakeInput(value: string, caretPos: number): RichTextInputHandle {
|
||||
let selStart = caretPos;
|
||||
let selEnd = caretPos;
|
||||
return {
|
||||
focus: vi.fn(),
|
||||
blur: vi.fn(),
|
||||
getSelectionStart: () => selStart,
|
||||
getSelectionEnd: () => selEnd,
|
||||
setSelectionRange: (start: number, end: number) => {
|
||||
selStart = start;
|
||||
selEnd = end;
|
||||
},
|
||||
getCaretRect: () => null,
|
||||
getValue: () => value,
|
||||
setValue: vi.fn(),
|
||||
insertText: vi.fn(),
|
||||
getTextareaElement: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("makePromptItem — context mode (default chat behavior)", () => {
|
||||
it("deletes the @query text and calls onPromptSelect", () => {
|
||||
const prompt = { id: "p1", name: "bug-template", content: "Reproduce, isolate, fix." };
|
||||
const onPromptSelect = vi.fn();
|
||||
const item = makePromptItem(prompt, "context", onPromptSelect);
|
||||
|
||||
const value = "Hello @bug";
|
||||
const triggerStart = 6;
|
||||
const cursorPos = value.length;
|
||||
const input = makeFakeInput(value, cursorPos);
|
||||
const onChange = vi.fn();
|
||||
|
||||
item.onSelect(input, value, triggerStart, onChange);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("Hello ");
|
||||
expect(onPromptSelect).toHaveBeenCalledWith("p1", "bug-template");
|
||||
});
|
||||
|
||||
it("exposes kind 'prompt' and label = prompt name", () => {
|
||||
const item = makePromptItem({ id: "p1", name: "foo", content: "bar" }, "context");
|
||||
expect(item.kind).toBe("prompt");
|
||||
expect(item.label).toBe("foo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("makePromptItem — inline mode (task-create behavior)", () => {
|
||||
it("replaces the @query text with the prompt content", () => {
|
||||
const prompt = {
|
||||
id: "p1",
|
||||
name: "bug-template",
|
||||
content: "Reproduce, isolate, fix with a regression test.",
|
||||
};
|
||||
const onPromptSelect = vi.fn();
|
||||
const item = makePromptItem(prompt, "inline", onPromptSelect);
|
||||
|
||||
const value = "Hello @bug";
|
||||
const triggerStart = 6;
|
||||
const cursorPos = value.length;
|
||||
const input = makeFakeInput(value, cursorPos);
|
||||
const onChange = vi.fn();
|
||||
|
||||
item.onSelect(input, value, triggerStart, onChange);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("Hello Reproduce, isolate, fix with a regression test.");
|
||||
expect(onPromptSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("places caret at the end of the inserted content", async () => {
|
||||
const prompt = { id: "p1", name: "p", content: "abc" };
|
||||
const item = makePromptItem(prompt, "inline");
|
||||
const value = "x @p";
|
||||
const triggerStart = 2;
|
||||
const input = makeFakeInput(value, value.length);
|
||||
const setSelectionRangeSpy = vi.spyOn(input, "setSelectionRange");
|
||||
const focusSpy = vi.spyOn(input, "focus");
|
||||
const onChange = vi.fn();
|
||||
|
||||
item.onSelect(input, value, triggerStart, onChange);
|
||||
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
|
||||
const expectedCaret = triggerStart + prompt.content.length;
|
||||
expect(setSelectionRangeSpy).toHaveBeenCalledWith(expectedCaret, expectedCaret);
|
||||
expect(focusSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves text after the cursor", () => {
|
||||
const prompt = { id: "p1", name: "p", content: "XYZ" };
|
||||
const item = makePromptItem(prompt, "inline");
|
||||
const value = "before @p after";
|
||||
const triggerStart = 7;
|
||||
const cursorPos = 9;
|
||||
const input = makeFakeInput(value, cursorPos);
|
||||
const onChange = vi.fn();
|
||||
|
||||
item.onSelect(input, value, triggerStart, onChange);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("before XYZ after");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectMentionTrigger", () => {
|
||||
it("returns the query when @ is at start of input", () => {
|
||||
expect(detectMentionTrigger("@foo", 4)).toEqual({ triggerStart: 0, query: "foo" });
|
||||
});
|
||||
|
||||
it("returns the query when @ follows whitespace", () => {
|
||||
expect(detectMentionTrigger("hello @bar", 10)).toEqual({ triggerStart: 6, query: "bar" });
|
||||
});
|
||||
|
||||
it("rejects @ inside a word (no preceding whitespace)", () => {
|
||||
expect(detectMentionTrigger("foo@bar", 7)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects when whitespace appears between @ and cursor", () => {
|
||||
expect(detectMentionTrigger("@foo bar", 8)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no @ before cursor", () => {
|
||||
expect(detectMentionTrigger("plain text", 5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterItems — relevance ordering", () => {
|
||||
function dummyItem(id: string, label: string): MentionItem {
|
||||
return { id, kind: "prompt", label, onSelect: vi.fn() };
|
||||
}
|
||||
|
||||
it("returns all items when query is empty", () => {
|
||||
const items = [dummyItem("1", "a"), dummyItem("2", "b")];
|
||||
expect(filterItems(items, "")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("orders prefix matches before contains matches", () => {
|
||||
const items = [dummyItem("contains", "abc-foo"), dummyItem("prefix", "foo-bar")];
|
||||
const out = filterItems(items, "foo");
|
||||
expect(out.map((i) => i.id)).toEqual(["prefix", "contains"]);
|
||||
});
|
||||
|
||||
it("returns built-in and user prompts together", () => {
|
||||
const items = [dummyItem("builtin", "review"), dummyItem("user", "review-mine")];
|
||||
const out = filterItems(items, "review");
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -48,7 +48,8 @@ function isValidMentionTrigger(text: string, pos: number): boolean {
|
||||
return charBefore === " " || charBefore === "\n" || charBefore === "\t";
|
||||
}
|
||||
|
||||
function filterItems(items: MentionItem[], query: string): MentionItem[] {
|
||||
/** Exported for tests. */
|
||||
export function filterItems(items: MentionItem[], query: string): MentionItem[] {
|
||||
if (!query) return items;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
@@ -106,9 +107,21 @@ function makePlanItem(onPlanSelect: () => void): MentionItem {
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a prompt mention item that adds prompt to context store instead of inserting content. */
|
||||
function makePromptItem(
|
||||
export type PromptInsertMode = "context" | "inline";
|
||||
|
||||
/**
|
||||
* Build a prompt mention item.
|
||||
*
|
||||
* - `"context"` (default): delete the `@query` text and notify `onPromptSelect`
|
||||
* so the caller can attach the prompt as context (used by chat).
|
||||
* - `"inline"`: replace the `@query` text with the prompt's full content
|
||||
* (used by task-create where there is no context store yet).
|
||||
*
|
||||
* Exported for unit tests.
|
||||
*/
|
||||
export function makePromptItem(
|
||||
prompt: { id: string; name: string; content: string },
|
||||
mode: PromptInsertMode,
|
||||
onPromptSelect?: (id: string, name: string) => void,
|
||||
): MentionItem {
|
||||
return {
|
||||
@@ -119,6 +132,16 @@ function makePromptItem(
|
||||
prompt.content.length > 100 ? prompt.content.slice(0, 100) + "..." : prompt.content,
|
||||
onSelect: (input, value, triggerStart, onChange) => {
|
||||
const cursorPos = input.getSelectionStart();
|
||||
if (mode === "inline") {
|
||||
const insertion = prompt.content;
|
||||
onChange(value.substring(0, triggerStart) + insertion + value.substring(cursorPos));
|
||||
const caret = triggerStart + insertion.length;
|
||||
requestAnimationFrame(() => {
|
||||
input.setSelectionRange(caret, caret);
|
||||
input.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
onChange(value.substring(0, triggerStart) + value.substring(cursorPos));
|
||||
onPromptSelect?.(prompt.id, prompt.name);
|
||||
requestAnimationFrame(() => {
|
||||
@@ -139,8 +162,8 @@ function clearMentionState(
|
||||
setQuery("");
|
||||
}
|
||||
|
||||
/** Detect an @-trigger in text before cursor and return the query, or null if none. */
|
||||
function detectMentionTrigger(
|
||||
/** Detect an @-trigger in text before cursor and return the query, or null if none. Exported for tests. */
|
||||
export function detectMentionTrigger(
|
||||
text: string,
|
||||
cursorPos: number,
|
||||
): { triggerStart: number; query: string } | null {
|
||||
@@ -160,6 +183,8 @@ export interface UseInlineMentionParams {
|
||||
onPlanSelect?: () => void;
|
||||
onFileSelect?: (path: string, name: string) => void;
|
||||
onPromptSelect?: (id: string, name: string) => void;
|
||||
/** How selecting a prompt mutates the input. Defaults to `"context"`. */
|
||||
promptInsertMode?: PromptInsertMode;
|
||||
}
|
||||
|
||||
function useFileSearch(sessionId: string | null | undefined, isOpen: boolean, query: string) {
|
||||
@@ -264,6 +289,7 @@ type MentionItemsOptions = {
|
||||
onPlanSelect?: () => void;
|
||||
onFileSelect?: (path: string, name: string) => void;
|
||||
onPromptSelect?: (id: string, name: string) => void;
|
||||
promptInsertMode: PromptInsertMode;
|
||||
};
|
||||
|
||||
function useMentionItems({
|
||||
@@ -273,6 +299,7 @@ function useMentionItems({
|
||||
onPlanSelect,
|
||||
onFileSelect,
|
||||
onPromptSelect,
|
||||
promptInsertMode,
|
||||
}: MentionItemsOptions) {
|
||||
const planItem = useMemo((): MentionItem | null => {
|
||||
if (!onPlanSelect) return null;
|
||||
@@ -280,8 +307,8 @@ function useMentionItems({
|
||||
}, [onPlanSelect]);
|
||||
|
||||
const promptItems = useMemo(
|
||||
() => prompts.map((prompt) => makePromptItem(prompt, onPromptSelect)),
|
||||
[prompts, onPromptSelect],
|
||||
() => prompts.map((prompt) => makePromptItem(prompt, promptInsertMode, onPromptSelect)),
|
||||
[prompts, promptInsertMode, onPromptSelect],
|
||||
);
|
||||
|
||||
const fileItems = useMemo(
|
||||
@@ -305,6 +332,7 @@ export function useInlineMention({
|
||||
onPlanSelect,
|
||||
onFileSelect,
|
||||
onPromptSelect,
|
||||
promptInsertMode = "context",
|
||||
}: UseInlineMentionParams) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState<Position | null>(null);
|
||||
@@ -321,6 +349,7 @@ export function useInlineMention({
|
||||
onPlanSelect,
|
||||
onFileSelect,
|
||||
onPromptSelect,
|
||||
promptInsertMode,
|
||||
});
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useInlineMention } from "@/hooks/use-inline-mention";
|
||||
import { measureCaretRect } from "@/lib/utils/caret-position";
|
||||
import type { RichTextInputHandle } from "@/components/task/chat/rich-text-input";
|
||||
|
||||
type Params = {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
value: string;
|
||||
onChange: (newValue: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wires the `useInlineMention` autocomplete hook to a plain `<textarea>` so
|
||||
* the task-create prompt input can offer `@`-mention selection of saved
|
||||
* custom prompts. Inlines the prompt's content text at the trigger position
|
||||
* (no context chips, no backend changes).
|
||||
*/
|
||||
export function useTaskCreatePromptMention({ textareaRef, value, onChange }: Params) {
|
||||
const adapter = useMemo<RichTextInputHandle>(
|
||||
() => ({
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
blur: () => textareaRef.current?.blur(),
|
||||
getSelectionStart: () => textareaRef.current?.selectionStart ?? 0,
|
||||
getSelectionEnd: () => textareaRef.current?.selectionEnd ?? 0,
|
||||
setSelectionRange: (start: number, end: number) => {
|
||||
textareaRef.current?.setSelectionRange(start, end);
|
||||
},
|
||||
getCaretRect: () => {
|
||||
const ta = textareaRef.current;
|
||||
return ta ? measureCaretRect(ta, ta.value) : null;
|
||||
},
|
||||
getValue: () => textareaRef.current?.value ?? "",
|
||||
setValue: (v: string) => onChange(v),
|
||||
insertText: (text: string, from: number, to: number) => {
|
||||
const current = textareaRef.current?.value ?? "";
|
||||
const newValue = current.substring(0, from) + text + current.substring(to);
|
||||
onChange(newValue);
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.setSelectionRange(from + text.length, from + text.length);
|
||||
});
|
||||
},
|
||||
getTextareaElement: () => textareaRef.current,
|
||||
}),
|
||||
[textareaRef, onChange],
|
||||
);
|
||||
|
||||
const adapterRef = useRef<RichTextInputHandle | null>(adapter);
|
||||
useLayoutEffect(() => {
|
||||
adapterRef.current = adapter;
|
||||
}, [adapter]);
|
||||
|
||||
return useInlineMention({
|
||||
inputRef: adapterRef,
|
||||
value,
|
||||
onChange,
|
||||
promptInsertMode: "inline",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
const MIRROR_STYLES = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"letterSpacing",
|
||||
"textTransform",
|
||||
"wordSpacing",
|
||||
"textIndent",
|
||||
"whiteSpace",
|
||||
"wordWrap",
|
||||
"wordBreak",
|
||||
"overflowWrap",
|
||||
"lineHeight",
|
||||
"padding",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"borderWidth",
|
||||
"boxSizing",
|
||||
] as const;
|
||||
|
||||
export function measureCaretRect(textarea: HTMLTextAreaElement, value: string): DOMRect {
|
||||
const selectionStart = textarea.selectionStart;
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
const mirror = document.createElement("div");
|
||||
|
||||
mirror.style.position = "absolute";
|
||||
mirror.style.visibility = "hidden";
|
||||
mirror.style.whiteSpace = "pre-wrap";
|
||||
mirror.style.wordWrap = "break-word";
|
||||
mirror.style.width = `${textarea.clientWidth}px`;
|
||||
MIRROR_STYLES.forEach((prop) => {
|
||||
mirror.style[prop as unknown as number] = computed[prop];
|
||||
});
|
||||
|
||||
document.body.appendChild(mirror);
|
||||
|
||||
mirror.textContent = value.substring(0, selectionStart);
|
||||
const marker = document.createElement("span");
|
||||
marker.textContent = "";
|
||||
mirror.appendChild(marker);
|
||||
|
||||
let caretLeft: number;
|
||||
let caretTop: number;
|
||||
try {
|
||||
const textareaRect = textarea.getBoundingClientRect();
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const mirrorRect = mirror.getBoundingClientRect();
|
||||
const scrollTop = textarea.scrollTop;
|
||||
caretLeft = textareaRect.left + (markerRect.left - mirrorRect.left);
|
||||
caretTop = textareaRect.top + (markerRect.top - mirrorRect.top) - scrollTop;
|
||||
} finally {
|
||||
document.body.removeChild(mirror);
|
||||
}
|
||||
|
||||
return new DOMRect(
|
||||
caretLeft,
|
||||
caretTop,
|
||||
0,
|
||||
parseInt(computed.lineHeight, 10) || parseInt(computed.fontSize, 10) * 1.2,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user