mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<HTMLDivElement>
|
||||
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 (
|
||||
<Popover open onOpenChange={(open) => !open && onClose()}>
|
||||
<PopoverAnchor virtualRef={virtualRef} />
|
||||
<PopoverContent
|
||||
asChild
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={8}
|
||||
onOpenAutoFocus={(event) => 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()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div ref={viewportRef} className={className} role={role} aria-label={ariaLabel}>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className="rich-markdown-doc-link-menu"
|
||||
style={{ left: menu.left, top: menu.top }}
|
||||
<RichMarkdownCompletionPopover
|
||||
editor={editor}
|
||||
position={menu.to}
|
||||
onClose={onClose}
|
||||
viewportRef={viewportRef}
|
||||
className="rich-markdown-doc-link-menu scrollbar-sleek"
|
||||
role="listbox"
|
||||
aria-label={translate(
|
||||
ariaLabel={translate(
|
||||
'auto.components.editor.RichMarkdownDocLinkMenu.0e8489bc11',
|
||||
'Markdown document links'
|
||||
)}
|
||||
@@ -44,7 +52,10 @@ export function RichMarkdownDocLinkMenu({
|
||||
return (
|
||||
<button
|
||||
key={rowKey}
|
||||
ref={index === selectedIndex ? activeItemRef : undefined}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
className={cn('rich-markdown-doc-link-item', index === selectedIndex && 'is-active')}
|
||||
// Prevent blur before click so the commit transaction retains its editor selection.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
@@ -79,6 +90,6 @@ export function RichMarkdownDocLinkMenu({
|
||||
'↑↓ navigate ↵ select esc dismiss'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</RichMarkdownCompletionPopover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<void>
|
||||
@@ -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}
|
||||
<RichMarkdownAnnotationOverlay
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { RichMarkdownSlashMenu } from './RichMarkdownSlashMenu'
|
||||
import { slashCommands } from './rich-markdown-slash-commands'
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({ 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: '<p>/</p>' })
|
||||
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(
|
||||
<RichMarkdownSlashMenu
|
||||
editor={editor}
|
||||
slashMenu={{ query: '', from: 1, to: 2, left: 0, top: 0 }}
|
||||
filteredCommands={slashCommands}
|
||||
selectedIndex={selectedIndex}
|
||||
onImagePick={vi.fn()}
|
||||
onEmojiPick={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function layOutResults(): HTMLElement {
|
||||
const viewport = document.querySelector<HTMLElement>('.rich-markdown-slash-results')
|
||||
if (!viewport) {
|
||||
throw new Error('Missing slash results')
|
||||
}
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: VIEWPORT_HEIGHT })
|
||||
viewport.querySelectorAll<HTMLElement>('[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()
|
||||
})
|
||||
@@ -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 (
|
||||
<div
|
||||
<RichMarkdownCompletionPopover
|
||||
editor={editor}
|
||||
position={slashMenu.to}
|
||||
onClose={onClose}
|
||||
className="rich-markdown-slash-menu"
|
||||
style={{ left: slashMenu.left, top: slashMenu.top }}
|
||||
role="dialog"
|
||||
aria-label={translate(
|
||||
ariaLabel={translate(
|
||||
'auto.components.editor.RichMarkdownSlashMenu.2e0400b958',
|
||||
'Slash commands'
|
||||
)}
|
||||
@@ -75,7 +85,7 @@ export function RichMarkdownSlashMenu({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rich-markdown-slash-results scrollbar-sleek" role="listbox">
|
||||
<div ref={viewportRef} className="rich-markdown-slash-results scrollbar-sleek" role="listbox">
|
||||
{filteredCommands.length === 0 ? (
|
||||
<div className="rich-markdown-slash-empty">
|
||||
{translate(
|
||||
@@ -95,6 +105,7 @@ export function RichMarkdownSlashMenu({
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
ref={index === selectedIndex ? activeItemRef : undefined}
|
||||
type="button"
|
||||
title={command.description}
|
||||
role="option"
|
||||
@@ -123,6 +134,6 @@ export function RichMarkdownSlashMenu({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</RichMarkdownCompletionPopover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
export function useRichMarkdownCompletionScroll(
|
||||
selectedIndex: number,
|
||||
rows: readonly unknown[]
|
||||
): {
|
||||
viewportRef: RefObject<HTMLDivElement | null>
|
||||
activeItemRef: RefObject<HTMLButtonElement | null>
|
||||
} {
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const activeItemRef = useRef<HTMLButtonElement>(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 }
|
||||
}
|
||||
Reference in New Issue
Block a user