mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
Fix mention popup placement and spaced queries
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
08fea10ce1
commit
bdc8e27bf4
2 changed files with 93 additions and 30 deletions
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue