From 9d2f744725837caf8754f7fab7feb0026b77efed Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 29 Oct 2026 17:19:34 -0700 Subject: [PATCH] fix(editor): keep completion menus placed and keyboard results visible (#34992) * fix(editor): keep keyboard completion results visible * fix(editor): keep completion menus placed and keyboard results visible * fix(editor): restore link bubble anchor and keep completion menus tied to their editor Restore the link bubble's required anchorElement, keep editor clicks and refocus from dismissing completion menus (selection sync still governs), close them when the editor is hidden, and cover keyboard scrolling and outside dismissal. * fix(editor): share #34980's editor-visibility dismissal so completion menus close when their workspace is hidden but painted * fix(editor): read latest dismiss callback via useEffectEvent instead of render-time ref write --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../src/assets/rich-markdown-editor.css | 28 ++--- .../editor/RichMarkdownCompletionPopover.tsx | 69 +++++++++++ .../editor/RichMarkdownDocLinkMenu.tsx | 23 +++- .../components/editor/RichMarkdownEditor.tsx | 2 + .../editor/RichMarkdownEditorSurface.tsx | 6 + .../editor/RichMarkdownSlashMenu.test.tsx | 117 ++++++++++++++++++ .../editor/RichMarkdownSlashMenu.tsx | 23 +++- .../editor/useRichMarkdownCompletionScroll.ts | 29 +++++ 8 files changed, 265 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/components/editor/RichMarkdownCompletionPopover.tsx create mode 100644 src/renderer/src/components/editor/RichMarkdownSlashMenu.test.tsx create mode 100644 src/renderer/src/components/editor/useRichMarkdownCompletionScroll.ts diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 886ae6b9621..44c1dfe07ec 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -1297,19 +1297,12 @@ opacity: 1; } -.rich-markdown-slash-menu { - position: absolute; - z-index: 30; +.rich-markdown-slash-menu[data-slot='popover-content'] { display: flex; - width: min(300px, calc(100% - 24px)); - max-height: min(440px, calc(100vh - 120px)); + width: min(300px, calc(100vw - 16px)); + max-height: min(440px, var(--radix-popover-content-available-height)); flex-direction: column; overflow: hidden; - border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); - border-radius: 6px; - background: var(--popover); - color: var(--popover-foreground); - box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); } .rich-markdown-slash-search { @@ -1339,6 +1332,7 @@ } .rich-markdown-slash-results { + position: relative; min-height: 0; overflow-y: auto; } @@ -1399,19 +1393,13 @@ font-family: Geist, sans-serif; } -.rich-markdown-doc-link-menu { - position: absolute; - z-index: 30; +.rich-markdown-doc-link-menu[data-slot='popover-content'] { + position: relative; display: flex; - width: min(360px, calc(100% - 24px)); - max-height: 320px; + width: min(360px, calc(100vw - 16px)); + max-height: min(320px, var(--radix-popover-content-available-height)); flex-direction: column; overflow-y: auto; - border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); - border-radius: 12px; - background: color-mix(in srgb, var(--background) 92%, transparent); - box-shadow: 0 18px 44px rgba(0, 0, 0, 0.2); - backdrop-filter: blur(16px); } .rich-markdown-doc-link-item { diff --git a/src/renderer/src/components/editor/RichMarkdownCompletionPopover.tsx b/src/renderer/src/components/editor/RichMarkdownCompletionPopover.tsx new file mode 100644 index 00000000000..79368c534bd --- /dev/null +++ b/src/renderer/src/components/editor/RichMarkdownCompletionPopover.tsx @@ -0,0 +1,69 @@ +import { useMemo, type ReactNode, type Ref } from 'react' +import type { Editor } from '@tiptap/react' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { useDismissWhenEditorHidden } from './use-dismiss-when-editor-hidden' + +type RichMarkdownCompletionPopoverProps = { + editor: Editor | null + position: number + onClose: () => void + className: string + role: 'dialog' | 'listbox' + ariaLabel: string + viewportRef?: Ref + children: ReactNode +} + +export function RichMarkdownCompletionPopover({ + editor, + position, + onClose, + className, + role, + ariaLabel, + viewportRef, + children +}: RichMarkdownCompletionPopoverProps): React.JSX.Element { + const editorDom = editor && !editor.isDestroyed ? editor.view.dom : null + useDismissWhenEditorHidden(editorDom, onClose) + const virtualRef = useMemo( + () => ({ + current: { + contextElement: editor && !editor.isDestroyed ? editor.view.dom : undefined, + getBoundingClientRect: () => { + if (!editor || editor.isDestroyed) { + return new DOMRect() + } + const cursor = Math.max(0, Math.min(position, editor.state.doc.content.size)) + const coords = editor.view.coordsAtPos(cursor) + return new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top) + } + } + }), + [editor, position] + ) + + return ( + !open && onClose()}> + + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + // Editor clicks and refocus keep the menu; selection sync decides whether it still applies. + onInteractOutside={(event) => { + if (event.target instanceof Node && editorDom?.contains(event.target)) { + event.preventDefault() + } + }} + > +
+ {children} +
+
+
+ ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx b/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx index 501b6d01033..21654e41f1f 100644 --- a/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx +++ b/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx @@ -4,6 +4,8 @@ import { cn } from '@/lib/utils' import { commitRow } from './rich-markdown-commands' import type { DocLinkMenuRow, DocLinkMenuState } from './rich-markdown-commands' import { translate } from '@/i18n/i18n' +import { RichMarkdownCompletionPopover } from './RichMarkdownCompletionPopover' +import { useRichMarkdownCompletionScroll } from './useRichMarkdownCompletionScroll' type RichMarkdownDocLinkMenuProps = { editor: Editor | null @@ -11,6 +13,7 @@ type RichMarkdownDocLinkMenuProps = { rows: DocLinkMenuRow[] totalMatches: number selectedIndex: number + onClose: () => void } export function RichMarkdownDocLinkMenu({ @@ -18,15 +21,20 @@ export function RichMarkdownDocLinkMenu({ menu, rows, totalMatches, - selectedIndex + selectedIndex, + onClose }: RichMarkdownDocLinkMenuProps): React.JSX.Element { + const { viewportRef, activeItemRef } = useRichMarkdownCompletionScroll(selectedIndex, rows) const overflow = totalMatches > rows.length return ( -
event.preventDefault()} @@ -79,6 +90,6 @@ export function RichMarkdownDocLinkMenu({ '↑↓ navigate  ↵ select  esc dismiss' )}
- + ) } diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 6a1d1855ce3..ffd99a13401 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -377,6 +377,8 @@ export default function RichMarkdownEditor({ onImagePick={handleLocalImagePick} onEmojiPick={menu.openEmojiMenu} onCloseEmojiMenu={() => menu.setEmojiMenu(null)} + onCloseSlashMenu={() => menu.setSlashMenu(null)} + onCloseDocLinkMenu={() => menu.setDocLinkMenu(null)} onOpenAnnotationPopover={review.openAnnotationPopover} onCancelAnnotationPopover={() => { review.setAnnotationPopover(null) diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index 0d00df21eea..66d611a4073 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -110,6 +110,8 @@ type RichMarkdownEditorSurfaceProps = { onImagePick: () => void onEmojiPick: (menu: SlashMenuState) => void onCloseEmojiMenu: () => void + onCloseSlashMenu: () => void + onCloseDocLinkMenu: () => void onOpenAnnotationPopover: () => void onCancelAnnotationPopover: () => void onSubmitAnnotation: (body: string) => Promise @@ -170,6 +172,8 @@ export function RichMarkdownEditorSurface({ onImagePick, onEmojiPick, onCloseEmojiMenu, + onCloseSlashMenu, + onCloseDocLinkMenu, onOpenAnnotationPopover, onCancelAnnotationPopover, onSubmitAnnotation, @@ -291,6 +295,7 @@ export function RichMarkdownEditorSurface({ selectedIndex={selectedCommandIndex} onImagePick={onImagePick} onEmojiPick={() => onEmojiPick(slashMenu)} + onClose={onCloseSlashMenu} /> ) : null} {emojiMenu ? ( @@ -309,6 +314,7 @@ export function RichMarkdownEditorSurface({ rows={docLinkRows} totalMatches={docLinkTotalMatches} selectedIndex={selectedDocLinkIndex} + onClose={onCloseDocLinkMenu} /> ) : null} ({ translate: (_key: string, fallback: string) => fallback })) + +const ROW_HEIGHT = 30 +const VIEWPORT_HEIGHT = 90 + +let editor: Editor +let editorElement: HTMLDivElement +let host: HTMLDivElement +let reactRoot: Root + +beforeEach(() => { + editorElement = document.createElement('div') + document.body.append(editorElement) + editor = new Editor({ element: editorElement, extensions: [StarterKit], content: '

/

' }) + vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ left: 0, right: 0, top: 0, bottom: 0 }) + host = document.createElement('div') + document.body.append(host) + reactRoot = createRoot(host) +}) + +afterEach(() => { + act(() => reactRoot.unmount()) + editor.destroy() + document.body.innerHTML = '' + vi.restoreAllMocks() +}) + +function renderMenu(selectedIndex: number, onClose: () => void): void { + act(() => + reactRoot.render( + + ) + ) +} + +function layOutResults(): HTMLElement { + const viewport = document.querySelector('.rich-markdown-slash-results') + if (!viewport) { + throw new Error('Missing slash results') + } + Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: VIEWPORT_HEIGHT }) + viewport.querySelectorAll('[role="option"]').forEach((option, index) => { + Object.defineProperty(option, 'offsetTop', { configurable: true, value: index * ROW_HEIGHT }) + Object.defineProperty(option, 'offsetHeight', { configurable: true, value: ROW_HEIGHT }) + }) + return viewport +} + +it('scrolls keyboard selection into view in both directions', () => { + const onClose = vi.fn() + renderMenu(0, onClose) + const viewport = layOutResults() + + renderMenu(10, onClose) + expect(viewport.scrollTop).toBe(11 * ROW_HEIGHT - VIEWPORT_HEIGHT) + + renderMenu(2, onClose) + expect(viewport.scrollTop).toBe(2 * ROW_HEIGHT) +}) + +it('stays open for editor clicks and closes for presses outside the editor', async () => { + const onClose = vi.fn() + renderMenu(0, onClose) + // Radix arms its outside-pointer listener on the next task after mount. + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + const press = (target: Element): void => { + act(() => { + target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) + target.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })) + }) + } + + press(editor.view.dom) + expect(onClose).not.toHaveBeenCalled() + + const outside = document.createElement('button') + document.body.append(outside) + press(outside) + expect(onClose).toHaveBeenCalledTimes(1) +}) + +it('closes when its workspace is hidden while still painted (opacity-0 + inert)', async () => { + const workspace = document.createElement('div') + document.body.append(workspace) + workspace.append(editorElement) + const onClose = vi.fn() + renderMenu(0, onClose) + + workspace.className = 'absolute inset-0 flex' + await act(() => Promise.resolve()) + expect(onClose).not.toHaveBeenCalled() + + workspace.inert = true + workspace.setAttribute('aria-hidden', 'true') + await act(() => Promise.resolve()) + expect(onClose).toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/editor/RichMarkdownSlashMenu.tsx b/src/renderer/src/components/editor/RichMarkdownSlashMenu.tsx index 47438a00308..9065060e2f2 100644 --- a/src/renderer/src/components/editor/RichMarkdownSlashMenu.tsx +++ b/src/renderer/src/components/editor/RichMarkdownSlashMenu.tsx @@ -6,6 +6,8 @@ import { runSlashCommand } from './rich-markdown-slash-commands' import type { SlashCommand, SlashMenuState } from './rich-markdown-slash-commands' import type { SlashCommandGroup } from './rich-markdown-slash-command-primitives' import { translate } from '@/i18n/i18n' +import { RichMarkdownCompletionPopover } from './RichMarkdownCompletionPopover' +import { useRichMarkdownCompletionScroll } from './useRichMarkdownCompletionScroll' function slashCommandGroupLabel(group: SlashCommandGroup): string { switch (group) { @@ -37,6 +39,7 @@ type RichMarkdownSlashMenuProps = { selectedIndex: number onImagePick: () => void onEmojiPick: () => void + onClose: () => void } export function RichMarkdownSlashMenu({ @@ -45,16 +48,23 @@ export function RichMarkdownSlashMenu({ filteredCommands, selectedIndex, onImagePick, - onEmojiPick + onEmojiPick, + onClose }: RichMarkdownSlashMenuProps): React.JSX.Element { + const { viewportRef, activeItemRef } = useRichMarkdownCompletionScroll( + selectedIndex, + filteredCommands + ) let currentGroup: SlashCommand['group'] | null = null return ( -
-
+
{filteredCommands.length === 0 ? (
{translate( @@ -95,6 +105,7 @@ export function RichMarkdownSlashMenu({
) : null}
+ ) } diff --git a/src/renderer/src/components/editor/useRichMarkdownCompletionScroll.ts b/src/renderer/src/components/editor/useRichMarkdownCompletionScroll.ts new file mode 100644 index 00000000000..da7969fa758 --- /dev/null +++ b/src/renderer/src/components/editor/useRichMarkdownCompletionScroll.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef, type RefObject } from 'react' + +export function useRichMarkdownCompletionScroll( + selectedIndex: number, + rows: readonly unknown[] +): { + viewportRef: RefObject + activeItemRef: RefObject +} { + const viewportRef = useRef(null) + const activeItemRef = useRef(null) + + useEffect(() => { + const viewport = viewportRef.current + const item = activeItemRef.current + if (!viewport || !item) { + return + } + const top = item.offsetTop + const bottom = top + item.offsetHeight + if (top < viewport.scrollTop) { + viewport.scrollTop = top + } else if (bottom > viewport.scrollTop + viewport.clientHeight) { + viewport.scrollTop = bottom - viewport.clientHeight + } + }, [selectedIndex, rows]) + + return { viewportRef, activeItemRef } +}