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) => (