forked from farhoodlabs/paperclip
Fix mention popup placement and spaced queries
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user