The previous implementation called selectFirstHeading() in the same rAF as editor.focus(), but the new note's content (applied via queueMicrotask inside a React effect) hadn't been swapped in yet. React's MessageChannel scheduler runs the re-render between animation frames, so the heading block didn't exist in the TipTap document when the selection ran. Fix: move selectFirstHeading() into a nested requestAnimationFrame inside doFocus(). Between rAF 1 (focus) and rAF 2 (select), all pending macrotasks — React's re-render + the queueMicrotask content swap — run to completion, guaranteeing the H1 block is in the document before we try to select it. Updated tests: add rAF mock to the timeout-path select test (which now needs it since selection is deferred via rAF); add a regression test that explicitly verifies focus in rAF1 and selection in rAF2. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
76 lines
2.7 KiB
TypeScript
76 lines
2.7 KiB
TypeScript
import { useEffect } from 'react'
|
|
|
|
interface TiptapChain {
|
|
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
|
|
run: () => void
|
|
}
|
|
|
|
interface TiptapEditor {
|
|
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
|
|
chain: () => TiptapChain
|
|
}
|
|
|
|
/** 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
|
|
|
|
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()
|
|
}
|
|
|
|
/**
|
|
* Focus editor when a new note is created (signaled via custom event).
|
|
* Uses adaptive timing: fast rAF path when editor is already mounted,
|
|
* short timeout when waiting for first mount.
|
|
* When selectTitle is true, also selects all text in the first H1 block.
|
|
*/
|
|
export function useEditorFocus(
|
|
editor: { focus: () => void; _tiptapEditor?: TiptapEditor },
|
|
editorMountedRef: React.RefObject<boolean>,
|
|
) {
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean } | undefined
|
|
const t0 = detail?.t0
|
|
const selectTitle = detail?.selectTitle ?? false
|
|
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`)
|
|
})
|
|
}
|
|
if (editorMountedRef.current) {
|
|
requestAnimationFrame(doFocus)
|
|
} else {
|
|
setTimeout(doFocus, 80)
|
|
}
|
|
}
|
|
window.addEventListener('laputa:focus-editor', handler)
|
|
return () => window.removeEventListener('laputa:focus-editor', handler)
|
|
}, [editor, editorMountedRef])
|
|
}
|