From 925ac9a3c1ccd34c9f5c8cc3da10895df2131ad3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 29 Apr 2026 11:16:43 -0500 Subject: [PATCH] PAP-2852 Anchor mention menu to caret and allow touch scrolling Mention menu now sits on the same line as the caret and to the right of the cursor (tracking each typed character instead of staying glued to the @ marker). When the menu would overflow below the viewport it flips above the line so it doesn't cover the line. Touch handling no longer preventDefaults on touchstart, so the dropdown scrolls natively on mobile; selection runs only on touchend if the finger lifted with negligible movement (a tap, not a scroll). Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.test.tsx | 108 +++++++++++++++-- ui/src/components/MarkdownEditor.tsx | 136 ++++++++++++++++++---- 2 files changed, 212 insertions(+), 32 deletions(-) diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index e41b1bab7f7..88b580aba0e 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -478,14 +478,26 @@ describe("MarkdownEditor", () => { }); }); - it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => { + it("places the menu top on the caret line and left at the caret X", () => { expect( computeMentionMenuPosition( - { viewportTop: 180, viewportLeft: 120 }, + { viewportTop: 100, viewportBottom: 118, viewportLeft: 240 }, + { offsetLeft: 0, offsetTop: 0, width: 800, height: 600 }, + ), + ).toEqual({ + top: 100, + left: 240, + }); + }); + + it("applies visual viewport offsets when present", () => { + expect( + computeMentionMenuPosition( + { viewportTop: 20, viewportBottom: 38, viewportLeft: 120 }, { offsetLeft: 24, offsetTop: 320, width: 320, height: 260 }, ), ).toEqual({ - top: 372, + top: 340, left: 144, }); }); @@ -493,7 +505,7 @@ describe("MarkdownEditor", () => { it("clamps the mention menu back into view near the viewport edges", () => { expect( computeMentionMenuPosition( - { viewportTop: 260, viewportLeft: 240 }, + { viewportTop: 260, viewportBottom: 278, viewportLeft: 240 }, { offsetLeft: 0, offsetTop: 0, width: 280, height: 220 }, ), ).toEqual({ @@ -502,15 +514,27 @@ describe("MarkdownEditor", () => { }); }); + it("flips the menu above the caret line when it would overflow below", () => { + expect( + computeMentionMenuPosition( + { viewportTop: 560, viewportBottom: 580, viewportLeft: 200 }, + { offsetLeft: 0, offsetTop: 0, width: 800, height: 600 }, + ), + ).toEqual({ + top: 372, + left: 200, + }); + }); + it("keeps a short mention menu on the same line when it fits below the caret", () => { expect( computeMentionMenuPosition( - { viewportTop: 160, viewportLeft: 120 }, + { viewportTop: 160, viewportBottom: 178, viewportLeft: 120 }, { offsetLeft: 0, offsetTop: 0, width: 320, height: 220 }, { width: 188, height: 42 }, ), ).toEqual({ - top: 164, + top: 160, left: 120, }); }); @@ -619,8 +643,20 @@ describe("MarkdownEditor", () => { editable.remove(); }); - it("accepts mention selection from touchstart taps", async () => { - const handleChange = vi.fn(); + function createTouchEvent( + type: "touchstart" | "touchmove" | "touchend", + touches: Array<{ clientX: number; clientY: number }>, + ) { + const event = new Event(type, { bubbles: true, cancelable: true }); + const list = touches as unknown as TouchList; + Object.defineProperty(event, "touches", { value: type === "touchend" ? [] : list }); + Object.defineProperty(event, "changedTouches", { value: list }); + return event; + } + + async function openMentionMenuFor( + handleChange: ReturnType, + ): Promise<{ option: HTMLButtonElement; root: ReturnType }> { const root = createRoot(container); await act(async () => { @@ -645,7 +681,6 @@ describe("MarkdownEditor", () => { const editable = container.querySelector('[contenteditable="true"]'); expect(editable).not.toBeNull(); - const textNode = editable?.firstChild; expect(textNode?.nodeType).toBe(Node.TEXT_NODE); @@ -659,15 +694,24 @@ describe("MarkdownEditor", () => { 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")); + .find((node) => node.textContent?.includes("Paperclip App")) as HTMLButtonElement | undefined; expect(option).toBeTruthy(); + return { option: option!, root }; + } + + it("accepts mention selection from a touch tap", async () => { + const handleChange = vi.fn(); + const { option, root } = await openMentionMenuFor(handleChange); + const point = { clientX: 100, clientY: 50 }; act(() => { - option?.dispatchEvent(new Event("touchstart", { bubbles: true, cancelable: true })); + option.dispatchEvent(createTouchEvent("touchstart", [point])); + }); + act(() => { + option.dispatchEvent(createTouchEvent("touchend", [point])); }); expect(handleChange).toHaveBeenCalledWith( @@ -678,4 +722,44 @@ describe("MarkdownEditor", () => { root.unmount(); }); }); + + it("does not preventDefault on touchstart so the mention menu can scroll on mobile", async () => { + const handleChange = vi.fn(); + const { option, root } = await openMentionMenuFor(handleChange); + + const touchstart = createTouchEvent("touchstart", [{ clientX: 100, clientY: 50 }]); + act(() => { + option.dispatchEvent(touchstart); + }); + + expect(touchstart.defaultPrevented).toBe(false); + expect(handleChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); + + it("does not select when the touch moves like a scroll", async () => { + const handleChange = vi.fn(); + const { option, root } = await openMentionMenuFor(handleChange); + const start = { clientX: 100, clientY: 50 }; + const moved = { clientX: 100, clientY: 90 }; + + act(() => { + option.dispatchEvent(createTouchEvent("touchstart", [start])); + }); + act(() => { + option.dispatchEvent(createTouchEvent("touchmove", [moved])); + }); + act(() => { + option.dispatchEvent(createTouchEvent("touchend", [moved])); + }); + + expect(handleChange).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 0ca8e0b6d87..77d526a68b5 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -174,8 +174,14 @@ interface MentionState { query: string; top: number; left: number; - /** Viewport-relative coords for portal positioning */ + /** + * Caret-aligned viewport coords for portal positioning. `viewportTop` / + * `viewportBottom` describe the active text line, and `viewportLeft` is the + * caret X (right edge of the last typed character) so the menu can sit on + * the same line, just to the right of the cursor. + */ viewportTop: number; + viewportBottom: number; viewportLeft: number; textNode: Text; atPos: number; @@ -263,6 +269,36 @@ export function findMentionMatch( }; } +interface CaretRect { + top: number; + bottom: number; + /** Caret X — the right edge of the last typed character (or left edge of the next). */ + x: number; +} + +function measureCaretRect(textNode: Text, offset: number, atPos: number): CaretRect { + const length = textNode.textContent?.length ?? 0; + const rectFromRange = (start: number, end: number, side: "right" | "left"): CaretRect | null => { + if (start < 0 || end > length || end <= start) return null; + const range = document.createRange(); + range.setStart(textNode, start); + range.setEnd(textNode, end); + const rect = range.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) return null; + return { top: rect.top, bottom: rect.bottom, x: side === "right" ? rect.right : rect.left }; + }; + + // Prefer the character immediately before the caret — its right edge IS the caret X + // and its top/bottom describe the active line. Falls back to the char after the caret + // and finally the @ marker if nothing else gives us a valid rect. + return ( + rectFromRange(Math.max(0, offset - 1), offset, "right") + ?? rectFromRange(offset, Math.min(length, offset + 1), "left") + ?? rectFromRange(atPos, atPos + 1, "right") + ?? { top: 0, bottom: 0, x: 0 } + ); +} + function detectMention(container: HTMLElement): MentionState | null { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return null; @@ -277,21 +313,20 @@ function detectMention(container: HTMLElement): MentionState | null { 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(); + // Anchor the menu to the live caret so it tracks each typed character instead of + // staying glued to the @ marker. + const caret = measureCaretRect(textNode as Text, offset, match.atPos); 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, + top: caret.top - containerRect.top, + left: caret.x - containerRect.left, + viewportTop: caret.top, + viewportBottom: caret.bottom, + viewportLeft: caret.x, textNode: textNode as Text, atPos: match.atPos, endPos: match.endPos, @@ -318,7 +353,7 @@ function getMentionMenuViewport(): MentionMenuViewport { } export function computeMentionMenuPosition( - anchor: Pick, + anchor: Pick, viewport: MentionMenuViewport, menuSize: MentionMenuSize = { width: MENTION_MENU_WIDTH, height: MENTION_MENU_HEIGHT }, ) { @@ -327,10 +362,23 @@ export function computeMentionMenuPosition( const minTop = viewport.offsetTop + MENTION_MENU_PADDING; const maxTop = viewport.offsetTop + viewport.height - menuSize.height; - return { - top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)), - left: Math.max(minLeft, Math.min(viewport.offsetLeft + anchor.viewportLeft, maxLeft)), - }; + // Place the menu's top edge on the current line so it sits next to the caret. + // If it would overflow below, flip above so the menu's bottom hugs the line. + const desiredTop = viewport.offsetTop + anchor.viewportTop; + let top: number; + if (desiredTop > maxTop) { + const flipped = viewport.offsetTop + anchor.viewportBottom - menuSize.height; + top = Math.max(minTop, Math.min(flipped, maxTop)); + } else { + top = Math.max(minTop, desiredTop); + } + + // Place the menu's left edge at the caret X so the menu is to the right of + // the cursor, clamped to stay inside the viewport. + const desiredLeft = viewport.offsetLeft + anchor.viewportLeft; + const left = Math.max(minLeft, Math.min(desiredLeft, maxLeft)); + + return { top, left }; } function getMentionMenuSize(optionCount: number): MentionMenuSize { @@ -903,6 +951,44 @@ export const MarkdownEditor = forwardRef } }, [selectMention]); + // Touch handling for the mention menu. We deliberately do NOT preventDefault + // on touchstart so the browser can still scroll the menu vertically; instead + // we record the start point and only treat the gesture as a selection if the + // finger lifted with negligible movement (i.e., a tap, not a scroll). + const touchStartPointRef = useRef<{ x: number; y: number } | null>(null); + const TOUCH_TAP_THRESHOLD_PX = 8; + + const handleAutocompleteTouchStart = useCallback((event: ReactTouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + touchStartPointRef.current = { x: touch.clientX, y: touch.clientY }; + }, []); + + const handleAutocompleteTouchMove = useCallback((event: ReactTouchEvent) => { + const start = touchStartPointRef.current; + if (!start) return; + const touch = event.touches[0]; + if (!touch) return; + if (Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > TOUCH_TAP_THRESHOLD_PX) { + touchStartPointRef.current = null; + } + }, []); + + const handleAutocompleteTouchEnd = useCallback(( + event: ReactTouchEvent, + option: AutocompleteOption, + ) => { + const start = touchStartPointRef.current; + touchStartPointRef.current = null; + if (!start) return; + const touch = event.changedTouches[0]; + if (!touch) return; + if (Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > TOUCH_TAP_THRESHOLD_PX) { + return; + } + handleAutocompletePress(event, option); + }, [handleAutocompletePress]); + function hasFilePayload(evt: DragEvent) { return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); } @@ -1131,26 +1217,36 @@ export const MarkdownEditor = forwardRef /> {/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */} - {mentionActive && filteredMentions.length > 0 && + {mentionActive && filteredMentions.length > 0 && mentionMenuPosition && createPortal(
{filteredMentions.map((option, i) => (