mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
Fix Markdown Find editing without moving the caret or viewport (#25144)
* Fix Markdown Find editing without changing the caret or viewport * Preserve Markdown selections across search focus and stale updates
This commit is contained in:
@@ -20,6 +20,7 @@ import type { MarkdownReviewNote } from '@/lib/markdown-review-notes'
|
||||
import type { RichMarkdownAnnotationTarget } from './rich-markdown-review-annotations'
|
||||
import type { RichMarkdownReviewNotePosition } from './rich-markdown-review-note-layout'
|
||||
import type { DiffComment } from '../../../../shared/diff-comment-types'
|
||||
import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus'
|
||||
|
||||
function shouldFocusEmptyEditorFromSurfaceClick(
|
||||
event: React.MouseEvent<HTMLDivElement>,
|
||||
@@ -209,6 +210,7 @@ export function RichMarkdownEditorSurface({
|
||||
// Image layout must not anchor-scroll over the restored tab position.
|
||||
className="relative h-full overflow-auto scrollbar-editor [overflow-anchor:none]"
|
||||
onMouseDown={(event) => {
|
||||
focusRichMarkdownEditorFromSearch(event.nativeEvent, editor?.view ?? null)
|
||||
if (!shouldFocusEmptyEditorFromSurfaceClick(event, editor)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { TableKit } from '@tiptap/extension-table'
|
||||
import { CellSelection } from '@tiptap/pm/tables'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus'
|
||||
|
||||
function findTextPosition(editor: Editor, text: string): number {
|
||||
let position: number | null = null
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text === text) {
|
||||
position = pos
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (position === null) {
|
||||
throw new Error(`Expected editor text: ${text}`)
|
||||
}
|
||||
return position
|
||||
}
|
||||
|
||||
function createSurface(content: string) {
|
||||
const root = document.createElement('div')
|
||||
root.className = 'rich-markdown-editor-shell'
|
||||
document.body.append(root)
|
||||
const editor = new Editor({ extensions: [StarterKit, TableKit], content })
|
||||
root.append(editor.view.dom)
|
||||
const search = document.createElement('div')
|
||||
search.className = 'rich-markdown-search'
|
||||
const input = document.createElement('input')
|
||||
search.append(input)
|
||||
root.append(search)
|
||||
root.addEventListener('mousedown', (event) => {
|
||||
if (event instanceof MouseEvent) {
|
||||
focusRichMarkdownEditorFromSearch(event, editor.view)
|
||||
}
|
||||
})
|
||||
return { editor, input }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('Find focus with editor-owned selection', () => {
|
||||
it('restores the remembered caret before Shift click when Find owns the native selection', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, input } = createSurface('<p>first</p><p>lower caret</p>')
|
||||
const caret = findTextPosition(editor, 'lower caret') + 6
|
||||
const updates = vi.fn()
|
||||
editor.on('update', updates)
|
||||
try {
|
||||
editor.commands.setTextSelection(caret)
|
||||
input.focus()
|
||||
document.getSelection()?.removeAllRanges()
|
||||
const paragraph = editor.view.dom.querySelector('p:last-child')
|
||||
const text = paragraph?.firstChild
|
||||
if (!paragraph || !text) {
|
||||
throw new Error('Expected lower paragraph text')
|
||||
}
|
||||
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: caret, inside: -1 })
|
||||
const originalDoc = editor.state.doc
|
||||
const selection = editor.state.selection
|
||||
const event = new MouseEvent('mousedown', {
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
paragraph.dispatchEvent(event)
|
||||
|
||||
expect(document.activeElement).toBe(editor.view.dom)
|
||||
expect(document.getSelection()?.anchorNode).toBe(text)
|
||||
expect(document.getSelection()?.anchorOffset).toBe(6)
|
||||
expect(document.getSelection()?.focusOffset).toBe(6)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
|
||||
vi.advanceTimersByTime(20)
|
||||
|
||||
expect(editor.state.selection).toBe(selection)
|
||||
expect(editor.state.doc).toBe(originalDoc)
|
||||
expect(updates).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('focuses a handled cross-cell selection and preserves its cells without marking content dirty', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, input } = createSurface(
|
||||
'<table><tbody><tr><td>first</td><td>second</td></tr></tbody></table>'
|
||||
)
|
||||
const updates = vi.fn()
|
||||
try {
|
||||
editor.commands.setTextSelection(findTextPosition(editor, 'first'))
|
||||
editor.on('update', updates)
|
||||
input.focus()
|
||||
const secondPos = findTextPosition(editor, 'second')
|
||||
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: secondPos, inside: secondPos })
|
||||
const second = editor.view.dom.querySelector('td:last-child p')
|
||||
if (!second) {
|
||||
throw new Error('Expected second table cell')
|
||||
}
|
||||
const originalDoc = editor.state.doc
|
||||
const event = new MouseEvent('mousedown', {
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
|
||||
second.dispatchEvent(event)
|
||||
|
||||
const selection = editor.state.selection
|
||||
expect(selection).toBeInstanceOf(CellSelection)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(document.activeElement).toBe(editor.view.dom)
|
||||
expect(editor.view.dom.querySelectorAll('.selectedCell')).toHaveLength(2)
|
||||
expect(editor.view.dom.classList.contains('ProseMirror-hideselection')).toBe(true)
|
||||
expect(document.getSelection()?.rangeCount).toBe(1)
|
||||
|
||||
vi.advanceTimersByTime(20)
|
||||
|
||||
expect(editor.state.selection).toBe(selection)
|
||||
expect(editor.state.doc).toBe(originalDoc)
|
||||
expect(updates).not.toHaveBeenCalled()
|
||||
expect(editor.view.dom.querySelectorAll('.selectedCell')).toHaveLength(2)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { Schema } from '@tiptap/pm/model'
|
||||
import { EditorState } from '@tiptap/pm/state'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { focusRichMarkdownEditorFromSearch } from './rich-markdown-search-focus'
|
||||
|
||||
const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'paragraph+' },
|
||||
paragraph: { content: 'text*' },
|
||||
text: {}
|
||||
}
|
||||
})
|
||||
|
||||
function createSurface() {
|
||||
const root = document.createElement('div')
|
||||
root.className = 'rich-markdown-editor-shell'
|
||||
const editorDom = document.createElement('div')
|
||||
editorDom.contentEditable = 'true'
|
||||
editorDom.tabIndex = -1
|
||||
const paragraph = document.createElement('p')
|
||||
paragraph.textContent = 'First editable paragraph'
|
||||
editorDom.append(paragraph)
|
||||
const search = document.createElement('div')
|
||||
search.className = 'rich-markdown-search'
|
||||
const findInput = document.createElement('input')
|
||||
const replaceInput = document.createElement('input')
|
||||
search.append(findInput, replaceInput)
|
||||
root.append(editorDom, search)
|
||||
document.body.append(root)
|
||||
const focus = vi.spyOn(editorDom, 'focus')
|
||||
const viewFocus = vi.fn(() => editorDom.focus({ preventScroll: true }))
|
||||
const view = { dom: editorDom, focus: viewFocus, state: EditorState.create({ schema }) }
|
||||
root.addEventListener('mousedown', (event) => {
|
||||
if (event instanceof MouseEvent) {
|
||||
focusRichMarkdownEditorFromSearch(event, view)
|
||||
}
|
||||
})
|
||||
return { root, editorDom, paragraph, findInput, replaceInput, focus, viewFocus }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
describe('rich markdown search focus handoff', () => {
|
||||
it.each(['find', 'replace'] as const)(
|
||||
'returns keyboard focus from %s without preventing native selection or requesting scroll',
|
||||
(field) => {
|
||||
const { paragraph, editorDom, findInput, replaceInput, focus, viewFocus } = createSurface()
|
||||
const input = field === 'find' ? findInput : replaceInput
|
||||
input.focus()
|
||||
const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })
|
||||
|
||||
paragraph.dispatchEvent(event)
|
||||
|
||||
expect(document.activeElement).toBe(editorDom)
|
||||
expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true })
|
||||
expect(viewFocus).not.toHaveBeenCalled()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('restores the editor selection before native Shift click handling', () => {
|
||||
const { paragraph, editorDom, findInput, viewFocus } = createSurface()
|
||||
findInput.focus()
|
||||
const event = new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
shiftKey: true
|
||||
})
|
||||
|
||||
paragraph.dispatchEvent(event)
|
||||
|
||||
expect(document.activeElement).toBe(editorDom)
|
||||
expect(viewFocus).toHaveBeenCalledExactlyOnceWith()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['button', 'input', 'textarea', 'select', 'noneditable', 'task-label'])(
|
||||
'leaves embedded %s controls in charge of focus',
|
||||
(kind) => {
|
||||
const { editorDom, findInput, focus } = createSurface()
|
||||
const control = document.createElement(
|
||||
kind === 'noneditable' ? 'div' : kind === 'task-label' ? 'label' : kind
|
||||
)
|
||||
if (kind === 'noneditable' || kind === 'task-label') {
|
||||
control.setAttribute('contenteditable', 'false')
|
||||
}
|
||||
const child = document.createElement('span')
|
||||
control.append(child)
|
||||
editorDom.append(control)
|
||||
findInput.focus()
|
||||
|
||||
child.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
|
||||
expect(document.activeElement).toBe(findInput)
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not steal focus from a different editor find widget or unrelated field', () => {
|
||||
const first = createSurface()
|
||||
const second = createSurface()
|
||||
const unrelated = document.createElement('input')
|
||||
document.body.append(unrelated)
|
||||
|
||||
for (const input of [second.findInput, unrelated]) {
|
||||
input.focus()
|
||||
first.paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
expect(document.activeElement).toBe(input)
|
||||
}
|
||||
|
||||
expect(first.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves ordinary editor clicks and blank surface clicks untouched', () => {
|
||||
const { root, editorDom, paragraph, findInput, focus } = createSurface()
|
||||
editorDom.focus()
|
||||
focus.mockClear()
|
||||
paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
findInput.focus()
|
||||
root.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(findInput)
|
||||
})
|
||||
|
||||
it('leaves canceled and non-primary presses untouched', () => {
|
||||
const { paragraph, findInput, focus } = createSurface()
|
||||
findInput.focus()
|
||||
const canceled = new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 })
|
||||
canceled.preventDefault()
|
||||
|
||||
for (const event of [
|
||||
canceled,
|
||||
new MouseEvent('mousedown', { bubbles: true, button: 1 }),
|
||||
new MouseEvent('mousedown', { bubbles: true, button: 2 })
|
||||
]) {
|
||||
paragraph.dispatchEvent(event)
|
||||
}
|
||||
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(findInput)
|
||||
})
|
||||
|
||||
it('allows editable content nested inside an unrelated noneditable ancestor', () => {
|
||||
const { root, paragraph, findInput, focus } = createSurface()
|
||||
root.setAttribute('contenteditable', 'false')
|
||||
findInput.focus()
|
||||
|
||||
paragraph.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
|
||||
expect(focus).toHaveBeenCalledExactlyOnceWith({ preventScroll: true })
|
||||
})
|
||||
|
||||
it('does nothing when the editor has not mounted', () => {
|
||||
focusRichMarkdownEditorFromSearch(new MouseEvent('mousedown', { button: 0 }), null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import { CellSelection } from '@tiptap/pm/tables'
|
||||
|
||||
export function focusRichMarkdownEditorFromSearch(
|
||||
event: MouseEvent,
|
||||
view: Pick<EditorView, 'dom' | 'focus' | 'state'> | null
|
||||
): void {
|
||||
if (
|
||||
!view ||
|
||||
event.button !== 0 ||
|
||||
(event.defaultPrevented && !(view.state.selection instanceof CellSelection))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const editorDom = view.dom
|
||||
const target = event.target
|
||||
if (!(target instanceof Element) || !editorDom.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
const root = editorDom.closest('.rich-markdown-editor-shell')
|
||||
const activeElement = editorDom.ownerDocument.activeElement
|
||||
if (
|
||||
!root ||
|
||||
!activeElement?.closest('.rich-markdown-search') ||
|
||||
activeElement.closest('.rich-markdown-editor-shell') !== root
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const control = target.closest('button, input, textarea, select, [contenteditable="false"]')
|
||||
if (control && editorDom.contains(control)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey || event.defaultPrevented) {
|
||||
// Shift extends the current selection; handled cell selection has no browser default.
|
||||
view.focus()
|
||||
} else {
|
||||
// Native focus preserves the browser's upcoming click or drag selection.
|
||||
editorDom.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { StrictMode, useLayoutEffect, type ReactNode } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (select: (state: unknown) => unknown) => select({ keybindings: {} })
|
||||
}))
|
||||
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
function mountSearch(content = '<p>beta beta</p><p>Edit here</p>', strict = false) {
|
||||
vi.useFakeTimers()
|
||||
const editor = new Editor({ extensions: [StarterKit], content })
|
||||
const root = document.createElement('div')
|
||||
root.className = 'rich-markdown-editor-shell'
|
||||
const searchBar = document.createElement('div')
|
||||
searchBar.className = 'rich-markdown-search'
|
||||
const input = document.createElement('input')
|
||||
const otherControl = document.createElement('input')
|
||||
searchBar.append(input)
|
||||
root.append(editor.view.dom, searchBar)
|
||||
document.body.append(root, otherControl)
|
||||
const scrollContainer = document.createElement('div')
|
||||
const rootRef = { current: root }
|
||||
const scrollContainerRef = { current: scrollContainer }
|
||||
const beforePassiveRef: { current: (() => void) | null } = { current: null }
|
||||
const scrollTo = vi.spyOn(scrollContainer, 'scrollTo')
|
||||
vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ top: 20, bottom: 40, left: 0, right: 0 })
|
||||
const initialProps: { currentEditor: Editor | null } = { currentEditor: editor }
|
||||
const hook = renderHook(
|
||||
({ currentEditor }: { currentEditor: Editor | null }) => {
|
||||
const result = useRichMarkdownSearch({ editor: currentEditor, rootRef, scrollContainerRef })
|
||||
result.searchState.searchInputRef.current = input
|
||||
useLayoutEffect(() => {
|
||||
const callback = beforePassiveRef.current
|
||||
beforePassiveRef.current = null
|
||||
callback?.()
|
||||
})
|
||||
return result
|
||||
},
|
||||
{
|
||||
initialProps,
|
||||
wrapper: strict
|
||||
? ({ children }: { children: ReactNode }) => <StrictMode>{children}</StrictMode>
|
||||
: undefined
|
||||
}
|
||||
)
|
||||
cleanups.push(() => {
|
||||
hook.unmount()
|
||||
editor.destroy()
|
||||
root.remove()
|
||||
otherControl.remove()
|
||||
})
|
||||
act(() => hook.result.current.openSearch())
|
||||
const query = (value: string) => {
|
||||
act(() => hook.result.current.searchActions.setSearchQuery(value))
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
}
|
||||
const selectedText = () =>
|
||||
editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)
|
||||
const documentCaret = () => {
|
||||
act(() => {
|
||||
editor.view.dom.focus({ preventScroll: true })
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
|
||||
})
|
||||
}
|
||||
return {
|
||||
editor,
|
||||
root,
|
||||
hook,
|
||||
input,
|
||||
otherControl,
|
||||
scrollTo,
|
||||
query,
|
||||
selectedText,
|
||||
documentCaret,
|
||||
beforePassiveRef
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length > 0) {
|
||||
cleanups.pop()?.()
|
||||
}
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('rich Markdown search adversarial interactions', () => {
|
||||
it('does not revive a delayed search after a document click and another control focus', () => {
|
||||
const { editor, hook, otherControl, query, documentCaret, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('Edit'))
|
||||
documentCaret()
|
||||
const selection = editor.state.selection
|
||||
act(() => otherControl.focus())
|
||||
scrollTo.mockClear()
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(editor.state.selection.eq(selection)).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(otherControl)
|
||||
})
|
||||
|
||||
it('continues typing at the document caret through case and whole-word changes', () => {
|
||||
const { editor, hook, query, documentCaret, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
documentCaret()
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.toggleMatchCase())
|
||||
act(() => hook.result.current.searchActions.toggleWholeWord())
|
||||
act(() => editor.commands.insertContent('XY'))
|
||||
expect(editor.getText()).toBe('beta beta\n\nEdit hereXY')
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets explicit search navigation select the only match after document editing', () => {
|
||||
const { editor, hook, query, documentCaret, selectedText, scrollTo } = mountSearch(
|
||||
'<p>beta</p><p>Edit here</p>'
|
||||
)
|
||||
query('beta')
|
||||
documentCaret()
|
||||
act(() => editor.commands.insertContent('X'))
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
expect(selectedText()).toBe('beta')
|
||||
expect(scrollTo).toHaveBeenCalledOnce()
|
||||
expect(editor.isFocused).toBe(true)
|
||||
})
|
||||
|
||||
it('advances past replacement text which still contains the search query', () => {
|
||||
const { editor, hook, query, selectedText } = mountSearch()
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.setReplaceQuery('betaX'))
|
||||
act(() => hook.result.current.searchActions.replaceCurrentMatch())
|
||||
expect(editor.getText()).toBe('betaX beta\n\nEdit here')
|
||||
expect(selectedText()).toBe('beta')
|
||||
expect(editor.state.selection.from).toBe(7)
|
||||
act(() => hook.result.current.searchActions.replaceCurrentMatch())
|
||||
expect(editor.getText()).toBe('betaX betaX\n\nEdit here')
|
||||
})
|
||||
|
||||
it('does not move the document caret when Replace All retains matching text', () => {
|
||||
const { editor, hook, query, documentCaret, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.setReplaceQuery('betaX'))
|
||||
documentCaret()
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.replaceAllMatches())
|
||||
expect(editor.getText()).toBe('betaX betaX\n\nEdit here')
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clamps navigation after external edits remove the active match', () => {
|
||||
const { editor, hook, query, selectedText } = mountSearch('<p>beta beta beta</p>')
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
act(() => editor.commands.setContent('<p>beta</p>'))
|
||||
expect(hook.result.current.searchState.activeMatchIndex).toBe(0)
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
expect(selectedText()).toBe('beta')
|
||||
})
|
||||
|
||||
it('keeps decorations fresh without moving selection through batched document edits', () => {
|
||||
const { editor, hook, query, documentCaret, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
documentCaret()
|
||||
scrollTo.mockClear()
|
||||
act(() => {
|
||||
editor.commands.insertContent(' beta')
|
||||
editor.commands.insertContent(' beta')
|
||||
})
|
||||
expect(hook.result.current.searchState.matchCount).toBe(4)
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1)
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not dispatch stale match positions when the document changes before passive effects', () => {
|
||||
const { editor, hook, beforePassiveRef } = mountSearch('<p>beta</p><p>target</p>')
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('target'))
|
||||
beforePassiveRef.current = () => editor.commands.setContent('<p>x</p>')
|
||||
expect(() => act(() => vi.advanceTimersByTime(150))).not.toThrow()
|
||||
expect(editor.getText()).toBe('x')
|
||||
expect(hook.result.current.searchState.matchCount).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels a pending query when the input is cleared', () => {
|
||||
const { editor, hook, query, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
const selection = editor.state.selection
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('Edit'))
|
||||
act(() => hook.result.current.searchActions.setSearchQuery(''))
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(0)
|
||||
expect(editor.state.selection.eq(selection)).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses refreshed positions when the document changes before search navigation commits', () => {
|
||||
const { editor, hook, beforePassiveRef, selectedText, scrollTo } = mountSearch(
|
||||
'<p>beta</p><p>target</p>'
|
||||
)
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('target'))
|
||||
beforePassiveRef.current = () => editor.commands.setContent('<p>prefix prefix target</p>')
|
||||
scrollTo.mockClear()
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(1)
|
||||
expect(selectedText()).toBe('target')
|
||||
expect(editor.state.selection.from).toBe(15)
|
||||
expect(scrollTo).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('moves Next relative to the clamped active match after several matches disappear', () => {
|
||||
const { editor, hook, query } = mountSearch('<p>beta beta beta beta beta</p>')
|
||||
query('beta')
|
||||
for (let index = 0; index < 4; index++) {
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
}
|
||||
expect(hook.result.current.searchState.activeMatchIndex).toBe(4)
|
||||
act(() => editor.commands.setContent('<p>beta beta beta</p>'))
|
||||
expect(hook.result.current.searchState.activeMatchIndex).toBe(0)
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
expect(hook.result.current.searchState.activeMatchIndex).toBe(1)
|
||||
expect(editor.state.selection.from).toBe(6)
|
||||
})
|
||||
|
||||
it('does not navigate to an old query when replacing during the debounce window', () => {
|
||||
const { editor, hook, query, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('Edit'))
|
||||
act(() => hook.result.current.searchActions.setReplaceQuery('changed'))
|
||||
act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1))
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.replaceCurrentMatch())
|
||||
expect(editor.getText()).toBe('beta beta\n\nchanged here')
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('survives strict effect replay, null editor, and a fresh editor instance', () => {
|
||||
const { editor, hook, input, query, documentCaret, scrollTo } = mountSearch(undefined, true)
|
||||
query('beta')
|
||||
documentCaret()
|
||||
act(() => editor.commands.insertContent('X'))
|
||||
act(() => input.focus())
|
||||
act(() => hook.rerender({ currentEditor: null }))
|
||||
const replacement = new Editor({ extensions: [StarterKit], content: '<p>beta</p>' })
|
||||
vi.spyOn(replacement.view, 'coordsAtPos').mockReturnValue({
|
||||
top: 20,
|
||||
bottom: 40,
|
||||
left: 0,
|
||||
right: 0
|
||||
})
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.rerender({ currentEditor: replacement }))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(1)
|
||||
expect(
|
||||
replacement.state.doc.textBetween(
|
||||
replacement.state.selection.from,
|
||||
replacement.state.selection.to
|
||||
)
|
||||
).toBe('beta')
|
||||
expect(scrollTo).toHaveBeenCalledOnce()
|
||||
act(() => hook.rerender({ currentEditor: null }))
|
||||
replacement.destroy()
|
||||
})
|
||||
|
||||
it('survives a debounce completing after its editor has been destroyed', () => {
|
||||
const { editor, hook } = mountSearch()
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('beta'))
|
||||
act(() => editor.destroy())
|
||||
expect(() => act(() => vi.advanceTimersByTime(150))).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not replay an old Next request into a replacement editor after focus leaves Find', () => {
|
||||
const { editor, hook, root, query, documentCaret, scrollTo } = mountSearch()
|
||||
query('beta')
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
documentCaret()
|
||||
act(() => hook.rerender({ currentEditor: null }))
|
||||
editor.view.dom.remove()
|
||||
const replacement = new Editor({ extensions: [StarterKit], content: '<p>beta</p>' })
|
||||
root.prepend(replacement.view.dom)
|
||||
vi.spyOn(replacement.view, 'coordsAtPos').mockReturnValue({
|
||||
top: 20,
|
||||
bottom: 40,
|
||||
left: 0,
|
||||
right: 0
|
||||
})
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.rerender({ currentEditor: replacement }))
|
||||
expect(replacement.state.selection.empty).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
act(() => hook.rerender({ currentEditor: null }))
|
||||
replacement.destroy()
|
||||
})
|
||||
|
||||
it('survives the editor being destroyed by a selection-update listener', () => {
|
||||
const { editor, query } = mountSearch()
|
||||
editor.on('selectionUpdate', () => editor.destroy())
|
||||
expect(() => query('beta')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useRichMarkdownSearch } from './useRichMarkdownSearch'
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (select: (state: unknown) => unknown) => select({ keybindings: {} })
|
||||
}))
|
||||
|
||||
function mountSearch() {
|
||||
const editor = new Editor({
|
||||
extensions: [StarterKit],
|
||||
content: '<p>beta beta</p><p>Edit here</p>'
|
||||
})
|
||||
const scrollContainer = document.createElement('div')
|
||||
const scrollTo = vi.spyOn(scrollContainer, 'scrollTo')
|
||||
vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ top: 20, bottom: 40, left: 0, right: 0 })
|
||||
const root = document.createElement('div')
|
||||
root.className = 'rich-markdown-editor-shell'
|
||||
const search = document.createElement('div')
|
||||
search.className = 'rich-markdown-search'
|
||||
const input = document.createElement('input')
|
||||
search.append(input)
|
||||
root.append(editor.view.dom)
|
||||
root.append(search)
|
||||
document.body.append(root)
|
||||
const hook = renderHook(() => {
|
||||
const result = useRichMarkdownSearch({
|
||||
editor,
|
||||
rootRef: { current: root },
|
||||
scrollContainerRef: { current: scrollContainer }
|
||||
})
|
||||
result.searchState.searchInputRef.current = input
|
||||
return result
|
||||
})
|
||||
act(() => hook.result.current.openSearch())
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('beta'))
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
const dispose = () => {
|
||||
hook.unmount()
|
||||
editor.destroy()
|
||||
root.remove()
|
||||
}
|
||||
return { editor, hook, scrollTo, dispose }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('rich markdown editing with Find open', () => {
|
||||
it('updates highlights without moving the typing caret or scrolling to a match', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, hook, scrollTo, dispose } = mountSearch()
|
||||
try {
|
||||
const insertion = editor.state.doc.content.size - 1
|
||||
act(() => editor.commands.setTextSelection(insertion))
|
||||
scrollTo.mockClear()
|
||||
act(() => editor.commands.insertContent('X'))
|
||||
act(() => editor.commands.insertContent('Y'))
|
||||
expect(editor.getText()).toBe('beta beta\n\nEdit hereXY')
|
||||
expect(editor.state.selection.from).toBe(insertion + 2)
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(hook.result.current.searchState.matchCount).toBe(2)
|
||||
expect(hook.result.current.searchState.isSearchOpen).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
act(() => editor.commands.undo())
|
||||
expect(editor.getText()).toBe('beta beta\n\nEdit here')
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves selection when edits remove every match and later restore one', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, hook, scrollTo, dispose } = mountSearch()
|
||||
try {
|
||||
scrollTo.mockClear()
|
||||
act(() => editor.commands.setContent('<p>No results</p><p>End</p>'))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(0)
|
||||
act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1))
|
||||
act(() => editor.commands.insertContent(' beta'))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(1)
|
||||
expect(editor.state.selection.empty).toBe(true)
|
||||
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('still navigates on Next with only one match, and advances after Replace', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, hook, scrollTo, dispose } = mountSearch()
|
||||
try {
|
||||
act(() => hook.result.current.searchActions.setReplaceQuery('changed'))
|
||||
act(() => hook.result.current.searchActions.replaceCurrentMatch())
|
||||
expect(editor.getText()).toBe('changed beta\n\nEdit here')
|
||||
expect(hook.result.current.searchState.matchCount).toBe(1)
|
||||
expect(
|
||||
editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)
|
||||
).toBe('beta')
|
||||
act(() => editor.commands.setTextSelection(editor.state.doc.content.size - 1))
|
||||
scrollTo.mockClear()
|
||||
act(() => hook.result.current.searchActions.moveToMatch(1))
|
||||
expect(
|
||||
editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to)
|
||||
).toBe('beta')
|
||||
expect(scrollTo).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not apply delayed search navigation after focus returns to the document', () => {
|
||||
vi.useFakeTimers()
|
||||
const { editor, hook, scrollTo, dispose } = mountSearch()
|
||||
try {
|
||||
act(() => hook.result.current.searchActions.setSearchQuery('Edit'))
|
||||
act(() => {
|
||||
editor.view.dom.focus({ preventScroll: true })
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
|
||||
})
|
||||
expect(editor.isFocused).toBe(true)
|
||||
const selection = editor.state.selection
|
||||
scrollTo.mockClear()
|
||||
act(() => vi.advanceTimersByTime(150))
|
||||
expect(hook.result.current.searchState.matchCount).toBe(1)
|
||||
expect(editor.state.selection.eq(selection)).toBe(true)
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
richMarkdownSearchPluginKey
|
||||
} from './rich-markdown-search'
|
||||
import { createRichMarkdownSearchMatchesCache } from './rich-markdown-search-matches-cache'
|
||||
import { useRichMarkdownSearchHighlights } from './useRichMarkdownSearchHighlights'
|
||||
|
||||
export function useRichMarkdownSearch({
|
||||
editor,
|
||||
@@ -42,7 +42,8 @@ export function useRichMarkdownSearch({
|
||||
const [matchCase, setMatchCase] = useState(false)
|
||||
const [wholeWord, setWholeWord] = useState(false)
|
||||
const [rawActiveMatchIndex, setRawActiveMatchIndex] = useState(-1)
|
||||
const [searchRevision, setSearchRevision] = useState(0)
|
||||
const [navigationRequest, setNavigationRequest] = useState({ revision: 0, selectMatch: false })
|
||||
const [, setSearchRevision] = useState(0)
|
||||
// Why: debouncing the query that drives match computation prevents the
|
||||
// expensive full-doc walk from running on every keystroke — the old
|
||||
// un-debounced path froze the main thread on large documents.
|
||||
@@ -59,24 +60,24 @@ export function useRichMarkdownSearch({
|
||||
const searchRequestQuery = isMarkdownPreviewSearchQueryTooLarge(debouncedQuery)
|
||||
? ''
|
||||
: debouncedQuery
|
||||
const searchDocument = editor && !editor.isDestroyed ? editor.state.doc : null
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!editor || !isSearchOpen || !searchRequestQuery) {
|
||||
if (!searchDocument || !isSearchOpen || !searchRequestQuery) {
|
||||
return []
|
||||
}
|
||||
return findMatches(editor.state.doc, searchRequestQuery, {
|
||||
return findMatches(searchDocument, searchRequestQuery, {
|
||||
matchCase,
|
||||
wholeWord
|
||||
})
|
||||
// searchRevision is bumped on ProseMirror doc edits to trigger recomputation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [editor, findMatches, isSearchOpen, searchRequestQuery, searchRevision, matchCase, wholeWord])
|
||||
}, [findMatches, isSearchOpen, searchRequestQuery, searchDocument, matchCase, wholeWord])
|
||||
|
||||
const matchCount = matches.length
|
||||
|
||||
const getLiveMatches = useCallback(() => {
|
||||
if (
|
||||
!editor ||
|
||||
editor.isDestroyed ||
|
||||
!isSearchOpen ||
|
||||
!searchQuery ||
|
||||
isMarkdownPreviewSearchQueryTooLarge(searchQuery)
|
||||
@@ -175,10 +176,14 @@ export function useRichMarkdownSearch({
|
||||
if (!match || liveMatches.some((candidate) => candidate.touchesReadOnlyAtom)) {
|
||||
return
|
||||
}
|
||||
// Why: removing the active match shifts the next match into the same index,
|
||||
// so leaving rawActiveMatchIndex untouched advances to it after recompute.
|
||||
replaceRange(match.from, match.to)
|
||||
}, [activeMatchIndex, getLiveMatches, replaceRange])
|
||||
// Skip matches inside the replacement, including when it still contains the query.
|
||||
const replacementEnd = match.from + replaceQuery.length
|
||||
const nextIndex = getLiveMatches().findIndex((candidate) => candidate.from >= replacementEnd)
|
||||
setRawActiveMatchIndex(Math.max(0, nextIndex))
|
||||
setDebouncedQuery(searchQuery)
|
||||
setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: true }))
|
||||
}, [activeMatchIndex, getLiveMatches, replaceQuery, replaceRange, searchQuery])
|
||||
|
||||
const replaceAllMatches = useCallback(() => {
|
||||
if (!editor) {
|
||||
@@ -203,24 +208,25 @@ export function useRichMarkdownSearch({
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr)
|
||||
}, [editor, getLiveMatches, replaceQuery])
|
||||
setDebouncedQuery(searchQuery)
|
||||
setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: false }))
|
||||
}, [editor, getLiveMatches, replaceQuery, searchQuery])
|
||||
|
||||
const moveToMatch = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
if (matchCount === 0) {
|
||||
const liveMatchCount = getLiveMatches().length
|
||||
if (liveMatchCount === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: rawActiveMatchIndex starts at -1 before the user navigates, but the
|
||||
// derived activeMatchIndex is already 0 (first match shown). Using 0 as the
|
||||
// base when raw is -1 ensures the first Enter press advances to match 1
|
||||
// instead of computing (-1+1)%N = 0 and leaving the effect unchanged.
|
||||
setRawActiveMatchIndex((currentIndex) => {
|
||||
const baseIndex = Math.max(currentIndex, 0)
|
||||
return (baseIndex + direction + matchCount) % matchCount
|
||||
const baseIndex = currentIndex >= 0 && currentIndex < liveMatchCount ? currentIndex : 0
|
||||
return (baseIndex + direction + liveMatchCount) % liveMatchCount
|
||||
})
|
||||
setDebouncedQuery(searchQuery)
|
||||
setNavigationRequest((request) => ({ revision: request.revision + 1, selectMatch: true }))
|
||||
},
|
||||
[matchCount]
|
||||
[getLiveMatches, searchQuery]
|
||||
)
|
||||
|
||||
const handleEditorUpdate = useCallback(() => {
|
||||
@@ -259,52 +265,18 @@ export function useRichMarkdownSearch({
|
||||
searchInputRef.current?.select()
|
||||
}, [isSearchOpen])
|
||||
|
||||
// Why: single effect to sync search state to ProseMirror. The old two-effect
|
||||
// chain (compute matches → set state → dispatch) caused an extra render cycle
|
||||
// and called findRichMarkdownSearchMatches twice per change.
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const query = isSearchOpen ? searchRequestQuery : ''
|
||||
|
||||
// Why: combining decoration meta and selection+scrollIntoView into one
|
||||
// transaction avoids a split-dispatch where the first dispatch updates
|
||||
// editor.state and the second dispatch's scrollIntoView can be lost
|
||||
// when ProseMirror coalesces view updates.
|
||||
// Why: passing pre-computed matches avoids the plugin re-walking the
|
||||
// entire document — the old double-walk froze the UI on large files.
|
||||
const tr = editor.state.tr
|
||||
tr.setMeta(richMarkdownSearchPluginKey, {
|
||||
activeIndex: activeMatchIndex,
|
||||
matches,
|
||||
query
|
||||
})
|
||||
|
||||
const activeMatch = query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null
|
||||
if (activeMatch) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to))
|
||||
}
|
||||
|
||||
editor.view.dispatch(tr)
|
||||
|
||||
// Why: ProseMirror's tr.scrollIntoView() delegates to the view's
|
||||
// scrollDOMIntoView which may fail to reach the outer flex scroll container
|
||||
// (the editor element itself has min-height: 100% and no overflow).
|
||||
// Reading coordsAtPos *after* the dispatch and manually scrolling the
|
||||
// container mirrors the approach used by MarkdownPreview search.
|
||||
if (activeMatch) {
|
||||
const container = scrollContainerRef.current
|
||||
if (container) {
|
||||
const coords = editor.view.coordsAtPos(activeMatch.from)
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const relativeTop = coords.top - containerRect.top
|
||||
const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2
|
||||
container.scrollTo({ top: targetScroll, behavior: 'instant' })
|
||||
}
|
||||
}
|
||||
}, [activeMatchIndex, searchRequestQuery, editor, isSearchOpen, matches, scrollContainerRef])
|
||||
useRichMarkdownSearchHighlights({
|
||||
activeMatchIndex,
|
||||
editor,
|
||||
matchCase,
|
||||
matches,
|
||||
navigationRequest,
|
||||
query: isSearchOpen ? searchRequestQuery : '',
|
||||
scrollContainerRef,
|
||||
wholeWord,
|
||||
rootRef,
|
||||
searchDocument
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import { richMarkdownSearchPluginKey, type RichMarkdownSearchMatch } from './rich-markdown-search'
|
||||
|
||||
type SearchNavigation = {
|
||||
editor: Editor
|
||||
query: string
|
||||
matchCase: boolean
|
||||
wholeWord: boolean
|
||||
navigationRequest: { revision: number; selectMatch: boolean }
|
||||
}
|
||||
|
||||
export function useRichMarkdownSearchHighlights({
|
||||
activeMatchIndex,
|
||||
editor,
|
||||
matchCase,
|
||||
matches,
|
||||
navigationRequest,
|
||||
query,
|
||||
rootRef,
|
||||
searchDocument,
|
||||
scrollContainerRef,
|
||||
wholeWord
|
||||
}: {
|
||||
activeMatchIndex: number
|
||||
editor: Editor | null
|
||||
matchCase: boolean
|
||||
matches: RichMarkdownSearchMatch[]
|
||||
navigationRequest: SearchNavigation['navigationRequest']
|
||||
query: string
|
||||
rootRef: RefObject<HTMLDivElement | null>
|
||||
searchDocument: ProseMirrorNode | null
|
||||
scrollContainerRef: RefObject<HTMLDivElement | null>
|
||||
wholeWord: boolean
|
||||
}): void {
|
||||
const lastNavigationRef = useRef<SearchNavigation | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed) {
|
||||
lastNavigationRef.current = null
|
||||
return
|
||||
}
|
||||
// A newer editor transaction can arrive between render and this effect.
|
||||
if (editor.state.doc !== searchDocument) {
|
||||
return
|
||||
}
|
||||
const previous = lastNavigationRef.current
|
||||
const searchChanged =
|
||||
previous?.editor !== editor ||
|
||||
previous.query !== query ||
|
||||
previous.matchCase !== matchCase ||
|
||||
previous.wholeWord !== wholeWord
|
||||
const navigationRequested =
|
||||
previous?.editor === editor && previous.navigationRequest !== navigationRequest
|
||||
lastNavigationRef.current = { editor, query, matchCase, wholeWord, navigationRequest }
|
||||
|
||||
// Refreshing matches after an edit must preserve the user's caret and viewport.
|
||||
const activeElement = rootRef.current?.ownerDocument.activeElement
|
||||
const searchOwnsFocus =
|
||||
activeElement &&
|
||||
rootRef.current?.contains(activeElement) &&
|
||||
activeElement.closest('.rich-markdown-search')
|
||||
const shouldNavigate = navigationRequested
|
||||
? navigationRequest.selectMatch
|
||||
: searchChanged && searchOwnsFocus
|
||||
const activeMatch =
|
||||
shouldNavigate && query && activeMatchIndex >= 0 ? matches[activeMatchIndex] : null
|
||||
const tr = editor.state.tr.setMeta(richMarkdownSearchPluginKey, {
|
||||
activeIndex: activeMatchIndex,
|
||||
matches,
|
||||
query
|
||||
})
|
||||
if (activeMatch) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, activeMatch.from, activeMatch.to))
|
||||
}
|
||||
editor.view.dispatch(tr)
|
||||
if (editor.isDestroyed || editor.state.doc !== tr.doc) {
|
||||
return
|
||||
}
|
||||
|
||||
// The editor's scrollIntoView does not reliably reach the outer flex viewport.
|
||||
const container = scrollContainerRef.current
|
||||
if (activeMatch && container) {
|
||||
const coords = editor.view.coordsAtPos(activeMatch.from)
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const relativeTop = coords.top - containerRect.top
|
||||
const targetScroll = container.scrollTop + relativeTop - containerRect.height / 2
|
||||
container.scrollTo({ top: targetScroll, behavior: 'instant' })
|
||||
}
|
||||
}, [
|
||||
activeMatchIndex,
|
||||
editor,
|
||||
matchCase,
|
||||
matches,
|
||||
navigationRequest,
|
||||
query,
|
||||
rootRef,
|
||||
searchDocument,
|
||||
scrollContainerRef,
|
||||
wholeWord
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { Locator, Page } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { pressShortcut } from './helpers/shortcuts'
|
||||
import {
|
||||
cleanupMarkdownFixture,
|
||||
createMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture,
|
||||
waitForRichMarkdownEditor
|
||||
} from './helpers/markdown-editor-fixture'
|
||||
|
||||
const FIRST = 'First alpha paragraph for pointer selection.'
|
||||
const SECOND = 'Second beta paragraph for pointer selection.'
|
||||
const SOURCE = [
|
||||
'# Adversarial Find interactions',
|
||||
'Needle first match.',
|
||||
...Array.from({ length: 65 }, (_, index) => `Spacer ${index} keeps the search match away.`),
|
||||
FIRST,
|
||||
SECOND,
|
||||
'| Left | Right |\n| --- | --- |\n| Cell alpha | Cell beta |\n| Cell gamma | Cell delta |',
|
||||
'- [ ] Task marker',
|
||||
'<details open>\n<summary>Details marker</summary>\n\nDetails body marker\n\n</details>',
|
||||
'```javascript\nconst codeMarker = "clean";\n```',
|
||||
'Late target paragraph for a pending query.',
|
||||
...Array.from({ length: 30 }, (_, index) => `Tail spacer ${index} keeps the second match away.`),
|
||||
'Needle final match.'
|
||||
].join('\n\n')
|
||||
|
||||
async function textPoint(target: Locator, offset: number) {
|
||||
return target.evaluate((element, offset) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
let remaining = offset
|
||||
let node = walker.nextNode()
|
||||
while (node) {
|
||||
if (node instanceof Text && remaining <= node.length) {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, remaining)
|
||||
range.collapse(true)
|
||||
const bounds = range.getBoundingClientRect()
|
||||
return { x: bounds.left, y: bounds.top + bounds.height / 2 }
|
||||
}
|
||||
remaining -= node.textContent?.length ?? 0
|
||||
node = walker.nextNode()
|
||||
}
|
||||
throw new Error('Text offset was not found')
|
||||
}, offset)
|
||||
}
|
||||
|
||||
async function selectedText(page: Page) {
|
||||
return page.evaluate(() => window.getSelection()?.toString() ?? '')
|
||||
}
|
||||
|
||||
async function centered(target: Locator) {
|
||||
await target.evaluate((element) => element.scrollIntoView({ block: 'center' }))
|
||||
}
|
||||
|
||||
async function find(page: Page, query = 'Needle', status = '1/2') {
|
||||
await pressShortcut(page, 'f')
|
||||
const input = page.getByRole('textbox', { name: 'Find in rich markdown editor' })
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(query)
|
||||
await expect(page.locator('.rich-markdown-search-status')).toHaveText(status)
|
||||
return input
|
||||
}
|
||||
|
||||
async function drag(page: Page, start: Locator, end: Locator, from: number, to: number) {
|
||||
const firstPoint = await textPoint(start, from)
|
||||
const lastPoint = await textPoint(end, to)
|
||||
await page.mouse.move(firstPoint.x, firstPoint.y)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(lastPoint.x, lastPoint.y, { steps: 15 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ orcaPage, registerPostElectronShutdownCleanup }, testInfo) => {
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const filePath = await createMarkdownFixture(
|
||||
context,
|
||||
'.orca-e2e-markdown-adversarial',
|
||||
'find-interactions',
|
||||
testInfo.workerIndex,
|
||||
SOURCE
|
||||
)
|
||||
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
const editor = await waitForRichMarkdownEditor(orcaPage)
|
||||
await editor.locator('p').first().click()
|
||||
})
|
||||
|
||||
test('Find preserves Shift-click, double-click, and multi-paragraph copy selection', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
const first = editor.getByText(FIRST, { exact: true })
|
||||
const second = editor.getByText(SECOND, { exact: true })
|
||||
const search = await find(orcaPage)
|
||||
await centered(first)
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
const scroll = await viewport.evaluate((element) => element.scrollTop)
|
||||
const start = await textPoint(first, 6)
|
||||
const end = await textPoint(second, 11)
|
||||
await orcaPage.mouse.click(start.x, start.y)
|
||||
await orcaPage.keyboard.down('Shift')
|
||||
await orcaPage.mouse.click(end.x, end.y)
|
||||
await orcaPage.keyboard.up('Shift')
|
||||
expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(
|
||||
orcaPage
|
||||
.locator('[data-tab-id]')
|
||||
.filter({ hasText: 'find-interactions' })
|
||||
.last()
|
||||
.locator('span.rounded-full')
|
||||
).toHaveCount(0)
|
||||
await orcaPage.mouse.click(start.x, start.y)
|
||||
await search.focus()
|
||||
await orcaPage.keyboard.down('Shift')
|
||||
await orcaPage.mouse.click(end.x, end.y)
|
||||
await orcaPage.keyboard.up('Shift')
|
||||
expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
|
||||
const copied = await editor.evaluate((element) => {
|
||||
const clipboard = new DataTransfer()
|
||||
element.dispatchEvent(
|
||||
new ClipboardEvent('copy', { bubbles: true, cancelable: true, clipboardData: clipboard })
|
||||
)
|
||||
return { plain: clipboard.getData('text/plain'), html: clipboard.getData('text/html') }
|
||||
})
|
||||
expect(copied.plain).toContain(FIRST.slice(6))
|
||||
expect(copied.plain).toContain(SECOND.slice(0, 11))
|
||||
expect(copied.html).toContain('<p>')
|
||||
await expect(editor.getByText('Needle first match.', { exact: true })).toBeVisible()
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('shift-click-copy-selection.png') })
|
||||
await search.focus()
|
||||
const word = await textPoint(first, 9)
|
||||
await orcaPage.mouse.dblclick(word.x, word.y)
|
||||
expect(await selectedText(orcaPage)).toBe('alpha')
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await expect(
|
||||
editor.getByText('First xy paragraph for pointer selection.', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(search).toHaveValue('Needle')
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
|
||||
})
|
||||
|
||||
test('Replace input returns to a multi-paragraph drag and explicit next-match navigation', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
const search = await find(orcaPage)
|
||||
await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click()
|
||||
const replace = orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' })
|
||||
await replace.fill('Thread')
|
||||
const first = editor.getByText(FIRST, { exact: true })
|
||||
const second = editor.getByText(SECOND, { exact: true })
|
||||
await centered(first)
|
||||
await drag(orcaPage, first, second, 6, 11)
|
||||
expect(await selectedText(orcaPage)).toBe(`${FIRST.slice(6)}\n\n${SECOND.slice(0, 11)}`)
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await expect(
|
||||
editor.getByText(`${FIRST.slice(0, 6)}xy${SECOND.slice(11)}`, { exact: true })
|
||||
).toBeVisible()
|
||||
await orcaPage.getByRole('button', { name: 'Next match', exact: true }).click()
|
||||
await expect.poll(() => selectedText(orcaPage)).toBe('Needle')
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2')
|
||||
await replace.focus()
|
||||
await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
|
||||
await expect(editor.getByText('Thread final match.', { exact: true })).toBeVisible()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
|
||||
await expect(replace).toBeFocused()
|
||||
await orcaPage.keyboard.press('Escape')
|
||||
await expect(search).toHaveCount(0)
|
||||
await pressShortcut(orcaPage, 'f')
|
||||
await expect(search).toBeFocused()
|
||||
await expect(search).toHaveValue('')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('replace-next-reopen.png') })
|
||||
})
|
||||
|
||||
test('Find returns focus and copies the intended cells after Shift-clicking a table', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
const search = await find(orcaPage, 'Cell alpha', '1/1')
|
||||
const firstCell = editor.getByText('Cell alpha', { exact: true })
|
||||
const lastCell = editor.getByText('Cell delta', { exact: true })
|
||||
await centered(firstCell)
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
const scroll = await viewport.evaluate((element) => element.scrollTop)
|
||||
const point = await textPoint(lastCell, 6)
|
||||
await orcaPage.keyboard.down('Shift')
|
||||
await orcaPage.mouse.move(point.x, point.y)
|
||||
await orcaPage.mouse.down()
|
||||
await orcaPage.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
)
|
||||
await orcaPage.mouse.up()
|
||||
await orcaPage.keyboard.up('Shift')
|
||||
await expect(editor.locator('td.selectedCell')).toHaveCount(4)
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('shift-cell-selection.png') })
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(search).toHaveValue('Cell alpha')
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
|
||||
const tab = orcaPage.locator('[data-tab-id]').filter({ hasText: 'find-interactions' }).last()
|
||||
await expect(tab.locator('span.rounded-full')).toHaveCount(0)
|
||||
const copied = await editor.evaluate((element) => {
|
||||
const clipboard = new DataTransfer()
|
||||
element.dispatchEvent(
|
||||
new ClipboardEvent('copy', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clipboardData: clipboard
|
||||
})
|
||||
)
|
||||
return clipboard.getData('text/plain')
|
||||
})
|
||||
expect(copied).toBe('Cell alpha\n\nCell beta\n\nCell gamma\n\nCell delta')
|
||||
})
|
||||
|
||||
test('Find preserves embedded task, details, and code editing', async ({ orcaPage }, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
const search = await find(orcaPage)
|
||||
await centered(editor.getByRole('checkbox'))
|
||||
await search.focus()
|
||||
const checkbox = editor.getByRole('checkbox')
|
||||
await checkbox.check()
|
||||
await expect(checkbox).toBeChecked()
|
||||
await expect(search).toHaveValue('Needle')
|
||||
await search.focus()
|
||||
const details = editor.locator('[data-type="details"]')
|
||||
await details.getByRole('button').click()
|
||||
await expect(details.locator('[data-type="detailsContent"]')).toBeHidden()
|
||||
await search.focus()
|
||||
await details.getByRole('button').click()
|
||||
await expect(details.locator('[data-type="detailsContent"]')).toBeVisible()
|
||||
const body = editor.getByText('Details body marker', { exact: true })
|
||||
const bodyPoint = await textPoint(body, 7)
|
||||
await search.focus()
|
||||
await orcaPage.mouse.click(bodyPoint.x, bodyPoint.y)
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await expect(editor.getByText('Detailsxy body marker', { exact: true })).toBeVisible()
|
||||
const code = editor.getByText('const codeMarker = "clean";', { exact: true })
|
||||
await centered(code)
|
||||
const codePoint = await textPoint(code, 6)
|
||||
await search.focus()
|
||||
await orcaPage.mouse.click(codePoint.x, codePoint.y)
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await expect(editor.getByText('const xycodeMarker = "clean";', { exact: true })).toBeVisible()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('embedded-control-editing.png') })
|
||||
})
|
||||
|
||||
test('a pending Find query cannot claim focus after document and checkbox interaction', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
const search = await find(orcaPage)
|
||||
const target = editor.getByText('Late target paragraph for a pending query.', { exact: true })
|
||||
await centered(target)
|
||||
const point = await textPoint(target, 5)
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
const scroll = await viewport.evaluate((element) => element.scrollTop)
|
||||
const checkbox = editor.getByRole('checkbox')
|
||||
const checkboxPoint = await checkbox.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 }
|
||||
})
|
||||
await search.fill('Cell alpha')
|
||||
await orcaPage.mouse.click(point.x, point.y)
|
||||
await expect(editor).toBeFocused()
|
||||
await orcaPage.mouse.click(checkboxPoint.x, checkboxPoint.y)
|
||||
await expect(checkbox).toBeChecked()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
|
||||
await expect(target).toHaveText('Late target paragraph for a pending query.')
|
||||
await orcaPage.mouse.click(point.x, point.y)
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await expect(
|
||||
editor.getByText('Late xytarget paragraph for a pending query.', { exact: true })
|
||||
).toBeVisible()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/1')
|
||||
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBeCloseTo(scroll, 0)
|
||||
await expect(search).toHaveValue('Cell alpha')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('pending-query-editor-caret.png') })
|
||||
})
|
||||
|
||||
test('Replace advances when its replacement still contains the search query', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
const editor = orcaPage.locator('.rich-markdown-editor')
|
||||
await find(orcaPage)
|
||||
await orcaPage.getByRole('button', { name: 'Toggle replace', exact: true }).click()
|
||||
await orcaPage.getByRole('textbox', { name: 'Replace in rich markdown editor' }).fill('NeedleX')
|
||||
await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
|
||||
await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('2/2')
|
||||
await expect(
|
||||
editor.getByText('Needle final match.', { exact: true }).locator('[data-active="true"]')
|
||||
).toHaveText('Needle')
|
||||
await orcaPage.getByRole('button', { name: 'Replace', exact: true }).click()
|
||||
await expect(editor.getByText('NeedleX final match.', { exact: true })).toBeVisible()
|
||||
await expect(editor.getByText('NeedleX first match.', { exact: true })).toBeVisible()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('replacement-retains-query.png') })
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Locator, Page } from '@stablyai/playwright-test'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { pressShortcut } from './helpers/shortcuts'
|
||||
import {
|
||||
cleanupMarkdownFixture,
|
||||
createMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture,
|
||||
waitForRichMarkdownEditor
|
||||
} from './helpers/markdown-editor-fixture'
|
||||
|
||||
const TARGET_TEXT = 'Editing target paragraph keeps the pointer position and viewport.'
|
||||
const MARKDOWN = [
|
||||
'# Find and pointer editing',
|
||||
'Needle remains at the beginning.',
|
||||
...Array.from({ length: 100 }, (_, index) =>
|
||||
index === 70 ? TARGET_TEXT : `Paragraph ${index} provides room to scroll through the document.`
|
||||
),
|
||||
'Needle remains at the end.'
|
||||
].join('\n\n')
|
||||
const TABLE_MARKDOWN = MARKDOWN.replace(
|
||||
TARGET_TEXT,
|
||||
`| Name | Value |\n| --- | --- |\n| Target | ${TARGET_TEXT} |`
|
||||
)
|
||||
|
||||
async function textPoint(paragraph: Locator, offset: number) {
|
||||
return paragraph.evaluate((element, offset) => {
|
||||
const text = element.firstChild
|
||||
if (!(text instanceof Text)) {
|
||||
throw new Error('Expected a plain-text paragraph')
|
||||
}
|
||||
const range = document.createRange()
|
||||
range.setStart(text, offset)
|
||||
range.collapse(true)
|
||||
const bounds = range.getBoundingClientRect()
|
||||
return { x: bounds.left, y: bounds.top + bounds.height / 2 }
|
||||
}, offset)
|
||||
}
|
||||
|
||||
async function readParagraphSelection(paragraph: Locator) {
|
||||
return paragraph.evaluate((element) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.anchorNode || !selection.focusNode) {
|
||||
return null
|
||||
}
|
||||
if (!element.contains(selection.anchorNode) || !element.contains(selection.focusNode)) {
|
||||
return null
|
||||
}
|
||||
const start = document.createRange()
|
||||
start.selectNodeContents(element)
|
||||
start.setEnd(selection.anchorNode, selection.anchorOffset)
|
||||
const end = document.createRange()
|
||||
end.selectNodeContents(element)
|
||||
end.setEnd(selection.focusNode, selection.focusOffset)
|
||||
return {
|
||||
from: Math.min(start.toString().length, end.toString().length),
|
||||
to: Math.max(start.toString().length, end.toString().length),
|
||||
text: selection.toString()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function centerParagraph(paragraph: Locator): Promise<void> {
|
||||
await paragraph.evaluate((element) => element.scrollIntoView({ block: 'center' }))
|
||||
}
|
||||
|
||||
async function pointAtParagraph(page: Page, paragraph: Locator, drag: boolean) {
|
||||
const start = await textPoint(paragraph, 8)
|
||||
await page.mouse.move(start.x, start.y)
|
||||
await page.mouse.down()
|
||||
if (drag) {
|
||||
const end = await textPoint(paragraph, 24)
|
||||
await page.mouse.move(end.x, end.y, { steps: 12 })
|
||||
}
|
||||
// Tiptap focus commands can schedule a stale-selection scroll for the next frame.
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
)
|
||||
await page.mouse.up()
|
||||
const selection = await readParagraphSelection(paragraph)
|
||||
expect(selection).not.toBeNull()
|
||||
if (!selection) {
|
||||
throw new Error('Pointer selection left the intended paragraph')
|
||||
}
|
||||
if (drag) {
|
||||
expect(selection.text).toBe('target paragraph')
|
||||
} else {
|
||||
expect(selection.from).toBe(selection.to)
|
||||
}
|
||||
return selection
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
})
|
||||
|
||||
for (const interaction of ['click', 'drag'] as const) {
|
||||
test(`keeps Find open while a document ${interaction} edits at the pointer position`, async ({
|
||||
orcaPage,
|
||||
registerPostElectronShutdownCleanup
|
||||
}, testInfo) => {
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const filePath = await createMarkdownFixture(
|
||||
context,
|
||||
'.orca-e2e-markdown-find-editing',
|
||||
`find-${interaction}`,
|
||||
testInfo.workerIndex,
|
||||
MARKDOWN
|
||||
)
|
||||
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
const editor = await waitForRichMarkdownEditor(orcaPage)
|
||||
const paragraph = editor.locator('p').filter({ hasText: 'Editing' })
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
await editor.locator('p').first().click()
|
||||
await pressShortcut(orcaPage, 'f')
|
||||
const search = orcaPage.getByRole('textbox', { name: 'Find in rich markdown editor' })
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill('Needle')
|
||||
await orcaPage.getByRole('button', { name: 'Match case', exact: true }).click()
|
||||
await orcaPage.getByRole('button', { name: 'Match whole word', exact: true }).click()
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
|
||||
await centerParagraph(paragraph)
|
||||
const originalScroll = await viewport.evaluate((element) => element.scrollTop)
|
||||
const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag')
|
||||
|
||||
// Separate key events allow document updates to expose a caret reset between characters.
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('find-document-edit.png') })
|
||||
await expect(paragraph).toHaveText(
|
||||
`${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}`
|
||||
)
|
||||
await expect(editor.locator('p').first()).toHaveText('Needle remains at the beginning.')
|
||||
await expect
|
||||
.poll(() => readParagraphSelection(paragraph))
|
||||
.toEqual({
|
||||
from: selection.from + 2,
|
||||
to: selection.from + 2,
|
||||
text: ''
|
||||
})
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollTop))
|
||||
.toBeCloseTo(originalScroll, 0)
|
||||
await expect(search).toBeVisible()
|
||||
await expect(search).toHaveValue('Needle')
|
||||
await expect(orcaPage.getByRole('button', { name: 'Match case', exact: true })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
await expect(
|
||||
orcaPage.getByRole('button', { name: 'Match whole word', exact: true })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(orcaPage.locator('.rich-markdown-search-status')).toHaveText('1/2')
|
||||
})
|
||||
|
||||
test(`keeps the scrolled table and ${interaction} selection after switching Source to Rich`, async ({
|
||||
orcaPage,
|
||||
registerPostElectronShutdownCleanup
|
||||
}, testInfo) => {
|
||||
const context = await getActiveWorktreeContext(orcaPage)
|
||||
const filePath = await createMarkdownFixture(
|
||||
context,
|
||||
'.orca-e2e-markdown-find-editing',
|
||||
`source-${interaction}`,
|
||||
testInfo.workerIndex,
|
||||
TABLE_MARKDOWN
|
||||
)
|
||||
registerPostElectronShutdownCleanup(() => cleanupMarkdownFixture(filePath))
|
||||
await openMarkdownFixture(orcaPage, context, filePath)
|
||||
await waitForRichMarkdownEditor(orcaPage)
|
||||
await orcaPage.getByRole('radio', { name: 'Source', exact: true }).click()
|
||||
await expect(orcaPage.locator('.monaco-editor')).toBeVisible()
|
||||
await orcaPage.getByRole('radio', { name: 'Rich Editor', exact: true }).click()
|
||||
const editor = await waitForRichMarkdownEditor(orcaPage)
|
||||
await expect(editor).not.toBeFocused()
|
||||
const paragraph = editor.locator('td p').filter({ hasText: 'Editing' })
|
||||
const viewport = orcaPage.locator('.rich-markdown-editor-shell .overflow-auto')
|
||||
await centerParagraph(paragraph)
|
||||
const originalScroll = await viewport.evaluate((element) => element.scrollTop)
|
||||
const selection = await pointAtParagraph(orcaPage, paragraph, interaction === 'drag')
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(paragraph).toHaveText(TARGET_TEXT)
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollTop))
|
||||
.toBeCloseTo(originalScroll, 0)
|
||||
const tab = orcaPage
|
||||
.locator('[data-tab-id]')
|
||||
.filter({ hasText: path.basename(filePath) })
|
||||
.last()
|
||||
await expect(tab.locator('span.rounded-full')).toHaveCount(0)
|
||||
await expect(tab.getByRole('button', { name: 'Close tab' })).toBeVisible()
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-table-selection.png') })
|
||||
await orcaPage.keyboard.type('xy', { delay: 100 })
|
||||
await orcaPage.screenshot({ path: testInfo.outputPath('source-rich-pointer-edit.png') })
|
||||
await expect(paragraph).toHaveText(
|
||||
`${TARGET_TEXT.slice(0, selection.from)}xy${TARGET_TEXT.slice(selection.to)}`
|
||||
)
|
||||
await expect
|
||||
.poll(() => readParagraphSelection(paragraph))
|
||||
.toEqual({
|
||||
from: selection.from + 2,
|
||||
to: selection.from + 2,
|
||||
text: ''
|
||||
})
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollTop))
|
||||
.toBeCloseTo(originalScroll, 0)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user