Fix mention popup placement and spaced queries

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-04 19:10:58 -05:00
parent 08fea10ce1
commit bdc8e27bf4
2 changed files with 93 additions and 30 deletions

View file

@ -3,7 +3,7 @@
import { act } from "react"; import { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor"; import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor";
const mdxEditorMockState = vi.hoisted(() => ({ const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false, emitMountEmptyReset: false,
@ -186,4 +186,31 @@ describe("MarkdownEditor", () => {
left: 92, left: 92,
}); });
}); });
it("keeps a short mention menu on the same line when it fits below the caret", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 160, viewportLeft: 120 },
{ offsetLeft: 0, offsetTop: 0, width: 320, height: 220 },
{ width: 188, height: 42 },
),
).toEqual({
top: 164,
left: 120,
});
});
it("keeps mention queries active across spaces", () => {
expect(findMentionMatch("Ping @Paperclip App", "Ping @Paperclip App".length)).toEqual({
trigger: "mention",
marker: "@",
query: "Paperclip App",
atPos: 5,
endPos: "Ping @Paperclip App".length,
});
});
it("still rejects slash commands once spaces are typed", () => {
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull();
});
}); });

View file

@ -108,9 +108,16 @@ interface MentionMenuViewport {
height: number; height: number;
} }
interface MentionMenuSize {
width: number;
height: number;
}
const MENTION_MENU_WIDTH = 188; const MENTION_MENU_WIDTH = 188;
const MENTION_MENU_HEIGHT = 208; const MENTION_MENU_HEIGHT = 208;
const MENTION_MENU_PADDING = 8; const MENTION_MENU_PADDING = 8;
const MENTION_MENU_ROW_HEIGHT = 34;
const MENTION_MENU_CHROME_HEIGHT = 8;
const CODE_BLOCK_LANGUAGES: Record<string, string> = { const CODE_BLOCK_LANGUAGES: Record<string, string> = {
txt: "Text", txt: "Text",
@ -140,19 +147,10 @@ const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = {
Editor: CodeMirrorEditor, Editor: CodeMirrorEditor,
}; };
function detectMention(container: HTMLElement): MentionState | null { export function findMentionMatch(
const sel = window.getSelection(); text: string,
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null; offset: number,
): Pick<MentionState, "trigger" | "marker" | "query" | "atPos" | "endPos"> | null {
const range = sel.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType !== Node.TEXT_NODE) return null;
if (!container.contains(textNode)) return null;
const text = textNode.textContent ?? "";
const offset = range.startOffset;
// Walk backwards from cursor to find an autocomplete trigger.
let atPos = -1; let atPos = -1;
let trigger: MentionState["trigger"] | null = null; let trigger: MentionState["trigger"] | null = null;
let marker: MentionState["marker"] | null = null; let marker: MentionState["marker"] | null = null;
@ -166,31 +164,54 @@ function detectMention(container: HTMLElement): MentionState | null {
} }
break; break;
} }
if (/\s/.test(ch)) break; if (ch === "\n" || ch === "\r") break;
} }
if (atPos === -1) return null; if (atPos === -1) return null;
const query = text.slice(atPos + 1, offset); const query = text.slice(atPos + 1, offset);
if (trigger === "skill" && /\s/.test(query)) return null;
// Get position relative to container
const tempRange = document.createRange();
tempRange.setStart(textNode, atPos);
tempRange.setEnd(textNode, atPos + 1);
const rect = tempRange.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return { return {
trigger: trigger ?? "mention", trigger: trigger ?? "mention",
marker: marker ?? "@", marker: marker ?? "@",
query, query,
atPos,
endPos: offset,
};
}
function detectMention(container: HTMLElement): MentionState | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null;
const range = sel.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType !== Node.TEXT_NODE) return null;
if (!container.contains(textNode)) return null;
const text = textNode.textContent ?? "";
const offset = range.startOffset;
const match = findMentionMatch(text, offset);
if (!match) return null;
// Get position relative to container
const tempRange = document.createRange();
tempRange.setStart(textNode, match.atPos);
tempRange.setEnd(textNode, match.atPos + 1);
const rect = tempRange.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return {
trigger: match.trigger,
marker: match.marker,
query: match.query,
top: rect.bottom - containerRect.top, top: rect.bottom - containerRect.top,
left: rect.left - containerRect.left, left: rect.left - containerRect.left,
viewportTop: rect.bottom, viewportTop: rect.bottom,
viewportLeft: rect.left, viewportLeft: rect.left,
textNode: textNode as Text, textNode: textNode as Text,
atPos, atPos: match.atPos,
endPos: offset, endPos: match.endPos,
}; };
} }
@ -216,11 +237,12 @@ function getMentionMenuViewport(): MentionMenuViewport {
export function computeMentionMenuPosition( export function computeMentionMenuPosition(
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">, anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
viewport: MentionMenuViewport, viewport: MentionMenuViewport,
menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT },
) { ) {
const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING; const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING;
const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH; const maxLeft = viewport.offsetLeft + viewport.width - menuSize.width;
const minTop = viewport.offsetTop + MENTION_MENU_PADDING; const minTop = viewport.offsetTop + MENTION_MENU_PADDING;
const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT; const maxTop = viewport.offsetTop + viewport.height - menuSize.height;
return { return {
top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)), top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)),
@ -228,6 +250,17 @@ export function computeMentionMenuPosition(
}; };
} }
function getMentionMenuSize(optionCount: number): MentionMenuSize {
const visibleRows = Math.max(1, Math.min(optionCount, 8));
return {
width: MENTION_MENU_WIDTH,
height: Math.min(
MENTION_MENU_HEIGHT,
visibleRows * MENTION_MENU_ROW_HEIGHT + MENTION_MENU_CHROME_HEIGHT,
),
};
}
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean { function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
if (!node || !container.contains(node)) return false; if (!node || !container.contains(node)) return false;
const el = node.nodeType === Node.ELEMENT_NODE const el = node.nodeType === Node.ELEMENT_NODE
@ -650,7 +683,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}, []); }, []);
const mentionMenuPosition = mentionState const mentionMenuPosition = mentionState
? computeMentionMenuPosition(mentionState, getMentionMenuViewport()) ? computeMentionMenuPosition(
mentionState,
getMentionMenuViewport(),
getMentionMenuSize(filteredMentions.length),
)
: null; : null;
return ( return (
@ -673,8 +710,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// Mention keyboard handling // Mention keyboard handling
if (mentionActive) { if (mentionActive) {
// Space dismisses the popup (let the character be typed normally) if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
if (e.key === " ") {
mentionStateRef.current = null; mentionStateRef.current = null;
setMentionState(null); setMentionState(null);
return; return;