a34ea719df
* 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>
65 lines
1.7 KiB
TypeScript
65 lines
1.7 KiB
TypeScript
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,
|
||
);
|
||
}
|