fix: stabilize new note editor focus
This commit is contained in:
85
src/hooks/editorFocusUtils.ts
Normal file
85
src/hooks/editorFocusUtils.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
const EDITABLE_SELECTOR = '.bn-editor [contenteditable="true"], .ProseMirror[contenteditable="true"]'
|
||||
const MAX_FOCUS_ATTEMPTS = 12
|
||||
|
||||
interface TiptapChain {
|
||||
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
|
||||
run: () => void
|
||||
}
|
||||
|
||||
export interface TiptapEditor {
|
||||
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
|
||||
chain: () => TiptapChain
|
||||
}
|
||||
|
||||
export interface FocusableEditor {
|
||||
focus: () => void
|
||||
_tiptapEditor?: TiptapEditor
|
||||
}
|
||||
|
||||
/** Select all text in the first heading block via the TipTap chain API. */
|
||||
function selectFirstHeading(editor: FocusableEditor): void {
|
||||
const tiptap = editor._tiptapEditor
|
||||
if (!tiptap?.state?.doc) return
|
||||
|
||||
let from = -1
|
||||
let to = -1
|
||||
|
||||
tiptap.state.doc.descendants((node, pos) => {
|
||||
if (from !== -1) return false
|
||||
if (node.type.name === 'heading') {
|
||||
from = pos + 1
|
||||
to = pos + node.nodeSize - 1
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (from === -1 || from >= to) return
|
||||
tiptap.chain().setTextSelection({ from, to }).run()
|
||||
}
|
||||
|
||||
function hasEditableFocus(): boolean {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
||||
}
|
||||
|
||||
function focusEditableNode(): boolean {
|
||||
const editable = document.querySelector<HTMLElement>(EDITABLE_SELECTOR)
|
||||
if (!editable) return false
|
||||
editable.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
function logFocusTiming(t0: number | undefined, label: 'focus' | 'focus+select'): void {
|
||||
if (!t0) return
|
||||
console.debug(`[perf] createNote → ${label}: ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
}
|
||||
|
||||
export function focusEditorWithRetries(
|
||||
editor: FocusableEditor,
|
||||
selectTitle: boolean,
|
||||
t0: number | undefined,
|
||||
attempt = 0,
|
||||
): void {
|
||||
editor.focus()
|
||||
if (!hasEditableFocus()) {
|
||||
focusEditableNode()
|
||||
}
|
||||
if (!hasEditableFocus() && attempt < MAX_FOCUS_ATTEMPTS) {
|
||||
requestAnimationFrame(() => focusEditorWithRetries(editor, selectTitle, t0, attempt + 1))
|
||||
return
|
||||
}
|
||||
if (!selectTitle) {
|
||||
logFocusTiming(t0, 'focus')
|
||||
return
|
||||
}
|
||||
// Defer selection to the next animation frame so the new note's content
|
||||
// (applied via queueMicrotask inside a React effect triggered by the tab
|
||||
// change) is in the document before we try to select the heading.
|
||||
// Between two rAF callbacks, all pending macrotasks — including React's
|
||||
// MessageChannel re-render and the subsequent queueMicrotask content swap
|
||||
// — complete, so the heading block is guaranteed to exist by rAF 2.
|
||||
requestAnimationFrame(() => {
|
||||
selectFirstHeading(editor)
|
||||
logFocusTiming(t0, 'focus+select')
|
||||
})
|
||||
}
|
||||
@@ -16,14 +16,21 @@ function makeTiptapMock(hasHeading = true) {
|
||||
}
|
||||
|
||||
describe('useEditorFocus', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function setup(isMounted: boolean, tiptap?: ReturnType<typeof makeTiptapMock>) {
|
||||
const editable = document.createElement('div')
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
editable.tabIndex = -1
|
||||
document.body.appendChild(editable)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn(), _tiptapEditor: tiptap } as any
|
||||
const editor = { focus: vi.fn(() => editable.focus()), _tiptapEditor: tiptap } as any
|
||||
const mountedRef = { current: isMounted }
|
||||
renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
return { editor, tiptap }
|
||||
return { editor, tiptap, editable }
|
||||
}
|
||||
|
||||
it('focuses editor via rAF when already mounted', async () => {
|
||||
@@ -84,8 +91,12 @@ describe('useEditorFocus', () => {
|
||||
})
|
||||
|
||||
it('cleans up event listener on unmount', () => {
|
||||
const editable = document.createElement('div')
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
editable.tabIndex = -1
|
||||
document.body.appendChild(editable)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn() } as any
|
||||
const editor = { focus: vi.fn(() => editable.focus()) } as any
|
||||
const mountedRef = { current: true }
|
||||
const { unmount } = renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
|
||||
@@ -96,6 +107,25 @@ describe('useEditorFocus', () => {
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to focusing the editable DOM node when editor.focus does not make it active', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const editable = document.createElement('div')
|
||||
editable.className = 'ProseMirror'
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
editable.tabIndex = -1
|
||||
document.body.appendChild(editable)
|
||||
const editableFocus = vi.spyOn(editable, 'focus')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn(), _tiptapEditor: undefined } as any
|
||||
const mountedRef = { current: true }
|
||||
renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(editableFocus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('selectTitle behavior', () => {
|
||||
it('selects H1 text when selectTitle is true and editor is mounted', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
import { useEffect } from 'react'
|
||||
import { focusEditorWithRetries, type FocusableEditor } from './editorFocusUtils'
|
||||
|
||||
const TAB_SWAP_EVENT_NAME = 'laputa:editor-tab-swapped'
|
||||
const FOCUS_EVENT_NAME = 'laputa:focus-editor'
|
||||
const SWAP_WAIT_FALLBACK_MS = 250
|
||||
|
||||
interface TiptapChain {
|
||||
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
|
||||
run: () => void
|
||||
interface FocusEventDetail {
|
||||
t0?: number
|
||||
selectTitle?: boolean
|
||||
path?: string | null
|
||||
}
|
||||
|
||||
interface TiptapEditor {
|
||||
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
|
||||
chain: () => TiptapChain
|
||||
function scheduleEditorFocus(
|
||||
editor: FocusableEditor,
|
||||
editorMountedRef: React.RefObject<boolean>,
|
||||
selectTitle: boolean,
|
||||
t0: number | undefined,
|
||||
): void {
|
||||
if (editorMountedRef.current) {
|
||||
requestAnimationFrame(() => focusEditorWithRetries(editor, selectTitle, t0))
|
||||
return
|
||||
}
|
||||
setTimeout(() => focusEditorWithRetries(editor, selectTitle, t0), 80)
|
||||
}
|
||||
|
||||
/** Select all text in the first heading block via the TipTap chain API. */
|
||||
function selectFirstHeading(editor: { _tiptapEditor?: TiptapEditor }): void {
|
||||
const tiptap = editor._tiptapEditor
|
||||
if (!tiptap?.state?.doc) return
|
||||
function registerPendingTabFocus(
|
||||
targetPath: string,
|
||||
scheduleFocus: () => void,
|
||||
pendingCleanups: Set<() => void>,
|
||||
): void {
|
||||
const handleTabSwap = (event: Event) => {
|
||||
const swapPath = (event as CustomEvent).detail?.path
|
||||
if (swapPath !== targetPath) return
|
||||
cleanupPending()
|
||||
scheduleFocus()
|
||||
}
|
||||
|
||||
let from = -1
|
||||
let to = -1
|
||||
const fallbackTimer = window.setTimeout(() => {
|
||||
cleanupPending()
|
||||
scheduleFocus()
|
||||
}, SWAP_WAIT_FALLBACK_MS)
|
||||
|
||||
tiptap.state.doc.descendants((node, pos) => {
|
||||
if (from !== -1) return false
|
||||
if (node.type.name === 'heading') {
|
||||
from = pos + 1
|
||||
to = pos + node.nodeSize - 1
|
||||
return false
|
||||
}
|
||||
})
|
||||
const cleanupPending = () => {
|
||||
window.clearTimeout(fallbackTimer)
|
||||
window.removeEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
|
||||
pendingCleanups.delete(cleanupPending)
|
||||
}
|
||||
|
||||
if (from === -1 || from >= to) return
|
||||
tiptap.chain().setTextSelection({ from, to }).run()
|
||||
pendingCleanups.add(cleanupPending)
|
||||
window.addEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,68 +58,24 @@ function selectFirstHeading(editor: { _tiptapEditor?: TiptapEditor }): void {
|
||||
* When selectTitle is true, also selects all text in the first H1 block.
|
||||
*/
|
||||
export function useEditorFocus(
|
||||
editor: { focus: () => void; _tiptapEditor?: TiptapEditor },
|
||||
editor: FocusableEditor,
|
||||
editorMountedRef: React.RefObject<boolean>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const pendingCleanups = new Set<() => void>()
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean; path?: string | null } | undefined
|
||||
const detail = (e as CustomEvent).detail as FocusEventDetail | undefined
|
||||
const t0 = detail?.t0
|
||||
const selectTitle = detail?.selectTitle ?? false
|
||||
const targetPath = detail?.path ?? null
|
||||
const doFocus = () => {
|
||||
editor.focus()
|
||||
if (!selectTitle) {
|
||||
if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
return
|
||||
}
|
||||
// Defer selection to the next animation frame so the new note's content
|
||||
// (applied via queueMicrotask inside a React effect triggered by the tab
|
||||
// change) is in the document before we try to select the heading.
|
||||
// Between two rAF callbacks, all pending macrotasks — including React's
|
||||
// MessageChannel re-render and the subsequent queueMicrotask content swap
|
||||
// — complete, so the heading block is guaranteed to exist by rAF 2.
|
||||
requestAnimationFrame(() => {
|
||||
selectFirstHeading(editor)
|
||||
if (t0) console.debug(`[perf] createNote → focus+select: ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleFocus = () => {
|
||||
if (editorMountedRef.current) {
|
||||
requestAnimationFrame(doFocus)
|
||||
return
|
||||
}
|
||||
setTimeout(doFocus, 80)
|
||||
}
|
||||
const scheduleFocus = () => scheduleEditorFocus(editor, editorMountedRef, selectTitle, t0)
|
||||
|
||||
if (!targetPath) {
|
||||
scheduleFocus()
|
||||
return
|
||||
}
|
||||
|
||||
const handleTabSwap = (event: Event) => {
|
||||
const swapPath = (event as CustomEvent).detail?.path
|
||||
if (swapPath !== targetPath) return
|
||||
cleanupPending()
|
||||
scheduleFocus()
|
||||
}
|
||||
|
||||
const fallbackTimer = window.setTimeout(() => {
|
||||
cleanupPending()
|
||||
scheduleFocus()
|
||||
}, SWAP_WAIT_FALLBACK_MS)
|
||||
|
||||
const cleanupPending = () => {
|
||||
window.clearTimeout(fallbackTimer)
|
||||
window.removeEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
|
||||
pendingCleanups.delete(cleanupPending)
|
||||
}
|
||||
|
||||
pendingCleanups.add(cleanupPending)
|
||||
window.addEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
|
||||
registerPendingTabFocus(targetPath, scheduleFocus, pendingCleanups)
|
||||
}
|
||||
|
||||
window.addEventListener(FOCUS_EVENT_NAME, handler)
|
||||
|
||||
Reference in New Issue
Block a user