From fb763f53389d076c15da5528bd511d114ed606ae Mon Sep 17 00:00:00 2001 From: Test Date: Sat, 28 Feb 2026 10:57:30 +0100 Subject: [PATCH] feat: sync note title with H1 heading (predictive title) When the editor's first block is an H1 heading, the note title automatically stays in sync. Changes are debounced at 500ms. Manual rename via tab breaks the sync until the tab is switched. - Show H1 in editor (stop stripping on load, stop reconstructing on save) - New useHeadingTitleSync hook tracks sync state and debounces title updates - handleEditorChange keeps frontmatter title: in sync with H1 - Fast path for H1-only content preserves instant new-note interactivity Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 9 ++ src/components/Editor.tsx | 32 ++++-- src/hooks/useEditorTabSwap.test.ts | 130 +++++++++++++++++++--- src/hooks/useEditorTabSwap.ts | 65 +++++++++-- src/hooks/useHeadingTitleSync.test.ts | 153 ++++++++++++++++++++++++++ src/hooks/useHeadingTitleSync.ts | 68 ++++++++++++ 6 files changed, 429 insertions(+), 28 deletions(-) create mode 100644 src/hooks/useHeadingTitleSync.test.ts create mode 100644 src/hooks/useHeadingTitleSync.ts diff --git a/src/App.tsx b/src/App.tsx index 674a6527..7c413fce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -239,6 +239,14 @@ function App() { await notes.handleRenameNote(path, newTitle, vaultSwitcher.vaultPath, vault.replaceEntry).then(vault.loadModifiedFiles) }, [notes, vaultSwitcher.vaultPath, vault, savePendingForPath]) + /** H1→title sync: update VaultEntry.title and tab entry in memory. */ + const handleTitleSync = useCallback((path: string, newTitle: string) => { + vault.updateEntry(path, { title: newTitle }) + notes.setTabs(prev => prev.map(t => + t.entry.path === path ? { ...t, entry: { ...t.entry, title: newTitle } } : t + )) + }, [vault, notes]) + const bulkActions = useBulkActions(entryActions, setToastMessage) const { setViewMode, sidebarVisible, noteListVisible } = useViewMode() @@ -320,6 +328,7 @@ function App() { onUnarchiveNote={entryActions.handleUnarchiveNote} onRenameTab={handleRenameTab} onContentChange={handleContentChange} + onTitleSync={handleTitleSync} canGoBack={navHistory.canGoBack} canGoForward={navHistory.canGoForward} onGoBack={handleGoBack} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 572c3cab..c614eb3b 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,5 +1,6 @@ -import { useRef, useEffect, memo } from 'react' -import { useEditorTabSwap } from '../hooks/useEditorTabSwap' +import { useRef, useEffect, useCallback, memo } from 'react' +import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap' +import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync' import { useCreateBlockNote } from '@blocknote/react' import '@blocknote/mantine/style.css' import { uploadImageFile } from '../hooks/useImageDrop' @@ -52,6 +53,8 @@ interface EditorProps { onUnarchiveNote?: (path: string) => void onRenameTab?: (path: string, newTitle: string) => void onContentChange?: (path: string, content: string) => void + /** Called when H1→title sync updates the title (debounced). */ + onTitleSync?: (path: string, newTitle: string) => void canGoBack?: boolean canGoForward?: boolean onGoBack?: () => void @@ -76,7 +79,7 @@ export const Editor = memo(function Editor({ showAIChat, onToggleAIChat, vaultPath, onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote, - onRenameTab, onContentChange, + onRenameTab, onContentChange, onTitleSync, canGoBack, canGoForward, onGoBack, onGoForward, }: EditorProps) { const vaultPathRef = useRef(vaultPath) @@ -87,14 +90,29 @@ export const Editor = memo(function Editor({ uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current), }) - const { handleEditorChange, editorMountedRef } = useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange }) + const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null + + const { syncActiveRef, onH1Changed, onManualRename } = useHeadingTitleSync({ + activeTabPath, + currentTitle: activeTab?.entry.title ?? null, + onTitleSync: onTitleSync ?? (() => {}), + }) + + const { handleEditorChange, editorMountedRef } = useEditorTabSwap({ + tabs, activeTabPath, editor, onContentChange, + onH1Change: onH1Changed, syncActiveRef, + }) useEditorFocus(editor, editorMountedRef) + const handleRenameTabWithSync = useCallback((path: string, newTitle: string) => { + const h1Text = getH1TextFromBlocks(editor.document) + onManualRename(newTitle, h1Text) + onRenameTab?.(path, newTitle) + }, [editor, onManualRename, onRenameTab]) + const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({ activeTabPath, onLoadDiff, onLoadDiffAtCommit, }) - - const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null const isLoadingNewTab = activeTabPath !== null && !activeTab const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean' const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified')) @@ -110,7 +128,7 @@ export const Editor = memo(function Editor({ onCloseTab={onCloseTab} onCreateNote={onCreateNote} onReorderTabs={onReorderTabs} - onRenameTab={onRenameTab} + onRenameTab={handleRenameTabWithSync} canGoBack={canGoBack} canGoForward={canGoForward} onGoBack={onGoBack} diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 58d6e98c..fa755317 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -1,25 +1,25 @@ import { describe, it, expect } from 'vitest' -import { extractEditorBody } from './useEditorTabSwap' +import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './useEditorTabSwap' describe('extractEditorBody', () => { - it('strips frontmatter and title heading from new note content (double newline)', () => { + it('strips frontmatter and preserves H1 heading for new note content', () => { const content = '---\ntitle: Untitled note\ntype: Note\nstatus: Active\n---\n\n# Untitled note\n\n' - expect(extractEditorBody(content)).toBe('') + expect(extractEditorBody(content)).toBe('# Untitled note\n\n') }) - it('strips frontmatter and title heading from content with single newline', () => { + it('strips frontmatter and preserves H1 with body content', () => { const content = '---\ntitle: Test\n---\n# Test\n\nBody text here.' - expect(extractEditorBody(content)).toBe('Body text here.') + expect(extractEditorBody(content)).toBe('# Test\n\nBody text here.') }) - it('preserves body content after the heading', () => { + it('preserves H1 and body content after frontmatter', () => { const content = '---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nFirst paragraph.\n\nSecond paragraph.' - expect(extractEditorBody(content)).toBe('First paragraph.\n\nSecond paragraph.') + expect(extractEditorBody(content)).toBe('# My Note\n\nFirst paragraph.\n\nSecond paragraph.') }) it('handles content without frontmatter', () => { const content = '# Just a Heading\n\nSome body text.' - expect(extractEditorBody(content)).toBe('Some body text.') + expect(extractEditorBody(content)).toBe('# Just a Heading\n\nSome body text.') }) it('handles content without frontmatter or heading', () => { @@ -38,17 +38,121 @@ describe('extractEditorBody', () => { it('preserves wikilinks in body', () => { const content = '---\ntitle: Test\n---\n\n# Test\n\nSee [[Other Note]] for details.' - expect(extractEditorBody(content)).toBe('See [[Other Note]] for details.') + expect(extractEditorBody(content)).toBe('# Test\n\nSee [[Other Note]] for details.') }) - it('does not strip heading that is not at the start of body', () => { + it('preserves non-leading headings', () => { const content = '---\ntitle: Test\n---\n\nSome intro text.\n\n# A Heading\n\nMore text.' expect(extractEditorBody(content)).toBe('Some intro text.\n\n# A Heading\n\nMore text.') }) - it('returns empty for buildNoteContent output', () => { - // Exactly what buildNoteContent('My Project', 'Project', 'Active') produces + it('preserves H1 for buildNoteContent output', () => { const content = '---\ntitle: My Project\ntype: Project\nstatus: Active\n---\n\n# My Project\n\n' - expect(extractEditorBody(content)).toBe('') + expect(extractEditorBody(content)).toBe('# My Project\n\n') + }) +}) + +describe('getH1TextFromBlocks', () => { + it('returns text from H1 heading block', () => { + const blocks = [{ + type: 'heading', + props: { level: 1 }, + content: [{ type: 'text', text: 'My Title', styles: {} }], + }] + expect(getH1TextFromBlocks(blocks)).toBe('My Title') + }) + + it('returns null for empty blocks', () => { + expect(getH1TextFromBlocks([])).toBeNull() + }) + + it('returns null for non-heading first block', () => { + const blocks = [{ + type: 'paragraph', + content: [{ type: 'text', text: 'Just text' }], + }] + expect(getH1TextFromBlocks(blocks)).toBeNull() + }) + + it('returns null for H2 heading', () => { + const blocks = [{ + type: 'heading', + props: { level: 2 }, + content: [{ type: 'text', text: 'Subtitle' }], + }] + expect(getH1TextFromBlocks(blocks)).toBeNull() + }) + + it('concatenates multiple text spans', () => { + const blocks = [{ + type: 'heading', + props: { level: 1 }, + content: [ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'World' }, + ], + }] + expect(getH1TextFromBlocks(blocks)).toBe('Hello World') + }) + + it('returns null for empty H1 content', () => { + const blocks = [{ + type: 'heading', + props: { level: 1 }, + content: [], + }] + expect(getH1TextFromBlocks(blocks)).toBeNull() + }) + + it('returns null for whitespace-only H1', () => { + const blocks = [{ + type: 'heading', + props: { level: 1 }, + content: [{ type: 'text', text: ' ' }], + }] + expect(getH1TextFromBlocks(blocks)).toBeNull() + }) + + it('returns null when blocks is null/undefined', () => { + expect(getH1TextFromBlocks(null as unknown as unknown[])).toBeNull() + expect(getH1TextFromBlocks(undefined as unknown as unknown[])).toBeNull() + }) + + it('filters non-text inline content', () => { + const blocks = [{ + type: 'heading', + props: { level: 1 }, + content: [ + { type: 'text', text: 'Title' }, + { type: 'wikilink', props: { target: 'linked' } }, + ], + }] + expect(getH1TextFromBlocks(blocks)).toBe('Title') + }) +}) + +describe('replaceTitleInFrontmatter', () => { + it('replaces title value in frontmatter', () => { + const fm = '---\ntitle: Old Title\ntype: Note\n---\n\n' + expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\ntype: Note\n---\n\n') + }) + + it('handles title with extra spaces after colon', () => { + const fm = '---\ntitle: Old Title\n---\n' + expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\n---\n') + }) + + it('returns unchanged frontmatter when no title line exists', () => { + const fm = '---\ntype: Note\n---\n' + expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntype: Note\n---\n') + }) + + it('replaces only the title line, not other fields', () => { + const fm = '---\ntitle: Old\ntype: Note\nstatus: Active\n---\n\n' + expect(replaceTitleInFrontmatter(fm, 'Updated')).toBe('---\ntitle: Updated\ntype: Note\nstatus: Active\n---\n\n') + }) + + it('handles empty string as frontmatter', () => { + expect(replaceTitleInFrontmatter('', 'Title')).toBe('') }) }) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index dc1f6372..df937fce 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -1,3 +1,4 @@ +import type React from 'react' import { useCallback, useEffect, useRef } from 'react' import type { useCreateBlockNote } from '@blocknote/react' import type { VaultEntry } from '../types' @@ -13,13 +14,39 @@ interface UseEditorTabSwapOptions { activeTabPath: string | null editor: ReturnType onContentChange?: (path: string, content: string) => void + /** Called on every editor change with the H1 text (null if no H1 first block). */ + onH1Change?: (h1Text: string | null) => void + /** When .current is false, handleEditorChange won't update frontmatter title from H1. */ + syncActiveRef?: React.MutableRefObject } -/** Strip the YAML frontmatter and the title heading (# ...) from raw file - * content, returning only the body that should appear in the editor. */ +/** Strip the YAML frontmatter from raw file content, returning the body + * (including any H1 heading) that should appear in the editor. */ export function extractEditorBody(rawFileContent: string): string { const [, rawBody] = splitFrontmatter(rawFileContent) - return rawBody.trimStart().replace(/^# [^\n]*\n?/, '').trimStart() + return rawBody.trimStart() +} + +/** Extract H1 text from the editor's first block, or null if not an H1. */ +export function getH1TextFromBlocks(blocks: unknown[]): string | null { + if (!blocks?.length) return null + const first = blocks[0] as { + type?: string + props?: { level?: number } + content?: Array<{ type?: string; text?: string }> + } + if (first.type !== 'heading' || first.props?.level !== 1) return null + if (!Array.isArray(first.content)) return null + const text = first.content + .filter(item => item.type === 'text') + .map(item => item.text || '') + .join('') + return text.trim() || null +} + +/** Replace the title: line in YAML frontmatter with a new title value. */ +export function replaceTitleInFrontmatter(frontmatter: string, newTitle: string): string { + return frontmatter.replace(/^(title:\s*).+$/m, `$1${newTitle}`) } /** @@ -33,7 +60,7 @@ export function extractEditorBody(rawFileContent: string): string { * * Returns `handleEditorChange`, the onChange callback for SingleEditorView. */ -export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange }: UseEditorTabSwapOptions) { +export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, onH1Change, syncActiveRef }: UseEditorTabSwapOptions) { // Cache parsed blocks per tab path for instant switching // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays const tabCacheRef = useRef>(new Map()) @@ -47,6 +74,8 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange // Keep refs to callbacks for the onChange handler const onContentChangeRef = useRef(onContentChange) onContentChangeRef.current = onContentChange + const onH1ChangeRef = useRef(onH1Change) + onH1ChangeRef.current = onH1Change const tabsRef = useRef(tabs) tabsRef.current = tabs @@ -84,13 +113,20 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange const restored = restoreWikilinksInBlocks(blocks) const bodyMarkdown = editor.blocksToMarkdownLossy(restored as typeof blocks) - // Reconstruct the full file: preserve original frontmatter + title heading + // Reconstruct full file: frontmatter + body (which now includes H1 if present) const [frontmatter] = splitFrontmatter(tab.content) - const title = tab.entry.title - const fullContent = `${frontmatter}# ${title}\n\n${bodyMarkdown}` + const h1Text = getH1TextFromBlocks(blocks) + + // Keep frontmatter title: in sync with H1 when sync is active + const isSyncActive = syncActiveRef?.current !== false + const fm = (isSyncActive && h1Text) + ? replaceTitleInFrontmatter(frontmatter, h1Text) + : frontmatter + const fullContent = `${fm}${bodyMarkdown}` onContentChangeRef.current?.(path, fullContent) - }, [editor]) + onH1ChangeRef.current?.(h1Text) + }, [editor, syncActiveRef]) // Swap document content when active tab changes. // Uses queueMicrotask to defer BlockNote mutations outside React's commit phase, @@ -171,6 +207,19 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange return } + // Fast path: H1-only content (e.g. newly created notes that just have + // the title heading). Build blocks directly to stay instant. + const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/) + if (h1OnlyMatch) { + const h1Doc = [ + { type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [{ type: 'text', text: h1OnlyMatch[1], styles: {} }], children: [] }, + { type: 'paragraph', content: [], children: [] }, + ] + cache.set(targetPath, h1Doc) + applyBlocks(h1Doc) + return + } + try { const result = editor.tryParseMarkdownToBlocks(preprocessed) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays diff --git a/src/hooks/useHeadingTitleSync.test.ts b/src/hooks/useHeadingTitleSync.test.ts new file mode 100644 index 00000000..91839d13 --- /dev/null +++ b/src/hooks/useHeadingTitleSync.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useHeadingTitleSync } from './useHeadingTitleSync' + +describe('useHeadingTitleSync', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('calls onTitleSync after debounce when H1 differs from title', () => { + const onTitleSync = vi.fn() + const { result } = renderHook(() => + useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old Title', onTitleSync }) + ) + + act(() => { result.current.onH1Changed('New Title') }) + expect(onTitleSync).not.toHaveBeenCalled() + + act(() => { vi.advanceTimersByTime(500) }) + expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'New Title') + }) + + it('does not call onTitleSync when H1 matches current title', () => { + const onTitleSync = vi.fn() + const { result } = renderHook(() => + useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Same Title', onTitleSync }) + ) + + act(() => { result.current.onH1Changed('Same Title') }) + act(() => { vi.advanceTimersByTime(500) }) + expect(onTitleSync).not.toHaveBeenCalled() + }) + + it('does not call onTitleSync when H1 is null', () => { + const onTitleSync = vi.fn() + const { result } = renderHook(() => + useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync }) + ) + + act(() => { result.current.onH1Changed(null) }) + act(() => { vi.advanceTimersByTime(500) }) + expect(onTitleSync).not.toHaveBeenCalled() + }) + + it('debounces rapid H1 changes and uses last value', () => { + const onTitleSync = vi.fn() + const { result } = renderHook(() => + useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old', onTitleSync }) + ) + + act(() => { result.current.onH1Changed('New 1') }) + act(() => { vi.advanceTimersByTime(200) }) + act(() => { result.current.onH1Changed('New 2') }) + act(() => { vi.advanceTimersByTime(200) }) + act(() => { result.current.onH1Changed('New 3') }) + act(() => { vi.advanceTimersByTime(500) }) + + expect(onTitleSync).toHaveBeenCalledTimes(1) + expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'New 3') + }) + + it('does not call onTitleSync when no active tab', () => { + const onTitleSync = vi.fn() + const { result } = renderHook(() => + useHeadingTitleSync({ activeTabPath: null, currentTitle: null, onTitleSync }) + ) + + act(() => { result.current.onH1Changed('Title') }) + act(() => { vi.advanceTimersByTime(500) }) + expect(onTitleSync).not.toHaveBeenCalled() + }) + + it('resets sync state when active tab changes', () => { + const onTitleSync = vi.fn() + const { result, rerender } = renderHook( + ({ path, title }) => useHeadingTitleSync({ activeTabPath: path, currentTitle: title, onTitleSync }), + { initialProps: { path: '/a.md', title: 'A' } } + ) + + // Break sync on tab A + act(() => { result.current.onManualRename('Custom', 'A H1') }) + + // H1 change should be ignored (sync broken) + act(() => { result.current.onH1Changed('New A') }) + act(() => { vi.advanceTimersByTime(500) }) + expect(onTitleSync).not.toHaveBeenCalled() + + // Switch to tab B — sync resets + rerender({ path: '/b.md', title: 'B' }) + + // H1 change should now work + act(() => { result.current.onH1Changed('New B') }) + act(() => { vi.advanceTimersByTime(500) }) + expect(onTitleSync).toHaveBeenCalledWith('/b.md', 'New B') + }) + + it('breaks sync when manual rename differs from H1', () => { + const onTitleSync = vi.fn() + const { result } = renderHook(() => + useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync }) + ) + + act(() => { result.current.onManualRename('Custom Name', 'Title') }) + + // H1 changes should be ignored + act(() => { result.current.onH1Changed('New H1') }) + act(() => { vi.advanceTimersByTime(500) }) + expect(onTitleSync).not.toHaveBeenCalled() + }) + + it('does not break sync when manual rename matches H1', () => { + const onTitleSync = vi.fn() + const { result } = renderHook(() => + useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Old', onTitleSync }) + ) + + act(() => { result.current.onManualRename('Same as H1', 'Same as H1') }) + + // Sync should still be active + act(() => { result.current.onH1Changed('Updated') }) + act(() => { vi.advanceTimersByTime(500) }) + expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'Updated') + }) + + it('does not break sync when H1 is null during manual rename', () => { + const onTitleSync = vi.fn() + const { result } = renderHook(() => + useHeadingTitleSync({ activeTabPath: '/note.md', currentTitle: 'Title', onTitleSync }) + ) + + act(() => { result.current.onManualRename('New Name', null) }) + + // Sync should still be active (no H1 to compare) + act(() => { result.current.onH1Changed('H1 Text') }) + act(() => { vi.advanceTimersByTime(500) }) + expect(onTitleSync).toHaveBeenCalledWith('/note.md', 'H1 Text') + }) + + it('clears pending debounce timer on tab switch', () => { + const onTitleSync = vi.fn() + const { result, rerender } = renderHook( + ({ path, title }) => useHeadingTitleSync({ activeTabPath: path, currentTitle: title, onTitleSync }), + { initialProps: { path: '/a.md', title: 'A' } } + ) + + act(() => { result.current.onH1Changed('New A') }) + // Switch tab before debounce fires + rerender({ path: '/b.md', title: 'B' }) + act(() => { vi.advanceTimersByTime(500) }) + + // Should NOT fire for old tab + expect(onTitleSync).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useHeadingTitleSync.ts b/src/hooks/useHeadingTitleSync.ts new file mode 100644 index 00000000..4a2060c2 --- /dev/null +++ b/src/hooks/useHeadingTitleSync.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef } from 'react' + +interface HeadingTitleSyncConfig { + activeTabPath: string | null + currentTitle: string | null + onTitleSync: (path: string, newTitle: string) => void +} + +const DEBOUNCE_MS = 500 + +/** + * Syncs the note title with the editor's first H1 heading. + * + * - On every editor change, the caller passes the current H1 text via onH1Changed. + * - After a 500ms debounce, VaultEntry.title is updated to match. + * - If the user manually renames the title (via tab) to something different from + * the H1, sync breaks and stops updating until the tab is switched. + * - If the H1 is deleted, the last synced title is kept (no clear). + */ +export function useHeadingTitleSync({ + activeTabPath, + currentTitle, + onTitleSync, +}: HeadingTitleSyncConfig) { + const syncActiveRef = useRef(true) + const debounceTimerRef = useRef>() + const activeTabPathRef = useRef(activeTabPath) + // eslint-disable-next-line react-hooks/refs + activeTabPathRef.current = activeTabPath + const onTitleSyncRef = useRef(onTitleSync) + // eslint-disable-next-line react-hooks/refs + onTitleSyncRef.current = onTitleSync + const currentTitleRef = useRef(currentTitle) + // eslint-disable-next-line react-hooks/refs + currentTitleRef.current = currentTitle + + // Reset sync state when switching tabs + useEffect(() => { + syncActiveRef.current = true + clearTimeout(debounceTimerRef.current) + }, [activeTabPath]) + + // Cleanup on unmount + useEffect(() => () => clearTimeout(debounceTimerRef.current), []) + + /** Called on every editor change with the H1 text (null if no H1 first block). */ + const onH1Changed = useCallback((h1Text: string | null) => { + if (!h1Text || !activeTabPathRef.current || !syncActiveRef.current) return + if (h1Text === currentTitleRef.current) return + + clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = setTimeout(() => { + if (syncActiveRef.current && activeTabPathRef.current) { + onTitleSyncRef.current(activeTabPathRef.current, h1Text) + } + }, DEBOUNCE_MS) + }, []) + + /** Called when the user manually renames via tab double-click. + * Breaks sync if the new title differs from the current H1. */ + const onManualRename = useCallback((newTitle: string, currentH1: string | null) => { + if (currentH1 && currentH1 !== newTitle) { + syncActiveRef.current = false + } + }, []) + + return { syncActiveRef, onH1Changed, onManualRename } +}