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 } +}