fix: remove legacy title section fallback

This commit is contained in:
lucaronin
2026-04-11 23:51:58 +02:00
parent eb65bb8f05
commit 2ca8f1b2a6
24 changed files with 113 additions and 983 deletions

View File

@@ -14,7 +14,7 @@ These frontmatter field names have special meaning in Laputa's UI:
| Field | Meaning | UI behavior |
|---|---|---|
| `title:` | Human-readable title (synced with filename) | Breadcrumb, sidebar. Filename = `slugify(title).md` |
| `title:` | Legacy display-title fallback for older notes | Used only when a note has no H1; new notes do not write it automatically |
| `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping |
| `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header |
| `icon:` | Per-note icon (emoji, Phosphor name, or HTTP/HTTPS image URL) | Rendered on note title surfaces; editable from the Properties panel |
@@ -237,16 +237,17 @@ Laputa separates **display title** from the file identifier:
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
- **On rename / explicit title edits** (`rename_note`): Laputa updates both filename and `title` frontmatter atomically, plus wikilinks across the vault.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions update the filename and wikilinks across the vault. The editor body remains the title editing surface.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Field (UI)
### Title Surface (UI)
The dedicated `TitleField` is a fallback editing surface, not the canonical one:
The BlockNote body is the only title editing surface:
- If the note already has an H1, the editor body is the primary title surface and the dedicated title row is hidden.
- If the note has no H1 and is not an untitled draft, `TitleField` appears above the editor and `onTitleSync` updates `title:` frontmatter plus the filename.
- `TitleField` also responds to `laputa:focus-editor` events with `selectTitle: true` for new-note flows that start without an H1.
- The first H1 is the canonical display title.
- There is no separate title row above the editor, even when a note has no H1.
- Notes without an H1 show the editor body and placeholder only.
- Filename changes are explicit breadcrumb actions, not a dedicated title-input side effect.
### Sidebar Selection

View File

@@ -65,7 +65,6 @@ laputa-app/
│ │ ├── WelcomeScreen.tsx # Onboarding screen
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
│ │ ├── TitleField.tsx # Editable note title above editor
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
│ │ ├── CommitDialog.tsx # Git commit modal
│ │ ├── CreateNoteDialog.tsx # New note modal

View File

@@ -0,0 +1,42 @@
---
type: ADR
id: "0055"
title: "H1 is the only editor title surface"
status: active
date: 2026-04-11
supersedes: "0044"
---
## Context
ADR-0044 moved Laputa to H1-as-title, but the frontend still carried a legacy fallback: when a note had no H1, `TitleField` and the old title section could reappear above the editor. That left two competing title surfaces in the product and made it possible for deleting an H1 to resurrect UI that was supposed to be gone.
The result was both behavioral drift and stale tests: some code paths still treated the dedicated title row as a valid editing surface even though the product direction is now keyboard-first writing directly in the document body.
## Decision
**The editor body is now the only title surface. Laputa never renders a separate title section above the editor, regardless of whether a note currently has an H1.**
Display-title behavior stays:
1. First H1 in the body
2. Legacy frontmatter `title:`
3. Filename-derived fallback
But the UI no longer exposes a dedicated title field for cases 2 or 3. When a note has no H1, the editor simply shows normal body content or the empty-editor placeholder.
Filename operations remain explicit:
- untitled notes still auto-rename from H1 on save
- manual filename rename/sync remains in the breadcrumb
## Options considered
- **Option A** (chosen): remove the fallback title section entirely. This makes the editor honest, removes a stale code path, and keeps title editing aligned with the keyboard-first document model.
- **Option B**: keep the fallback title field for non-H1 notes. This preserves an alternate rename path, but it reintroduces the exact dual-surface ambiguity that ADR-0044 tried to escape.
- **Option C**: hide the title section with CSS only. Low churn, but it leaves dead render/state paths in place and makes regressions like “delete H1 and old title row returns” easy to reintroduce.
## Consequences
- Deleting an H1 no longer reveals any legacy title UI; the user stays in the editor body.
- `TitleField` and the title-section render path are removed from the frontend.
- Breadcrumb filename controls are now the only explicit file-identifier editing surface outside the editor body.
- Older tests that asserted title editing through `TitleField` are obsolete and should be replaced by H1-title or breadcrumb-filename coverage.

View File

@@ -99,7 +99,7 @@ proposed → active → superseded
| [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active |
| [0042](0042-trash-auto-purge-safety-model.md) | Trash auto-purge safety model | superseded → [0045](0045-permanent-delete-no-trash.md) |
| [0043](0043-reactive-vault-state-on-save.md) | Reactive vault state: editor changes propagate immediately to all UI | active |
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | active |
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | superseded → [0055](0055-h1-is-the-only-editor-title-surface.md) |
| [0045](0045-permanent-delete-no-trash.md) | Permanent delete with confirm modal — no Trash system | active |
| [0046](0046-starter-vault-cloned-from-github.md) | Starter vault cloned from GitHub at runtime — no bundled content | active |
| [0047](0047-regex-mode-for-view-filter-conditions.md) | Regex mode for view filter conditions | active |
@@ -110,3 +110,4 @@ proposed → active → superseded
| [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | Renderer-first shortcut execution with native-menu dedupe | active |
| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active |
| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active |
| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active |

View File

@@ -707,7 +707,6 @@ function App() {
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={appSave.handleContentChange}
onSave={appSave.handleSave}
onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync}
onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}

View File

@@ -141,26 +141,16 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.querySelector('.breadcrumb-bar')!
expect(bar).toHaveClass('border-b', 'border-transparent')
expect(bar).not.toHaveAttribute('data-title-hidden')
bar.setAttribute('data-title-hidden', '')
expect(bar).toHaveAttribute('data-title-hidden')
})
it('uses the active separator state when raw mode forces the title into the breadcrumb', () => {
it('keeps the breadcrumb title visible in raw mode', () => {
const { container } = render(
<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode onToggleRaw={vi.fn()} />,
)
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
})
it('keeps the breadcrumb title visible when the separate title section is absent', () => {
const { container } = render(
<BreadcrumbBar entry={baseEntry} {...defaultProps} showTitleSection={false} />,
)
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
})
})
describe('BreadcrumbBar — filename controls', () => {

View File

@@ -43,7 +43,6 @@ interface BreadcrumbBarProps {
onArchive?: () => void
onUnarchive?: () => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
showTitleSection?: boolean
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
}
@@ -515,17 +514,13 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
entry,
barRef,
onRenameFilename,
showTitleSection = true,
...actionProps
}: BreadcrumbBarProps) {
// In raw/diff mode the title section is not rendered — always show title in breadcrumb.
// Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect.
const titleAlwaysVisible = !showTitleSection || actionProps.rawMode || actionProps.diffMode
return (
<div
ref={barRef}
data-tauri-drag-region
{...(titleAlwaysVisible ? { 'data-title-hidden': '' } : {})}
data-title-hidden=""
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{
height: 52,

View File

@@ -204,57 +204,6 @@
width: 100%;
}
/* --- Title Section: wraps icon + title + separator --- */
.title-section {
width: 100%;
flex-shrink: 0;
}
.title-section__heading {
position: relative;
}
.title-section__inline-add-icon {
position: absolute;
left: 8px;
top: 0;
z-index: 1;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.title-section:hover .title-section__inline-add-icon,
.title-section__inline-add-icon:focus-within {
opacity: 1;
pointer-events: auto;
}
.title-section__row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
padding-top: 8px;
margin-left: 8px;
}
/* No emoji: title aligns flush left (no indent for icon area) */
.title-section__row--no-icon {
margin-left: 0;
}
/* When emoji is present, restore top padding to the row itself */
.title-section__row:has(.note-icon-button--active) {
padding-top: 32px;
}
.title-section__separator {
border-bottom: 1px solid var(--border-primary, rgba(0, 0, 0, 0.08));
margin-top: 12px;
margin-left: 8px;
}
/* --- Note Icon Area --- */
.note-icon-area {
display: flex;
@@ -303,7 +252,6 @@
transition: opacity 0.15s;
}
.title-section:hover .note-icon-button--add,
.note-icon-button--add:focus-visible {
opacity: 1;
}
@@ -352,56 +300,6 @@
color: var(--destructive, #ef4444);
}
/* --- Title Field --- */
.title-field {
flex: 1;
min-width: 0;
}
.title-field__input {
display: block;
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: var(--headings-h1-font-size, 32px);
font-weight: var(--headings-h1-font-weight, 700);
line-height: var(--headings-h1-line-height, 1.2);
letter-spacing: var(--headings-h1-letter-spacing, -0.015em);
color: var(--foreground);
padding: 0;
resize: none;
overflow: hidden;
font-family: inherit;
field-sizing: content;
}
.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;
}
/* When the legacy title UI is shown, hide the first H1 heading in BlockNote
to avoid duplicate title display. Otherwise the editor's H1 remains visible
and serves as the title surface. */
.title-section[data-title-ui-visible] ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] {
display: none;
}
.title-section[data-title-ui-visible] ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
display: none;
}
/* =============================================
Disable BlockNote/Mantine editor animations
=============================================

View File

@@ -54,8 +54,6 @@ interface EditorProps {
onUnarchiveNote?: (path: string) => void
onContentChange?: (path: string, content: string) => void
onSave?: () => void
/** Called when the user edits the title in TitleField. */
onTitleSync?: (path: string, newTitle: string) => void
/** Called when the user explicitly renames the filename from the breadcrumb. */
onRenameFilename?: (path: string, newFilenameStem: string) => void
canGoBack?: boolean
@@ -205,7 +203,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onTitleSync, onRenameFilename,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
} = props
@@ -256,7 +254,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawLatestContentRef={rawLatestContentRef}
onTitleChange={onTitleSync}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}
onKeepMine={onKeepMine}

View File

@@ -1,168 +0,0 @@
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 and title is focused', () => {
render(<TitleField title="My Note" filename="wrong-name.md" onTitleChange={() => {}} />)
// Not shown when unfocused
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
// Shown when focused
fireEvent.focus(screen.getByTestId('title-field-input'))
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('shows new title optimistically after commit (before prop updates)', () => {
const onChange = vi.fn()
const { rerender } = render(<TitleField title="Old Title" filename="old-title.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'New Title' } })
fireEvent.blur(input)
// After commit, should show new title even though prop is still "Old Title"
expect(input).toHaveValue('New Title')
// After prop updates to match, should still show new title
rerender(<TitleField title="New Title" filename="new-title.md" onTitleChange={onChange} />)
expect(input).toHaveValue('New Title')
})
it('resets optimistic title when prop changes from external source', () => {
const onChange = vi.fn()
const { rerender } = render(<TitleField title="Title A" filename="title-a.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
// Simulate external title change (e.g., tab switch)
rerender(<TitleField title="Title B" filename="title-b.md" onTitleChange={onChange} />)
expect(input).toHaveValue('Title B')
})
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)
})
it('resets stale localValue when title prop changes after focus', () => {
// Regression: creating a new note fires focus-editor before React re-renders,
// so handleFocus captures the OLD note's title into localValue.
// When React re-renders with the new title, localValue should be cleared.
const onChange = vi.fn()
const { rerender } = render(<TitleField title="Old Note" filename="old-note.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
// Simulate: focus fires while title prop is still "Old Note"
fireEvent.focus(input)
expect(input).toHaveValue('Old Note')
// React re-renders with new note's title (tab switched)
rerender(<TitleField title="Untitled note" filename="untitled-note.md" onTitleChange={onChange} />)
expect(input).toHaveValue('Untitled note')
})
it('shows vault-relative path (without .md) only when title is focused', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
// Path hidden by default
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
// Focus title → path appears
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
// No bare filename shown when path is visible
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
})
it('hides path on blur', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
const input = screen.getByTestId('title-field-input')
fireEvent.focus(input)
expect(screen.getByTestId('title-field-path')).toBeInTheDocument()
fireEvent.blur(input)
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('hides path for notes at vault root even when focused', () => {
render(<TitleField title="Root Note" filename="root-note.md" notePath="/Users/luca/Laputa/root-note.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('hides path when vaultPath is not provided', () => {
render(<TitleField title="Note" filename="note.md" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('resolves vault-relative path when paths differ by symlink prefix', () => {
// vaultPath uses symlink /Users/luca/... but notePath is canonical /Volumes/Jupiter/...
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Workspace/laputa-app/demo-vault-v2" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
})
it('handles vaultPath with trailing slash', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa/" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
})
})

View File

@@ -1,170 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { slugify } from '../hooks/useNoteCreation'
interface TitleFieldProps {
title: string
filename: string
editable?: boolean
/** Absolute path of the note file. */
notePath?: string
/** Absolute path of the vault root. */
vaultPath?: string
/** 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)
const [prevTitle, setPrevTitle] = useState(title)
// Reset local edit when the title prop changes (e.g. note switch).
// This prevents a stale handleFocus closure from locking in the old note's title
// when focus-editor fires before React re-renders with the new tab.
if (prevTitle !== title) {
setPrevTitle(title)
if (localValue !== null) setLocalValue(null)
}
// 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, notePath, vaultPath, onTitleChange }: TitleFieldProps) {
const inputRef = useRef<HTMLTextAreaElement>(null)
const [isFocused, setIsFocused] = useState(false)
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
useOptimisticTitle(title, onTitleChange)
// Auto-resize textarea to fit content (fallback for browsers without field-sizing: content)
const autoResize = useCallback(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}, [])
useEffect(() => {
autoResize()
// Schedule a second measurement after the browser has painted, to catch
// cases where scrollHeight is stale on the first read (e.g. tab switch).
const id = requestAnimationFrame(autoResize)
return () => cancelAnimationFrame(id)
}, [value, autoResize])
// Re-measure when container width changes (text may re-wrap)
useEffect(() => {
const el = inputRef.current
if (!el) return
const ro = new ResizeObserver(autoResize)
ro.observe(el)
return () => ro.disconnect()
}, [autoResize])
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$/, '')
// Compute vault-relative path (only for notes in subdirectories)
const relativePath = (() => {
if (!notePath || !vaultPath) return null
const vp = vaultPath.replace(/\/+$/, '')
const np = notePath.replace(/\.md$/, '')
if (np.startsWith(vp + '/')) return np.slice(vp.length + 1)
// Fallback: match by vault directory name for symlink-resolved paths
const vaultName = vp.split('/').pop()
if (!vaultName) return null
const segments = np.split('/')
const idx = segments.lastIndexOf(vaultName)
if (idx >= 0) return segments.slice(idx + 1).join('/')
return null
})()
const isSubdirectory = relativePath != null && relativePath.includes('/')
// Show path only when title is focused and note is in a subdirectory
const showRelativePath = isFocused && isSubdirectory
// Show filename hint when slug differs, but only when focused (not always)
const showFilename = isFocused && !showRelativePath && (isEditing || currentStem !== expectedSlug)
return (
<div className="title-field" data-testid="title-field">
<textarea
ref={inputRef}
className="title-field__input"
value={value}
rows={1}
onChange={e => { setEdit(e.target.value); autoResize() }}
onFocus={() => { setIsFocused(true); handleFocus() }}
onBlur={() => { setIsFocused(false); 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>
)}
{showRelativePath && (
<span className="title-field__path" data-testid="title-field-path" style={{ display: 'block', fontSize: 11, color: 'var(--muted-foreground)', marginTop: 2 }}>
{relativePath}
</span>
)}
</div>
)
}

View File

@@ -7,18 +7,6 @@ vi.mock('../BreadcrumbBar', () => ({
BreadcrumbBar: () => <div data-testid="breadcrumb-bar" />,
}))
vi.mock('../TitleField', () => ({
TitleField: () => <div data-testid="title-field-input" />,
}))
vi.mock('../NoteIcon', () => ({
NoteIcon: ({ icon }: { icon: string | null }) => (
icon
? <button type="button" data-testid="note-icon-display" />
: <button type="button" data-testid="note-icon-add" />
),
}))
vi.mock('../ArchivedNoteBanner', () => ({
ArchivedNoteBanner: () => <div data-testid="archived-banner" />,
}))
@@ -69,12 +57,7 @@ function createModel(overrides: Record<string, unknown> = {}) {
onKeepTheirs: vi.fn(),
breadcrumbBarRef: createRef<HTMLDivElement>(),
wordCount: 12,
titleSectionRef: createRef<HTMLDivElement>(),
showTitleSection: true,
hasDisplayIcon: false,
entryIcon: null,
vaultPath: '/vault',
onTitleChange: vi.fn(),
cssVars: {},
onNavigateWikilink: vi.fn(),
onEditorChange: vi.fn(),
@@ -95,27 +78,12 @@ function createModel(overrides: Record<string, unknown> = {}) {
}
describe('EditorContentLayout', () => {
it('does not render a standalone add-icon row when the note has no icon', () => {
it('never renders the legacy title section', () => {
const { container } = render(<EditorContentLayout {...createModel()} />)
expect(container.querySelector('.title-section__add-icon')).toBeNull()
expect(container.querySelector('.title-section__inline-add-icon')).not.toBeNull()
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
})
it('keeps the existing icon and title inside the same title row', () => {
const { container } = render(
<EditorContentLayout
{...createModel({
hasDisplayIcon: true,
entryIcon: 'rocket',
})}
/>,
)
const titleRow = container.querySelector('.title-section__row')
expect(titleRow?.querySelector('[data-testid="note-icon-display"]')).not.toBeNull()
expect(titleRow?.querySelector('[data-testid="title-field-input"]')).not.toBeNull()
expect(container.querySelector('.title-section')).toBeNull()
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
expect(screen.getByTestId('single-editor-view')).toBeInTheDocument()
})
it('shows the loading skeleton instead of stale editor chrome while switching tabs', () => {

View File

@@ -1,8 +1,6 @@
import type React from 'react'
import { DiffView } from '../DiffView'
import { BreadcrumbBar } from '../BreadcrumbBar'
import { TitleField } from '../TitleField'
import { NoteIcon } from '../NoteIcon'
import { ArchivedNoteBanner } from '../ArchivedNoteBanner'
import { ConflictNoteBanner } from '../ConflictNoteBanner'
import { RawEditorView } from '../RawEditorView'
@@ -99,14 +97,12 @@ function ActiveTabBreadcrumb({
barRef,
wordCount,
path,
showTitleSection,
actions,
}: {
activeTab: NonNullable<EditorContentModel['activeTab']>
barRef: React.RefObject<HTMLDivElement | null>
wordCount: number
path: string
showTitleSection: boolean
actions: BreadcrumbActions
}) {
return (
@@ -114,7 +110,6 @@ function ActiveTabBreadcrumb({
entry={activeTab.entry}
wordCount={wordCount}
barRef={barRef}
showTitleSection={showTitleSection}
showDiffToggle={actions.showDiffToggle}
diffMode={actions.diffMode}
diffLoading={actions.diffLoading}
@@ -136,50 +131,6 @@ function ActiveTabBreadcrumb({
)
}
function TitleSection({
activeTab,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
vaultPath,
onTitleChange,
}: Pick<
EditorContentModel,
'activeTab' | 'entryIcon' | 'hasDisplayIcon' | 'path' | 'showTitleSection' | 'titleSectionRef' | 'vaultPath' | 'onTitleChange'
>) {
if (!activeTab) return null
return (
<div ref={titleSectionRef} className="title-section" data-title-ui-visible={showTitleSection || undefined}>
{showTitleSection && (
<>
<div className="title-section__heading">
{!hasDisplayIcon && (
<div className="title-section__inline-add-icon">
<NoteIcon icon={null} editable />
</div>
)}
<div className={`title-section__row${hasDisplayIcon ? '' : ' title-section__row--no-icon'}`}>
{hasDisplayIcon && <NoteIcon icon={entryIcon} editable />}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable
notePath={path}
vaultPath={vaultPath}
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
/>
</div>
</div>
<div className="title-section__separator" />
</>
)}
</div>
)
}
function EditorChrome({
isArchived,
onUnarchiveNote,
@@ -213,52 +164,28 @@ function EditorChrome({
function EditorCanvas({
showEditor,
cssVars,
activeTab,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
vaultPath,
onTitleChange,
editor,
entries,
onNavigateWikilink,
onEditorChange,
isDeletedPreview,
vaultPath,
}: Pick<
EditorContentModel,
| 'showEditor'
| 'cssVars'
| 'activeTab'
| 'entryIcon'
| 'hasDisplayIcon'
| 'path'
| 'showTitleSection'
| 'titleSectionRef'
| 'vaultPath'
| 'onTitleChange'
| 'editor'
| 'entries'
| 'onNavigateWikilink'
| 'onEditorChange'
| 'isDeletedPreview'
| 'vaultPath'
>) {
if (!showEditor) return null
return (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="editor-content-wrapper">
<TitleSection
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
/>
<SingleEditorView
editor={editor}
entries={entries}
@@ -293,12 +220,7 @@ export function EditorContentLayout(model: EditorContentModel) {
onKeepTheirs,
breadcrumbBarRef,
wordCount,
titleSectionRef,
showTitleSection,
hasDisplayIcon,
entryIcon,
vaultPath,
onTitleChange,
cssVars,
onNavigateWikilink,
onEditorChange,
@@ -321,7 +243,6 @@ export function EditorContentLayout(model: EditorContentModel) {
barRef={breadcrumbBarRef}
wordCount={wordCount}
path={path}
showTitleSection={showTitleSection}
actions={{
diffMode: model.diffMode,
diffLoading: model.diffLoading,
@@ -365,14 +286,7 @@ export function EditorContentLayout(model: EditorContentModel) {
<EditorCanvas
showEditor={showEditor}
cssVars={cssVars}
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}

View File

@@ -46,47 +46,47 @@ function deriveState(tab: EditorContentTab | null, overrides?: Partial<VaultEntr
}
describe('deriveEditorContentState', () => {
it('hides the legacy title section when loaded content contains a top-level H1', () => {
it('marks loaded content with a top-level H1 as titled', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Legacy Project\n---\n# Legacy Project\n\nBody',
})
expect(state.hasH1).toBe(true)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
it('keeps the title section for notes without an H1', () => {
it('keeps editor content visible for notes without an H1', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Legacy Project\n---\nBody without a heading',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
it('hides the legacy title section when a frontmatter title drives the display title', () => {
it('keeps editor content visible when a legacy frontmatter title exists', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Spring 2026\nstatus: Active\n---\n## Goals',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
it('keeps the title section when the document title still comes from the filename', () => {
it('does not fall back to a separate title section when the filename drives the display title', () => {
const state = deriveState({
entry: baseEntry,
content: '---\nstatus: Active\n---\nBody without a heading',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(true)
expect(state.showEditor).toBe(true)
})
it('hides the title section for untitled drafts before they get an H1', () => {
it('keeps untitled drafts in the editor even before they get an H1', () => {
const draftEntry = {
...baseEntry,
path: '/vault/untitled-note-1700000000.md',
@@ -105,6 +105,6 @@ describe('deriveEditorContentState', () => {
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
})

View File

@@ -1,5 +1,5 @@
import type { NoteStatus, VaultEntry } from '../../types'
import { contentDefinesDisplayTitle, extractH1TitleFromContent } from '../../utils/noteTitle'
import { extractH1TitleFromContent } from '../../utils/noteTitle'
import { countWords } from '../../utils/wikilinks'
export interface EditorContentTab {
@@ -14,17 +14,11 @@ interface EditorContentStateInput {
activeStatus: NoteStatus
}
interface TitleSectionState {
hasDisplayTitle: boolean
hasH1: boolean
}
interface VisibilityState {
effectiveRawMode: boolean
isDeletedPreview: boolean
isNonMarkdownText: boolean
showEditor: boolean
showTitleSection: boolean
}
export interface EditorContentState {
@@ -35,7 +29,6 @@ export interface EditorContentState {
isNonMarkdownText: boolean
effectiveRawMode: boolean
showEditor: boolean
showTitleSection: boolean
path: string
wordCount: number
}
@@ -49,38 +42,18 @@ function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean {
return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false
}
function contentDefinesTitle(activeTab: EditorContentTab | null): boolean {
return activeTab ? contentDefinesDisplayTitle(activeTab.content) : false
}
function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean {
return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true
}
function resolveHasDisplayTitle(activeTab: EditorContentTab | null, hasH1: boolean): boolean {
return hasH1 || contentDefinesTitle(activeTab)
}
function deriveTitleSectionState(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): TitleSectionState {
const hasH1 = resolveHasH1(activeTab, freshEntry)
return {
hasDisplayTitle: resolveHasDisplayTitle(activeTab, hasH1),
hasH1,
}
}
function deriveVisibilityState(input: {
activeStatus: NoteStatus
activeTab: EditorContentTab | null
freshEntry: VaultEntry | undefined
hasDisplayTitle: boolean
rawMode: boolean
}): VisibilityState {
const {
activeStatus,
activeTab,
freshEntry,
hasDisplayTitle,
rawMode,
} = input
const isDeletedPreview = !!activeTab && !freshEntry
@@ -92,36 +65,23 @@ function deriveVisibilityState(input: {
isNonMarkdownText,
effectiveRawMode,
showEditor: !effectiveRawMode,
showTitleSection: !isDeletedPreview && !hasDisplayTitle && !isUnsavedUntitledDraft(activeTab, activeStatus),
}
}
function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean {
if (!activeTab) return false
if (!activeTab.entry.filename.startsWith('untitled-')) return false
return activeStatus === 'new' || activeStatus === 'unsaved' || activeStatus === 'pendingSave'
}
export function deriveEditorContentState({
activeTab,
entries,
rawMode,
activeStatus,
}: EditorContentStateInput): EditorContentState {
export function deriveEditorContentState(input: EditorContentStateInput): EditorContentState {
const { activeTab, entries, rawMode } = input
const freshEntry = findFreshEntry(activeTab, entries)
const titleState = deriveTitleSectionState(activeTab, freshEntry)
const hasH1 = resolveHasH1(activeTab, freshEntry)
const visibilityState = deriveVisibilityState({
activeStatus,
activeTab,
freshEntry,
hasDisplayTitle: titleState.hasDisplayTitle,
rawMode,
})
return {
freshEntry,
isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false,
hasH1: titleState.hasH1,
hasH1,
...visibilityState,
path: activeTab?.entry.path ?? '',
wordCount: activeTab ? countWords(activeTab.content) : 0,

View File

@@ -1,9 +1,8 @@
import type React from 'react'
import { useEffect, useRef } from 'react'
import { useRef } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { NoteStatus, VaultEntry } from '../../types'
import { useEditorTheme } from '../../hooks/useTheme'
import { resolveNoteIcon } from '../../utils/noteIcon'
import { deriveEditorContentState } from './editorContentState'
export interface Tab {
@@ -39,61 +38,17 @@ export interface EditorContentProps {
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawLatestContentRef?: React.MutableRefObject<string | null>
onTitleChange?: (path: string, newTitle: string) => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
}
function useBreadcrumbTitleVisibility({
showEditor,
showTitleSection,
path,
breadcrumbBarRef,
titleSectionRef,
}: {
showEditor: boolean
showTitleSection: boolean
path: string
breadcrumbBarRef: React.RefObject<HTMLDivElement | null>
titleSectionRef: React.RefObject<HTMLDivElement | null>
}) {
useEffect(() => {
if (!showEditor) return
const bar = breadcrumbBarRef.current
const titleSection = titleSectionRef.current
if (!bar || !titleSection) return
if (!showTitleSection) {
bar.setAttribute('data-title-hidden', '')
return () => {
bar.removeAttribute('data-title-hidden')
}
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) bar.removeAttribute('data-title-hidden')
else bar.setAttribute('data-title-hidden', '')
},
{ threshold: 0 },
)
observer.observe(titleSection)
return () => {
observer.disconnect()
bar.removeAttribute('data-title-hidden')
}
}, [path, showEditor, showTitleSection, breadcrumbBarRef, titleSectionRef])
}
export function useEditorContentModel(props: EditorContentProps) {
const {
activeTab,
entries,
rawMode,
activeStatus,
diffMode,
} = props
@@ -105,29 +60,17 @@ export function useEditorContentModel(props: EditorContentProps) {
effectiveRawMode,
showEditor: showContentEditor,
path,
showTitleSection,
wordCount,
} = deriveEditorContentState({
activeTab,
entries,
rawMode,
activeStatus,
activeStatus: props.activeStatus,
})
const showEditor = !diffMode && showContentEditor
const entryIcon = activeTab?.entry.icon ?? null
const hasDisplayIcon = resolveNoteIcon(entryIcon).kind !== 'none'
const titleSectionRef = useRef<HTMLDivElement | null>(null)
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
useBreadcrumbTitleVisibility({
showEditor,
showTitleSection,
path,
breadcrumbBarRef,
titleSectionRef,
})
return {
...props,
cssVars,
@@ -136,11 +79,7 @@ export function useEditorContentModel(props: EditorContentProps) {
effectiveRawMode,
forceRawMode: isNonMarkdownText || isDeletedPreview,
showEditor,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
breadcrumbBarRef,
wordCount,
}

View File

@@ -56,8 +56,7 @@ test('vault loads entries from fixture files @smoke', async ({ page }) => {
// Open a note and verify editor shows its content from disk
await openNote(page, 'Alpha Project')
// Verify the stable title field rather than the editor heading rendering.
await expect(page.getByTestId('title-field-input')).toHaveValue('Alpha Project', { timeout: 5_000 })
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
})
// ---------------------------------------------------------------------------

View File

@@ -30,12 +30,10 @@ test('create note via sidebar + button does not crash', async ({ page }) => {
await page.waitForSelector('[data-testid="sidebar-top-nav"]', { timeout: 10000 })
await page.waitForTimeout(500)
const plusButtons = page.locator('button[aria-label*="Create new"]')
if (await plusButtons.count() > 0) {
await plusButtons.first().click()
await page.waitForTimeout(2000)
}
await page.locator('button[title="Create new note"]').first().click()
await page.waitForTimeout(2000)
expect(errors).toHaveLength(0)
await expect(page.locator('[data-testid="title-field-input"]')).toBeVisible()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i)
})

View File

@@ -1,100 +0,0 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
/** Known BlockNote/ProseMirror error that fires during editor re-mount after rename. */
const KNOWN_EDITOR_ERRORS = ['isConnected']
function isKnownEditorError(msg: string): boolean {
return KNOWN_EDITOR_ERRORS.some(k => msg.includes(k))
}
/**
* Helper: create a new note and rename it via TitleField.
*/
async function createNoteWithTitle(page: import('@playwright/test').Page, title: string) {
// 1. Cmd+N → new "Untitled note"
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
// 2. Edit the title via TitleField
const titleInput = page.getByTestId('title-field-input')
await titleInput.waitFor({ timeout: 3000 })
await titleInput.click()
await titleInput.fill(title)
await titleInput.press('Enter')
// 3. Wait for async rename to complete
await page.waitForTimeout(1000)
}
test.describe('Note filename updates on title change + save', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
// Wait for startup toast to dismiss
await page.waitForTimeout(2500)
})
test('Cmd+N creates untitled note, editing TitleField triggers rename', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
await createNoteWithTitle(page, 'Test Note ABC')
// The toast should show "Renamed" after the TitleField commit
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('Renamed', { timeout: 5000 })
// Breadcrumb should show the new title
const breadcrumb = page.locator('span.truncate.font-medium')
await expect(breadcrumb.first()).toContainText('Test Note ABC', { timeout: 2000 })
// Cmd+S should NOT trigger another rename (filename already matches)
await sendShortcut(page, 's', ['Control'])
await page.waitForTimeout(500)
// No unexpected JS errors
expect(errors).toEqual([])
})
test('saving a note whose filename already matches does not trigger rename', async ({ page }) => {
// Click "All Notes" to show all notes regardless of section grouping
const allNotes = page.locator('text=All Notes').first()
await allNotes.click()
await page.waitForTimeout(500)
// Click on the first note in the note list
const noteListContainer = page.locator('[data-testid="note-list-container"]')
await noteListContainer.waitFor({ timeout: 5000 })
const noteItem = noteListContainer.locator('.cursor-pointer').first()
await noteItem.click()
await page.waitForTimeout(500)
// Cmd+S — should show "Saved" or "Nothing to save", NOT "Renamed"
await sendShortcut(page, 's', ['Control'])
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toBeVisible({ timeout: 3000 })
const toastText = await toast.textContent()
expect(toastText).not.toContain('Renamed')
})
test('rapid TitleField edits only rename to final title', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
await createNoteWithTitle(page, 'First Title')
// Edit TitleField again with final title
const titleInput = page.getByTestId('title-field-input')
await titleInput.click()
await titleInput.fill('Final Title')
await titleInput.press('Enter')
// Wait for rename
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('Renamed', { timeout: 5000 })
expect(errors).toEqual([])
})
})

View File

@@ -86,6 +86,23 @@ test('@smoke older notes with a document title do not render the legacy title se
await expect(page.locator('.title-section[data-title-ui-visible]')).toHaveCount(0)
})
test('deleting the H1 does not resurrect the legacy title section', async ({ page }) => {
await openNote(page, 'Alpha Project')
await openRawMode(page)
const rawContent = await getRawEditorContent(page)
expect(rawContent).toContain('# Alpha Project')
await setRawEditorContent(page, rawContent.replace('# Alpha Project\n\n', ''))
await page.keyboard.press('Meta+s')
await openBlockNoteMode(page)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('title-field-input')).toHaveCount(0)
await expect(page.locator('.title-section')).toHaveCount(0)
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toHaveCount(0)
})
test('@smoke edited H1 titles drive note list, search, and wikilink autocomplete', async ({ page }) => {
const updatedTitle = 'Updated Display Title'
const noteList = page.locator('[data-testid="note-list-container"]')

View File

@@ -1,37 +0,0 @@
import { test, expect } from '@playwright/test'
test.describe('Renaming a note updates wikilinks across the vault', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 })
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('title field rename triggers rename flow and shows toast', async ({ page }) => {
// 1. Click the first note in the list to open it
const noteListContainer = page.locator('[data-testid="note-list-container"]')
await noteListContainer.waitFor({ timeout: 5000 })
const firstNote = noteListContainer.locator('.cursor-pointer').first()
await firstNote.click()
await page.waitForTimeout(500)
// 2. Find the title field in the editor
const titleField = page.locator('[data-testid="title-field"] input, [data-testid="title-field"]')
await expect(titleField.first()).toBeVisible({ timeout: 5000 })
const originalTitle = await titleField.first().inputValue().catch(() => titleField.first().textContent())
expect(originalTitle).toBeTruthy()
// 3. Click the title field and change the title
await titleField.first().click()
await page.keyboard.press('Meta+a')
const newTitle = `${originalTitle} Renamed`
await page.keyboard.type(newTitle)
await page.keyboard.press('Tab') // blur to trigger rename
await page.waitForTimeout(1500)
// 4. Verify the toast message appeared (confirms rename flow ran)
const toast = page.getByText('Renamed', { exact: true })
await expect(toast).toBeVisible({ timeout: 5000 })
})
})

View File

@@ -1,99 +0,0 @@
import { test, expect } from '@playwright/test'
import { sendShortcut } from './helpers'
/** Known BlockNote/ProseMirror error that fires during editor re-mount after rename. */
const KNOWN_EDITOR_ERRORS = ['isConnected']
function isKnownEditorError(msg: string): boolean {
return KNOWN_EDITOR_ERRORS.some(k => msg.includes(k))
}
test.describe('Title/filename sync', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2500)
})
test('new note renamed via TitleField updates frontmatter and filename', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
// Create note via Cmd+N
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
// Edit the title via TitleField (not H1)
const titleInput = page.getByTestId('title-field-input')
await titleInput.waitFor({ timeout: 3000 })
await titleInput.click()
await titleInput.fill('Career Tracks Depend on Company Shape')
await titleInput.press('Enter')
// Wait for async rename
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('Renamed', { timeout: 5000 })
// Breadcrumb should show the new title
const breadcrumb = page.locator('span.truncate.font-medium')
await expect(breadcrumb.first()).toContainText('Career Tracks Depend on Company Shape', { timeout: 2000 })
expect(errors).toEqual([])
})
test('opening a note shows title from frontmatter in tab/breadcrumb', async ({ page }) => {
// Click "All Notes" to see all notes
const allNotes = page.locator('text=All Notes').first()
await allNotes.click()
await page.waitForTimeout(500)
// Click on the first note in the note list
const noteListContainer = page.locator('[data-testid="note-list-container"]')
await noteListContainer.waitFor({ timeout: 5000 })
const noteItem = noteListContainer.locator('.cursor-pointer').first()
const noteTitle = await noteItem.locator('.font-medium, .text-sm').first().textContent()
await noteItem.click()
await page.waitForTimeout(500)
// Breadcrumb should show the note title
if (noteTitle) {
const breadcrumb = page.locator('span.truncate.font-medium')
await expect(breadcrumb.first()).toContainText(noteTitle.trim(), { timeout: 2000 })
}
})
test('rename via TitleField updates both title and filename', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
// Create a note
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
// First rename via TitleField
const titleInput = page.getByTestId('title-field-input')
await titleInput.waitFor({ timeout: 3000 })
await titleInput.click()
await titleInput.fill('Original Title XYZ')
await titleInput.press('Enter')
// Wait for rename
await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5000 })
// Second rename via TitleField
await titleInput.click()
await titleInput.fill('Renamed Title XYZ')
await titleInput.press('Enter')
// Wait for second rename
await expect(page.locator('.fixed.bottom-8')).toContainText('Renamed', { timeout: 5000 })
// Breadcrumb should show the new title
const breadcrumb = page.locator('span.truncate.font-medium')
await expect(breadcrumb.first()).toContainText('Renamed Title XYZ', { timeout: 2000 })
expect(errors).toEqual([])
})
})

View File

@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'
import { executeCommand, openCommandPalette } from './helpers'
test('clicking + in type section creates note with that type', async ({ page }) => {
await page.goto('/')
@@ -11,26 +12,19 @@ test('clicking + in type section creates note with that type', async ({ page })
// Click the "+" button to create a new note
await page.click('[title="Create new note"]')
await page.waitForTimeout(1000)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 })
// The new note should have type-based naming (e.g., "Untitled project N")
const titleInput = page.locator('[data-testid="title-field-input"]')
if (await titleInput.count() > 0) {
const value = await titleInput.inputValue()
console.log('New note title:', value)
expect(value.toLowerCase()).toContain('project')
}
// The new note should use a type-specific untitled filename.
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-project-\d+/i)
// Toggle raw editor to see frontmatter
await page.keyboard.press('Meta+Shift+m')
await page.waitForTimeout(500)
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5000 })
const rawEditor = page.locator('.cm-content')
if (await rawEditor.count() > 0) {
const content = await rawEditor.textContent()
console.log('Raw content:', content?.substring(0, 200))
expect(content).toContain('type: Project')
}
const content = await rawEditor.textContent()
expect(content).toContain('type: Project')
})
test('clicking + in All Notes creates generic note', async ({ page }) => {
@@ -43,14 +37,9 @@ test('clicking + in All Notes creates generic note', async ({ page }) => {
// Click the "+" button
await page.click('[title="Create new note"]')
await page.waitForTimeout(1000)
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5000 })
// The new note should be a generic "Note" type
const titleInput = page.locator('[data-testid="title-field-input"]')
if (await titleInput.count() > 0) {
const value = await titleInput.inputValue()
console.log('New note title:', value)
expect(value.toLowerCase()).toContain('note')
expect(value.toLowerCase()).not.toContain('project')
}
// The new note should be a generic untitled note, not a typed one.
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+/i)
await expect(page.getByTestId('breadcrumb-filename-trigger')).not.toContainText(/untitled-project-\d+/i)
})

View File

@@ -75,8 +75,6 @@ test.describe('Wikilink insertion and navigation', () => {
const expected = targetTitle?.substring(0, 4) ?? ''
await expect.poll(async () => {
const titleInput = page.getByTestId('title-field-input')
if (await titleInput.count()) return await titleInput.inputValue()
const heading = page.locator('.bn-editor h1').first()
if (await heading.count()) return (await heading.textContent()) ?? ''
return ''