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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -238,6 +238,7 @@ export const Editor = memo(function Editor({
|
||||
vaultPath={vaultPath}
|
||||
isDarkTheme={isDarkTheme}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onTitleChange={onTitleSync}
|
||||
/>
|
||||
}
|
||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
81
src/components/TitleField.test.tsx
Normal file
81
src/components/TitleField.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
89
src/components/TitleField.tsx
Normal file
89
src/components/TitleField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user