H1 headings are body content, not the title source of truth. Remove useHeadingTitleSync hook and frontmatter-title-from-H1 logic in the editor. Rust rename_note no longer modifies H1 in file content — only updates the frontmatter title: field. TitleField now uses optimistic UI: the new title displays immediately after commit while the async file rename runs in background, preventing UI freezes on slow filesystems or large vaults. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
106 lines
3.4 KiB
TypeScript
106 lines
3.4 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
import { slugify } from '../hooks/useNoteCreation'
|
|
|
|
interface TitleFieldProps {
|
|
title: string
|
|
filename: string
|
|
editable?: boolean
|
|
/** Called when the user finishes editing the title (blur or Enter). */
|
|
onTitleChange: (newTitle: string) => void
|
|
}
|
|
|
|
/** Manages local edit + optimistic title state for TitleField. */
|
|
function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
|
|
const [localValue, setLocalValue] = useState<string | null>(null)
|
|
// [optimisticTitle, forPropTitle]: shown after commit until title prop catches up
|
|
const [optimistic, setOptimistic] = useState<[string, string] | null>(null)
|
|
const isFocusedRef = useRef(false)
|
|
|
|
// Clear optimistic once the prop changes (rename completed or tab switched)
|
|
const optimisticValue = optimistic && optimistic[1] === title ? optimistic[0] : null
|
|
const value = localValue ?? optimisticValue ?? title
|
|
const isEditing = localValue !== null || optimisticValue !== null
|
|
|
|
const handleFocus = useCallback(() => {
|
|
isFocusedRef.current = true
|
|
setLocalValue(title)
|
|
}, [title])
|
|
|
|
const commitTitle = useCallback(() => {
|
|
isFocusedRef.current = false
|
|
const trimmed = (localValue ?? '').trim()
|
|
if (trimmed && trimmed !== title) {
|
|
setLocalValue(null)
|
|
setOptimistic([trimmed, title])
|
|
onTitleChange(trimmed)
|
|
} else {
|
|
setLocalValue(null)
|
|
}
|
|
}, [localValue, title, onTitleChange])
|
|
|
|
const revert = useCallback(() => setLocalValue(null), [])
|
|
const setEdit = useCallback((v: string) => setLocalValue(v), [])
|
|
|
|
return { value, isEditing, handleFocus, commitTitle, revert, setEdit }
|
|
}
|
|
|
|
/**
|
|
* Dedicated title input field above the editor.
|
|
* Displays the title as an editable field and shows the resulting filename below.
|
|
*/
|
|
export function TitleField({ title, filename, editable = true, onTitleChange }: TitleFieldProps) {
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
|
|
useOptimisticTitle(title, onTitleChange)
|
|
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const detail = (e as CustomEvent).detail
|
|
if (detail?.selectTitle && inputRef.current) {
|
|
inputRef.current.focus()
|
|
inputRef.current.select()
|
|
}
|
|
}
|
|
window.addEventListener('laputa:focus-editor', handler)
|
|
return () => window.removeEventListener('laputa:focus-editor', handler)
|
|
}, [])
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
inputRef.current?.blur()
|
|
}
|
|
if (e.key === 'Escape') {
|
|
revert()
|
|
inputRef.current?.blur()
|
|
}
|
|
}, [revert])
|
|
|
|
const expectedSlug = slugify(value.trim() || title)
|
|
const currentStem = filename.replace(/\.md$/, '')
|
|
const showFilename = isEditing || currentStem !== expectedSlug
|
|
|
|
return (
|
|
<div className="title-field" data-testid="title-field">
|
|
<input
|
|
ref={inputRef}
|
|
className="title-field__input"
|
|
value={value}
|
|
onChange={e => setEdit(e.target.value)}
|
|
onFocus={handleFocus}
|
|
onBlur={commitTitle}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={!editable}
|
|
placeholder="Untitled"
|
|
spellCheck={false}
|
|
data-testid="title-field-input"
|
|
/>
|
|
{showFilename && (
|
|
<span className="title-field__filename" data-testid="title-field-filename">
|
|
{expectedSlug}.md
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|