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
+28 -1
View File
@@ -3,7 +3,7 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor";
const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false,
@@ -186,4 +186,31 @@ describe("MarkdownEditor", () => {
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();
});
});
+65 -29
View File
@@ -108,9 +108,16 @@ interface MentionMenuViewport {
height: number;
}
interface MentionMenuSize {
width: number;
height: number;
}
const MENTION_MENU_WIDTH = 188;
const MENTION_MENU_HEIGHT = 208;
const MENTION_MENU_PADDING = 8;
const MENTION_MENU_ROW_HEIGHT = 34;
const MENTION_MENU_CHROME_HEIGHT = 8;
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
txt: "Text",
@@ -140,19 +147,10 @@ const FALLBACK_CODE_BLOCK_DESCRIPTOR: CodeBlockEditorDescriptor = {
Editor: CodeMirrorEditor,
};
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;
// Walk backwards from cursor to find an autocomplete trigger.
export function findMentionMatch(
text: string,
offset: number,
): Pick<MentionState, "trigger" | "marker" | "query" | "atPos" | "endPos"> | null {
let atPos = -1;
let trigger: MentionState["trigger"] | null = null;
let marker: MentionState["marker"] | null = null;
@@ -166,31 +164,54 @@ function detectMention(container: HTMLElement): MentionState | null {
}
break;
}
if (/\s/.test(ch)) break;
if (ch === "\n" || ch === "\r") break;
}
if (atPos === -1) return null;
const query = text.slice(atPos + 1, offset);
// 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();
if (trigger === "skill" && /\s/.test(query)) return null;
return {
trigger: trigger ?? "mention",
marker: marker ?? "@",
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,
left: rect.left - containerRect.left,
viewportTop: rect.bottom,
viewportLeft: rect.left,
textNode: textNode as Text,
atPos,
endPos: offset,
atPos: match.atPos,
endPos: match.endPos,
};
}
@@ -216,11 +237,12 @@ function getMentionMenuViewport(): MentionMenuViewport {
export function computeMentionMenuPosition(
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
viewport: MentionMenuViewport,
menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT },
) {
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 maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT;
const maxTop = viewport.offsetTop + viewport.height - menuSize.height;
return {
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 {
if (!node || !container.contains(node)) return false;
const el = node.nodeType === Node.ELEMENT_NODE
@@ -650,7 +683,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}, []);
const mentionMenuPosition = mentionState
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
? computeMentionMenuPosition(
mentionState,
getMentionMenuViewport(),
getMentionMenuSize(filteredMentions.length),
)
: null;
return (
@@ -673,8 +710,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// Mention keyboard handling
if (mentionActive) {
// Space dismisses the popup (let the character be typed normally)
if (e.key === " ") {
if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
mentionStateRef.current = null;
setMentionState(null);
return;