fix(ui): restore touch mention selection

This commit is contained in:
Dotta committed 2026-04-14 11:45:22 -05:00
1 parent 31a73c8c9c
commit 093691b683
2 files changed
+117 -8

No files matched your search

+90 -3
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 { buildSkillMentionHref } from "@paperclipai/shared";
import { buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import {
computeMentionMenuPosition,
findClosestAutocompleteAnchor,
@@ -36,17 +36,20 @@ vi.mock("@mdxeditor/editor", async () => {
markdown,
placeholder,
onChange,
className,
}: {
markdown: string;
placeholder?: string;
onChange?: (value: string) => void;
className?: string;
},
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
) {
const [content, setContent] = React.useState(markdown);
const editableRef = React.useRef<HTMLDivElement>(null);
const handle = React.useMemo(() => ({
setMarkdown: (value: string) => setContent(value),
focus: () => {},
focus: () => editableRef.current?.focus(),
}), []);
React.useEffect(() => {
@@ -64,7 +67,17 @@ vi.mock("@mdxeditor/editor", async () => {
};
}, []);
return <div data-testid="mdx-editor">{content || placeholder || ""}</div>;
return (
<div
ref={editableRef}
data-testid="mdx-editor"
className={className}
contentEditable
suppressContentEditableWarning
>
{content || placeholder || ""}
</div>
);
});
return {
@@ -105,14 +118,28 @@ async function flush() {
describe("MarkdownEditor", () => {
let container: HTMLDivElement;
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
originalRangeRect = Range.prototype.getBoundingClientRect;
Range.prototype.getBoundingClientRect = () => ({
x: 32,
y: 24,
width: 12,
height: 18,
top: 24,
right: 44,
bottom: 42,
left: 32,
toJSON: () => ({}),
});
});
afterEach(() => {
container.remove();
Range.prototype.getBoundingClientRect = originalRangeRect;
vi.clearAllMocks();
mdxEditorMockState.emitMountEmptyReset = false;
});
@@ -312,4 +339,64 @@ describe("MarkdownEditor", () => {
editable.remove();
});
it("accepts mention selection from touchstart taps", async () => {
const handleChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="@Pap"
onChange={handleChange}
mentions={[
{
id: "project:project-123",
kind: "project",
name: "Paperclip App",
projectId: "project-123",
projectColor: "#336699",
},
]}
/>,
);
});
await flush();
const editable = container.querySelector('[contenteditable="true"]');
expect(editable).not.toBeNull();
const textNode = editable?.firstChild;
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
const selection = window.getSelection();
const range = document.createRange();
range.setStart(textNode!, "@Pap".length);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
act(() => {
document.dispatchEvent(new Event("selectionchange"));
});
await flush();
const option = Array.from(document.body.querySelectorAll('button[type="button"]'))
.find((node) => node.textContent?.includes("Paperclip App"));
expect(option).toBeTruthy();
act(() => {
option?.dispatchEvent(new Event("touchstart", { bubbles: true, cancelable: true }));
});
expect(handleChange).toHaveBeenCalledWith(
`[@Paperclip App](${buildProjectMentionHref("project-123", "#336699")}) `,
);
await act(async () => {
root.unmount();
});
});
});
+27 -5
View File
@@ -8,6 +8,9 @@ import {
useRef,
useState,
type DragEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type TouchEvent as ReactTouchEvent,
} from "react";
import { createPortal } from "react-dom";
import {
@@ -443,6 +446,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const skillEnterArmedRef = useRef(false);
const autocompleteSelectionHandledRef = useRef(false);
const mentionActive = mentionState !== null && (
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
@@ -676,6 +680,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
};
}, [checkMention, mentionActive]);
useEffect(() => {
if (mentionActive) return;
autocompleteSelectionHandledRef.current = false;
}, [mentionActive]);
useEffect(() => {
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!editable) return;
@@ -696,7 +705,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// Read from ref to avoid stale-closure issues (selectionchange can
// update state between the last render and this callback firing).
const state = mentionStateRef.current;
if (!state) return;
if (!state) return false;
const current = latestValueRef.current;
const next = applyMention(current, state, option);
if (next !== current) {
@@ -729,10 +738,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return true;
},
[decorateProjectMentions, onChange],
);
const handleAutocompletePress = useCallback((
event: ReactMouseEvent<HTMLButtonElement> | ReactPointerEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>,
option: AutocompleteOption,
) => {
event.preventDefault();
event.stopPropagation();
if (autocompleteSelectionHandledRef.current) return;
const handled = selectMention(option);
if (handled) {
autocompleteSelectionHandledRef.current = true;
}
}, [selectMention]);
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
}
@@ -917,10 +940,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent",
)}
onPointerDown={(e) => {
e.preventDefault(); // prevent blur
selectMention(option);
}}
onPointerDown={(e) => handleAutocompletePress(e, option)}
onMouseDown={(e) => handleAutocompletePress(e, option)}
onTouchStart={(e) => handleAutocompletePress(e, option)}
onMouseEnter={() => {
if (mentionStateRef.current?.trigger === "skill") {
skillEnterArmedRef.current = true;