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 } +}