feat: add dedicated TitleField above editor

Adds a TitleField component between the breadcrumb bar and BlockNote
editor that serves as the primary title editing surface. The H1 block
inside BlockNote is hidden via CSS. The title field:
- Shows the note title in a prominent input field
- Displays the expected filename when it differs from the current one
- Triggers onTitleSync on blur/Enter to rename the file
- Responds to laputa:focus-editor selectTitle events for new notes
- Reverts on Escape or empty input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-16 04:46:28 +01:00
parent ed33058862
commit db94b8b3a0
5 changed files with 228 additions and 1 deletions

View File

@@ -161,3 +161,47 @@
color: var(--text-faint) !important;
opacity: 1 !important;
}
/* --- Title Field --- */
.title-field {
padding: 16px 54px 0;
flex-shrink: 0;
}
.title-field__input {
display: block;
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: var(--editor-title-size, 28px);
font-weight: 700;
line-height: 1.2;
color: var(--foreground);
padding: 0;
}
.title-field__input::placeholder {
color: var(--text-faint, #ccc);
}
.title-field__input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.title-field__filename {
display: block;
font-size: 11px;
color: var(--text-faint, #999);
margin-top: 2px;
}
/* Hide the first H1 heading in BlockNote — title is shown in TitleField above */
.editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] {
display: none;
}
/* Also hide the BlockContainer wrapper if its heading is hidden */
.editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
display: none;
}

View File

@@ -238,6 +238,7 @@ export const Editor = memo(function Editor({
vaultPath={vaultPath}
isDarkTheme={isDarkTheme}
rawLatestContentRef={rawLatestContentRef}
onTitleChange={onTitleSync}
/>
}
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}

View File

@@ -3,6 +3,7 @@ import type { VaultEntry, NoteStatus } from '../types'
import type { useCreateBlockNote } from '@blocknote/react'
import { DiffView } from './DiffView'
import { BreadcrumbBar } from './BreadcrumbBar'
import { TitleField } from './TitleField'
import { TrashedNoteBanner } from './TrashedNoteBanner'
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
import { RawEditorView } from './RawEditorView'
@@ -44,6 +45,8 @@ interface EditorContentProps {
isDarkTheme?: boolean
/** Ref updated by RawEditorView on every keystroke with the latest doc. */
rawLatestContentRef?: React.MutableRefObject<string | null>
/** Called when the user edits the dedicated title field. */
onTitleChange?: (path: string, newTitle: string) => void
}
function EditorLoadingSkeleton() {
@@ -163,10 +166,11 @@ export function EditorContent({
diffMode, diffContent, onToggleDiff,
rawMode, onToggleRaw, onRawContentChange, onSave,
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
onDeleteNote, rawLatestContentRef,
onDeleteNote, rawLatestContentRef, onTitleChange,
...breadcrumbProps
}: EditorContentProps) {
const isTrashed = activeTab?.entry.trashed ?? false
const showTitleField = activeTab && !diffMode && !rawMode
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
@@ -185,6 +189,14 @@ export function EditorContent({
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
)}
{showTitleField && (
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable={!isTrashed}
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
/>
)}
<EditorBody activeTab={activeTab} isLoadingNewTab={isLoadingNewTab} entries={entries} editor={editor} diffMode={diffMode} diffContent={diffContent} onToggleDiff={onToggleDiff} rawMode={rawMode} onRawContentChange={onRawContentChange} onSave={onSave} onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} isTrashed={isTrashed} rawLatestContentRef={rawLatestContentRef} />
</div>
)

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TitleField } from './TitleField'
describe('TitleField', () => {
it('renders the title in the input', () => {
render(<TitleField title="My Note" filename="my-note.md" onTitleChange={() => {}} />)
expect(screen.getByTestId('title-field-input')).toHaveValue('My Note')
})
it('calls onTitleChange on blur with new value', () => {
const onChange = vi.fn()
render(<TitleField title="Old Title" filename="old-title.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: 'New Title' } })
fireEvent.blur(input)
expect(onChange).toHaveBeenCalledWith('New Title')
})
it('does not call onTitleChange if title unchanged', () => {
const onChange = vi.fn()
render(<TitleField title="Same Title" filename="same-title.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.focus(input)
fireEvent.blur(input)
expect(onChange).not.toHaveBeenCalled()
})
it('reverts to original title if input is emptied', () => {
const onChange = vi.fn()
render(<TitleField title="Keep This" filename="keep-this.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect(onChange).not.toHaveBeenCalled()
expect(input).toHaveValue('Keep This')
})
it('shows filename indicator when slug differs from current filename', () => {
render(<TitleField title="My Note" filename="wrong-name.md" onTitleChange={() => {}} />)
expect(screen.getByTestId('title-field-filename')).toHaveTextContent('my-note.md')
})
it('does not show filename when slug matches and not editing', () => {
render(<TitleField title="My Note" filename="my-note.md" onTitleChange={() => {}} />)
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
})
it('disables input when editable is false', () => {
render(<TitleField title="Read Only" filename="read-only.md" editable={false} onTitleChange={() => {}} />)
expect(screen.getByTestId('title-field-input')).toBeDisabled()
})
it('commits title on Enter key', () => {
const onChange = vi.fn()
render(<TitleField title="Before" filename="before.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: 'After' } })
fireEvent.keyDown(input, { key: 'Enter' })
// In jsdom, blur() after keyDown needs explicit blur event
fireEvent.blur(input)
expect(onChange).toHaveBeenCalledWith('After')
})
it('reverts on Escape key', () => {
const onChange = vi.fn()
render(<TitleField title="Original" filename="original.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: 'Changed' } })
fireEvent.keyDown(input, { key: 'Escape' })
// Escape reverts value and blurs
expect(input).toHaveValue('Original')
})
it('responds to laputa:focus-editor event with selectTitle', () => {
render(<TitleField title="Focus Me" filename="focus-me.md" onTitleChange={() => {}} />)
const input = screen.getByTestId('title-field-input')
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
expect(document.activeElement).toBe(input)
})
})

View File

@@ -0,0 +1,89 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { slugify } from '../hooks/useNoteActions'
interface TitleFieldProps {
title: string
filename: string
editable?: boolean
/** Called when the user finishes editing the title (blur or Enter). */
onTitleChange: (newTitle: string) => void
}
/**
* Dedicated title input field above the editor.
* Displays the title as an editable field and shows the resulting filename below.
* Replaces the H1 block as the primary title editing surface.
*/
export function TitleField({ title, filename, editable = true, onTitleChange }: TitleFieldProps) {
const [localValue, setLocalValue] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const isFocusedRef = useRef(false)
// The displayed value: use local edit value while focused, otherwise prop title
const value = localValue ?? title
// Listen for laputa:focus-editor with selectTitle to focus this field
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 handleFocus = useCallback(() => {
isFocusedRef.current = true
setLocalValue(title)
}, [title])
const commitTitle = useCallback(() => {
isFocusedRef.current = false
const trimmed = (localValue ?? '').trim()
if (trimmed && trimmed !== title) {
onTitleChange(trimmed)
}
setLocalValue(null) // reset to prop-driven
}, [localValue, title, onTitleChange])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
inputRef.current?.blur()
}
if (e.key === 'Escape') {
setLocalValue(null) // revert to prop
inputRef.current?.blur()
}
}, [])
const expectedSlug = slugify(value.trim() || title)
const currentStem = filename.replace(/\.md$/, '')
const showFilename = localValue !== null || currentStem !== expectedSlug
return (
<div className="title-field" data-testid="title-field">
<input
ref={inputRef}
className="title-field__input"
value={value}
onChange={e => setLocalValue(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>
)
}