From 57a5693922d02a92db1e4d50cabc3b135e95a5e3 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 12 Apr 2026 00:06:49 +0200 Subject: [PATCH] fix: stabilize new note editor focus --- src/hooks/editorFocusUtils.ts | 85 +++++++++++++++++++++++ src/hooks/useEditorFocus.test.ts | 38 +++++++++-- src/hooks/useEditorFocus.ts | 112 ++++++++++++------------------- 3 files changed, 161 insertions(+), 74 deletions(-) create mode 100644 src/hooks/editorFocusUtils.ts diff --git a/src/hooks/editorFocusUtils.ts b/src/hooks/editorFocusUtils.ts new file mode 100644 index 00000000..ae8119c3 --- /dev/null +++ b/src/hooks/editorFocusUtils.ts @@ -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(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') + }) +} diff --git a/src/hooks/useEditorFocus.test.ts b/src/hooks/useEditorFocus.test.ts index f789e5d8..546f5572 100644 --- a/src/hooks/useEditorFocus.test.ts +++ b/src/hooks/useEditorFocus.test.ts @@ -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) { + 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 }) diff --git a/src/hooks/useEditorFocus.ts b/src/hooks/useEditorFocus.ts index aed546da..125a778d 100644 --- a/src/hooks/useEditorFocus.ts +++ b/src/hooks/useEditorFocus.ts @@ -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, + 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, ) { 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)