diff --git a/src/components/Editor.css b/src/components/Editor.css index 049c1aa9..71dba97d 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -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; +} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index d1e6647e..1253a411 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -238,6 +238,7 @@ export const Editor = memo(function Editor({ vaultPath={vaultPath} isDarkTheme={isDarkTheme} rawLatestContentRef={rawLatestContentRef} + onTitleChange={onTitleSync} /> } {(showAIChat || !inspectorCollapsed) && } diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index c8864701..bd341500 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -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 + /** 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 (
@@ -185,6 +189,14 @@ export function EditorContent({ {activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && ( breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} /> )} + {showTitleField && ( + onTitleChange?.(activeTab.entry.path, newTitle)} + /> + )}
) diff --git a/src/components/TitleField.test.tsx b/src/components/TitleField.test.tsx new file mode 100644 index 00000000..4929b7dc --- /dev/null +++ b/src/components/TitleField.test.tsx @@ -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( {}} />) + expect(screen.getByTestId('title-field-input')).toHaveValue('My Note') + }) + + it('calls onTitleChange on blur with new value', () => { + const onChange = vi.fn() + render() + 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() + 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() + 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( {}} />) + expect(screen.getByTestId('title-field-filename')).toHaveTextContent('my-note.md') + }) + + it('does not show filename when slug matches and not editing', () => { + render( {}} />) + expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument() + }) + + it('disables input when editable is false', () => { + render( {}} />) + expect(screen.getByTestId('title-field-input')).toBeDisabled() + }) + + it('commits title on Enter key', () => { + const onChange = vi.fn() + render() + 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() + 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( {}} />) + const input = screen.getByTestId('title-field-input') + window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } })) + expect(document.activeElement).toBe(input) + }) +}) diff --git a/src/components/TitleField.tsx b/src/components/TitleField.tsx new file mode 100644 index 00000000..336d55cc --- /dev/null +++ b/src/components/TitleField.tsx @@ -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(null) + const inputRef = useRef(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 ( +
+ setLocalValue(e.target.value)} + onFocus={handleFocus} + onBlur={commitTitle} + onKeyDown={handleKeyDown} + disabled={!editable} + placeholder="Untitled" + spellCheck={false} + data-testid="title-field-input" + /> + {showFilename && ( + + {expectedSlug}.md + + )} +
+ ) +}