diff --git a/src/hooks/useEditorFocus.test.ts b/src/hooks/useEditorFocus.test.ts index 20ede4ff..9461805f 100644 --- a/src/hooks/useEditorFocus.test.ts +++ b/src/hooks/useEditorFocus.test.ts @@ -2,19 +2,33 @@ import { describe, it, expect, vi, afterEach } from 'vitest' import { renderHook } from '@testing-library/react' import { useEditorFocus } from './useEditorFocus' +function makeTiptapMock(hasHeading = true) { + const chainResult = { setTextSelection: vi.fn().mockReturnThis(), run: vi.fn() } + const descendantsMock = vi.fn().mockImplementation((cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => { + if (hasHeading) cb({ type: { name: 'heading' }, nodeSize: 15 }, 2) + }) + return { + state: { doc: { descendants: descendantsMock } }, + chain: vi.fn(() => chainResult), + _chainResult: chainResult, + _descendantsMock: descendantsMock, + } +} + describe('useEditorFocus', () => { afterEach(() => { vi.restoreAllMocks() }) - function setup(isMounted: boolean) { - const editor = { focus: vi.fn() } + function setup(isMounted: boolean, tiptap?: ReturnType) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test + const editor = { focus: vi.fn(), _tiptapEditor: tiptap } as any const mountedRef = { current: isMounted } renderHook(() => useEditorFocus(editor, mountedRef)) - return editor + return { editor, tiptap } } it('focuses editor via rAF when already mounted', async () => { const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) - const editor = setup(true) + const { editor } = setup(true) window.dispatchEvent(new CustomEvent('laputa:focus-editor')) @@ -24,7 +38,7 @@ describe('useEditorFocus', () => { it('focuses editor via setTimeout when not yet mounted', () => { vi.useFakeTimers() - const editor = setup(false) + const { editor } = setup(false) window.dispatchEvent(new CustomEvent('laputa:focus-editor')) @@ -35,7 +49,8 @@ describe('useEditorFocus', () => { }) it('cleans up event listener on unmount', () => { - const editor = { focus: vi.fn() } + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test + const editor = { focus: vi.fn() } as any const mountedRef = { current: true } const { unmount } = renderHook(() => useEditorFocus(editor, mountedRef)) @@ -45,4 +60,67 @@ describe('useEditorFocus', () => { expect(editor.focus).not.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 }) + const tiptap = makeTiptapMock(true) + const { editor } = setup(true, tiptap) + + window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } })) + + expect(editor.focus).toHaveBeenCalled() + expect(tiptap.chain).toHaveBeenCalled() + expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 }) + expect(tiptap._chainResult.run).toHaveBeenCalled() + }) + + it('does not select title when selectTitle is false (default)', () => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) + const tiptap = makeTiptapMock(true) + const { editor } = setup(true, tiptap) + + window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: false } })) + + expect(editor.focus).toHaveBeenCalled() + expect(tiptap.chain).not.toHaveBeenCalled() + }) + + it('does not select title when selectTitle is absent from event detail', () => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) + const tiptap = makeTiptapMock(true) + const { editor } = setup(true, tiptap) + + window.dispatchEvent(new CustomEvent('laputa:focus-editor')) + + expect(editor.focus).toHaveBeenCalled() + expect(tiptap.chain).not.toHaveBeenCalled() + }) + + it('skips selection when no heading found in document', () => { + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 }) + const tiptap = makeTiptapMock(false) + const { editor } = setup(true, tiptap) + + window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } })) + + expect(editor.focus).toHaveBeenCalled() + expect(tiptap.chain).not.toHaveBeenCalled() + }) + + it('selects H1 text after timeout when editor not yet mounted', () => { + vi.useFakeTimers() + const tiptap = makeTiptapMock(true) + const { editor } = setup(false, tiptap) + + window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } })) + + expect(editor.focus).not.toHaveBeenCalled() + vi.advanceTimersByTime(80) + expect(editor.focus).toHaveBeenCalled() + expect(tiptap.chain).toHaveBeenCalled() + expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 }) + vi.useRealTimers() + }) + }) }) diff --git a/src/hooks/useEditorFocus.ts b/src/hooks/useEditorFocus.ts index b4d81a97..73536e15 100644 --- a/src/hooks/useEditorFocus.ts +++ b/src/hooks/useEditorFocus.ts @@ -1,19 +1,54 @@ 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 }, + editor: { focus: () => void; _tiptapEditor?: TiptapEditor }, editorMountedRef: React.RefObject, ) { useEffect(() => { const handler = (e: Event) => { - const t0 = (e as CustomEvent).detail?.t0 as number | undefined + 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) selectFirstHeading(editor) if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`) } if (editorMountedRef.current) { diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index a3172a93..e9bc5d40 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -241,8 +241,10 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e: } /** Dispatch focus-editor event with perf timing marker. */ -function signalFocusEditor(): void { - window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { t0: performance.now() } })) +function signalFocusEditor(opts?: { selectTitle?: boolean }): void { + window.dispatchEvent(new CustomEvent('laputa:focus-editor', { + detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false }, + })) } interface PersistCallbacks { @@ -372,7 +374,7 @@ export function useNoteActions(config: NoteActionsConfig) { addEntryWithMock(resolved.entry, resolved.content, addEntry) config.trackUnsaved?.(resolved.entry.path) config.markContentPending?.(resolved.entry.path, resolved.content) - signalFocusEditor() + signalFocusEditor({ selectTitle: true }) setTimeout(() => pendingNamesRef.current.delete(title), 500) }, [entries, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable