From 093691b683848d4c9e000ac166a63fe5ca2a6a2d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 06:07:13 -0500 Subject: [PATCH] fix(ui): restore touch mention selection --- ui/src/components/MarkdownEditor.test.tsx | 93 ++++++++++++++++++++++- ui/src/components/MarkdownEditor.tsx | 32 ++++++-- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 3e0431557e0..90f3ed9b6f5 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -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(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
{content || placeholder || ""}
; + return ( +
+ {content || placeholder || ""} +
+ ); }); 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( + , + ); + }); + + 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(); + }); + }); }); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 00e9dfc6fca..95e08e9606c 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -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 const mentionStateRef = useRef(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 }; }, [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 // 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 mentionStateRef.current = null; skillEnterArmedRef.current = false; setMentionState(null); + return true; }, [decorateProjectMentions, onChange], ); + const handleAutocompletePress = useCallback(( + event: ReactMouseEvent | ReactPointerEvent | ReactTouchEvent, + option: AutocompleteOption, + ) => { + event.preventDefault(); + event.stopPropagation(); + if (autocompleteSelectionHandledRef.current) return; + const handled = selectMention(option); + if (handled) { + autocompleteSelectionHandledRef.current = true; + } + }, [selectMention]); + function hasFilePayload(evt: DragEvent) { return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); } @@ -917,10 +940,9 @@ export const MarkdownEditor = forwardRef "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;