Compare commits
1 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b5f949c74 |
@@ -68,6 +68,12 @@
|
||||
color: var(--headings-h1-color);
|
||||
letter-spacing: var(--headings-h1-letter-spacing);
|
||||
}
|
||||
.editor__blocknote-container [data-content-type="heading"]:not([data-level])[data-is-empty-and-focused] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before,
|
||||
.editor__blocknote-container [data-content-type="heading"]:not([data-level])[data-is-only-empty-block] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before,
|
||||
.editor__blocknote-container [data-content-type="heading"][data-level="1"][data-is-empty-and-focused] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before,
|
||||
.editor__blocknote-container [data-content-type="heading"][data-level="1"][data-is-only-empty-block] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
content: "Title" !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-block-outer:has(> .bn-block > [data-content-type="heading"][data-level="2"]) {
|
||||
margin-top: var(--headings-h2-margin-top) !important;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const EDITABLE_SELECTOR = '.bn-editor [contenteditable="true"], .ProseMirror[contenteditable="true"]'
|
||||
const ROOT_EDITABLE_SELECTOR = '.ProseMirror[contenteditable="true"]'
|
||||
const FALLBACK_EDITABLE_SELECTOR = '.bn-editor [contenteditable="true"]'
|
||||
const MAX_FOCUS_ATTEMPTS = 12
|
||||
|
||||
interface TiptapChain {
|
||||
@@ -33,7 +34,7 @@ function selectFirstHeading(editor: FocusableEditor): void {
|
||||
}
|
||||
})
|
||||
|
||||
if (from === -1 || from >= to) return
|
||||
if (from === -1 || to === -1 || from > to) return
|
||||
tiptap.chain().setTextSelection({ from, to }).run()
|
||||
}
|
||||
|
||||
@@ -42,11 +43,43 @@ function hasEditableFocus(): boolean {
|
||||
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
||||
}
|
||||
|
||||
function focusEditableNode(): boolean {
|
||||
const editable = document.querySelector<HTMLElement>(EDITABLE_SELECTOR)
|
||||
if (!editable) return false
|
||||
function canFocusWindow(): boolean {
|
||||
return !navigator.userAgent.toLowerCase().includes('jsdom')
|
||||
}
|
||||
|
||||
function focusEditableCandidate(editable: HTMLElement): boolean {
|
||||
if (canFocusWindow()) {
|
||||
window.focus?.()
|
||||
}
|
||||
editable.focus()
|
||||
return true
|
||||
|
||||
if (hasEditableFocus()) return true
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (selection && editable.isContentEditable) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editable)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
editable.focus()
|
||||
}
|
||||
|
||||
return hasEditableFocus()
|
||||
}
|
||||
|
||||
function focusEditableNode(): boolean {
|
||||
const rootEditable = document.querySelector<HTMLElement>(ROOT_EDITABLE_SELECTOR)
|
||||
if (rootEditable && focusEditableCandidate(rootEditable)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const fallbackEditable = document.querySelector<HTMLElement>(FALLBACK_EDITABLE_SELECTOR)
|
||||
if (fallbackEditable && focusEditableCandidate(fallbackEditable)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function logFocusTiming(t0: number | undefined, label: 'focus' | 'focus+select'): void {
|
||||
|
||||
@@ -2,10 +2,10 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useEditorFocus } from './useEditorFocus'
|
||||
|
||||
function makeTiptapMock(hasHeading = true) {
|
||||
function makeTiptapMock(hasHeading = true, headingNodeSize = 15) {
|
||||
const chainResult = { setTextSelection: vi.fn().mockReturnThis(), run: vi.fn() }
|
||||
const descendantsMock = vi.fn().mockImplementation((cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => {
|
||||
if (hasHeading) cb({ type: { name: 'heading' }, nodeSize: 15 }, 2)
|
||||
if (hasHeading) cb({ type: { name: 'heading' }, nodeSize: headingNodeSize }, 2)
|
||||
})
|
||||
return {
|
||||
state: { doc: { descendants: descendantsMock } },
|
||||
@@ -222,5 +222,18 @@ describe('useEditorFocus', () => {
|
||||
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
|
||||
expect(tiptap._chainResult.run).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses selection to the caret for an empty H1', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true, 2)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).toHaveBeenCalled()
|
||||
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 3 })
|
||||
expect(tiptap._chainResult.run).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +51,11 @@ describe('extractEditorBody', () => {
|
||||
const content = '---\ntitle: My Project\ntype: Project\nstatus: Active\n---\n\n# My Project\n\n'
|
||||
expect(extractEditorBody(content)).toBe('# My Project\n\n')
|
||||
})
|
||||
|
||||
it('preserves an empty H1 for untitled-note content', () => {
|
||||
const content = '---\ntype: Note\nstatus: Active\n---\n\n# \n\n'
|
||||
expect(extractEditorBody(content)).toBe('# \n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getH1TextFromBlocks', () => {
|
||||
@@ -167,7 +172,14 @@ function makeTab(path: string, title: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeUntitledTab(path: string, title = 'Untitled Note 1') {
|
||||
function makeUntitledTab(path: string, title = 'Untitled Note 1', remainder = '') {
|
||||
return {
|
||||
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
|
||||
content: `---\ntype: Note\nstatus: Active\n---\n\n# \n\n${remainder}`,
|
||||
}
|
||||
}
|
||||
|
||||
function makeBlankBodyTab(path: string, title = 'Untitled Note 1') {
|
||||
return {
|
||||
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
|
||||
content: '---\ntype: Note\nstatus: Active\n---\n',
|
||||
@@ -270,7 +282,7 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const populatedTab = makeTab('a.md', 'Note A')
|
||||
const untitledTab = makeUntitledTab('untitled.md')
|
||||
const untitledTab = makeBlankBodyTab('untitled.md')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
@@ -290,6 +302,109 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders empty H1 untitled notes via TipTap HTML content', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const populatedTab = makeTab('a.md', 'Note A')
|
||||
const untitledTab = makeUntitledTab('untitled.md')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
mockEditor._tiptapEditor.commands.setContent.mockClear()
|
||||
|
||||
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
|
||||
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
|
||||
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<h1></h1><p></p>')
|
||||
})
|
||||
|
||||
it('renders empty H1 typed notes with template content under the title', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
mockEditor.blocksToHTMLLossy.mockReturnValue('<h2>Objective</h2><p></p>')
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const populatedTab = makeTab('a.md', 'Note A')
|
||||
const typedUntitledTab = makeUntitledTab('untitled.md', 'Untitled Project 1', '## Objective\n\n')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
mockEditor.tryParseMarkdownToBlocks.mockClear()
|
||||
mockEditor._tiptapEditor.commands.setContent.mockClear()
|
||||
|
||||
rerender({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n')
|
||||
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<h1></h1><h2>Objective</h2><p></p>')
|
||||
})
|
||||
|
||||
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const onContentChange = vi.fn()
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const populatedTab = makeTab('a.md', 'Note A')
|
||||
const untitledTab = makeUntitledTab('untitled.md')
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ tabs, activeTabPath }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, onContentChange,
|
||||
}),
|
||||
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md' } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
const queued: Array<() => void> = []
|
||||
vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((cb: VoidFunction) => {
|
||||
queued.push(cb)
|
||||
})
|
||||
|
||||
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md' })
|
||||
|
||||
expect(queued).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange()
|
||||
})
|
||||
|
||||
expect(onContentChange).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
queued.shift()?.()
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
@@ -76,11 +76,20 @@ function cacheEditorState(
|
||||
}
|
||||
|
||||
function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
|
||||
if (!preprocessed.trim()) {
|
||||
const trimmed = preprocessed.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return [{ type: 'paragraph', content: [] }]
|
||||
}
|
||||
|
||||
const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/)
|
||||
if (trimmed === '#') {
|
||||
return [
|
||||
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [], children: [] },
|
||||
{ type: 'paragraph', content: [], children: [] },
|
||||
]
|
||||
}
|
||||
|
||||
const h1OnlyMatch = trimmed.match(/^# (.+)$/)
|
||||
if (!h1OnlyMatch) return null
|
||||
|
||||
return [
|
||||
@@ -93,6 +102,21 @@ function isBlankBodyContent(content: string): boolean {
|
||||
return extractEditorBody(content).trim() === ''
|
||||
}
|
||||
|
||||
function extractBodyRemainderAfterEmptyH1(content: string): string | null {
|
||||
const body = extractEditorBody(content)
|
||||
const [firstLine, secondLine, ...rest] = body.split('\n')
|
||||
if (!firstLine) return null
|
||||
|
||||
const normalizedFirstLine = firstLine.trimEnd()
|
||||
if (normalizedFirstLine !== '#' && normalizedFirstLine !== '# ') return null
|
||||
|
||||
if (secondLine === '') {
|
||||
return rest.join('\n').trimStart()
|
||||
}
|
||||
|
||||
return [secondLine, ...rest].join('\n').trimStart()
|
||||
}
|
||||
|
||||
function blankParagraphBlocks(): EditorBlocks {
|
||||
return [{ type: 'paragraph', content: [], children: [] }]
|
||||
}
|
||||
@@ -187,6 +211,40 @@ function applyBlankStateToEditor(
|
||||
})
|
||||
}
|
||||
|
||||
function applyHtmlStateToEditor(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
html: string,
|
||||
suppressChangeRef: MutableRefObject<boolean>,
|
||||
) {
|
||||
suppressChangeRef.current = true
|
||||
try {
|
||||
editor._tiptapEditor.commands.setContent(html)
|
||||
} catch (err) {
|
||||
console.error('applyHtmlStateToEditor failed:', err)
|
||||
suppressChangeRef.current = false
|
||||
throw err
|
||||
}
|
||||
|
||||
queueMicrotask(() => { suppressChangeRef.current = false })
|
||||
requestAnimationFrame(() => {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
if (scrollEl) scrollEl.scrollTop = 0
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveEmptyHeadingHtml(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
content: string,
|
||||
): Promise<string | null> {
|
||||
const remainder = extractBodyRemainderAfterEmptyH1(content)
|
||||
if (remainder === null) return null
|
||||
if (!remainder.trim()) return '<h1></h1><p></p>'
|
||||
|
||||
const parsed = await parseMarkdownBlocks(editor, preProcessWikilinks(remainder))
|
||||
const withWikilinks = injectWikilinks(parsed)
|
||||
return `<h1></h1>${editor.blocksToHTMLLossy(withWikilinks as typeof parsed)}`
|
||||
}
|
||||
|
||||
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
|
||||
return activeTabPath
|
||||
? tabs.find(tab => tab.entry.path === activeTabPath)
|
||||
@@ -350,8 +408,13 @@ function scheduleTabSwap(options: {
|
||||
suppressChangeRef,
|
||||
} = options
|
||||
|
||||
suppressChangeRef.current = true
|
||||
|
||||
const doSwap = () => {
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
if (prevActivePathRef.current !== targetPath) {
|
||||
suppressChangeRef.current = false
|
||||
return
|
||||
}
|
||||
rawSwapPendingRef.current = false
|
||||
|
||||
if (isBlankBodyContent(activeTab.content)) {
|
||||
@@ -361,6 +424,21 @@ function scheduleTabSwap(options: {
|
||||
return
|
||||
}
|
||||
|
||||
void resolveEmptyHeadingHtml(editor, activeTab.content)
|
||||
.then((html) => {
|
||||
if (prevActivePathRef.current !== targetPath || !html) return
|
||||
applyHtmlStateToEditor(editor, html, suppressChangeRef)
|
||||
requestAnimationFrame(() => signalEditorTabSwapped(targetPath))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
suppressChangeRef.current = false
|
||||
console.error('Failed to render empty heading state:', err)
|
||||
})
|
||||
|
||||
if (extractBodyRemainderAfterEmptyH1(activeTab.content) !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
void resolveBlocksForTarget(editor, cache, targetPath, activeTab.content)
|
||||
.then(({ blocks, scrollTop }) => {
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
@@ -368,6 +446,7 @@ function scheduleTabSwap(options: {
|
||||
requestAnimationFrame(() => signalEditorTabSwapped(targetPath))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
suppressChangeRef.current = false
|
||||
console.error('Failed to parse/swap editor content:', err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,6 +211,22 @@ describe('buildNoteContent', () => {
|
||||
const content = buildNoteContent({ title: 'My Note', type: 'Note', status: 'Active', template: null })
|
||||
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
|
||||
})
|
||||
|
||||
it('prepends an empty H1 for untitled-note creation flows', () => {
|
||||
const content = buildNoteContent({ title: null, type: 'Note', status: 'Active', initialEmptyHeading: true })
|
||||
expect(content).toBe('---\ntype: Note\nstatus: Active\n---\n\n# \n\n')
|
||||
})
|
||||
|
||||
it('keeps the empty H1 ahead of templates for typed untitled notes', () => {
|
||||
const content = buildNoteContent({
|
||||
title: null,
|
||||
type: 'Project',
|
||||
status: 'Active',
|
||||
template: '## Objective\n\n## Notes\n\n',
|
||||
initialEmptyHeading: true,
|
||||
})
|
||||
expect(content).toBe('---\ntype: Project\nstatus: Active\n---\n\n# \n\n## Objective\n\n## Notes\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTemplate', () => {
|
||||
|
||||
@@ -132,6 +132,21 @@ describe('buildNoteContent', () => {
|
||||
const content = buildNoteContent({ title: 'P', type: 'Project', status: 'Active', template: '## Objective\n\n' })
|
||||
expect(content).toContain('## Objective')
|
||||
})
|
||||
|
||||
it('prepends an empty H1 when requested for untitled-note flows', () => {
|
||||
expect(buildNoteContent({ title: null, type: 'Note', status: 'Active', initialEmptyHeading: true })).toBe('---\ntype: Note\nstatus: Active\n---\n\n# \n\n')
|
||||
})
|
||||
|
||||
it('keeps the empty H1 before any template content', () => {
|
||||
const content = buildNoteContent({
|
||||
title: null,
|
||||
type: 'Project',
|
||||
status: 'Active',
|
||||
template: '## Objective\n\n',
|
||||
initialEmptyHeading: true,
|
||||
})
|
||||
expect(content).toBe('---\ntype: Project\nstatus: Active\n---\n\n# \n\n## Objective\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewNote', () => {
|
||||
@@ -247,6 +262,7 @@ describe('useNoteCreation hook', () => {
|
||||
expect(addEntry).toHaveBeenCalledTimes(1)
|
||||
expect(addEntry.mock.calls[0][0].title).toBe('Untitled Note 1700000000')
|
||||
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-note-1700000000.md')
|
||||
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntype: Note\nstatus: Active\n---\n\n# \n\n')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -352,6 +368,7 @@ describe('useNoteCreation hook', () => {
|
||||
expect(focusListener).toHaveBeenCalledTimes(1)
|
||||
const event = focusListener.mock.calls[0][0] as CustomEvent
|
||||
expect(event.detail.path).toMatch(/\/test\/vault\/untitled-note-\d+\.md$/)
|
||||
expect(event.detail.selectTitle).toBe(true)
|
||||
|
||||
window.removeEventListener('laputa:focus-editor', focusListener)
|
||||
})
|
||||
|
||||
@@ -89,15 +89,23 @@ export interface NoteContentParams {
|
||||
type: string
|
||||
status: string | null
|
||||
template?: string | null
|
||||
initialEmptyHeading?: boolean
|
||||
}
|
||||
|
||||
export function buildNoteContent({ title, type, status, template }: NoteContentParams): string {
|
||||
function buildNoteBody({ template, initialEmptyHeading }: Pick<NoteContentParams, 'template' | 'initialEmptyHeading'>): string {
|
||||
if (initialEmptyHeading) {
|
||||
return template ? `\n# \n\n${template}` : '\n# \n\n'
|
||||
}
|
||||
return template ? `\n${template}` : ''
|
||||
}
|
||||
|
||||
export function buildNoteContent({ title, type, status, template, initialEmptyHeading = false }: NoteContentParams): string {
|
||||
const lines = ['---']
|
||||
if (title) lines.push(`title: ${title}`)
|
||||
lines.push(`type: ${type}`)
|
||||
if (status) lines.push(`status: ${status}`)
|
||||
lines.push('---')
|
||||
const body = template ? `\n${template}` : ''
|
||||
const body = buildNoteBody({ template, initialEmptyHeading })
|
||||
return `${lines.join('\n')}\n${body}`
|
||||
}
|
||||
|
||||
@@ -246,12 +254,12 @@ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
|
||||
const template = resolveTemplate({ entries: deps.entries, typeName: noteType })
|
||||
const status = NO_STATUS_TYPES.has(noteType) ? null : 'Active'
|
||||
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
|
||||
const content = buildNoteContent({ title: null, type: noteType, status, template })
|
||||
const content = buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true })
|
||||
deps.openTabWithContent(entry, content)
|
||||
addEntryWithMock(entry, content, deps.addEntry)
|
||||
deps.trackUnsaved?.(entry.path)
|
||||
deps.markContentPending?.(entry.path, content)
|
||||
signalFocusEditor({ path: entry.path })
|
||||
signalFocusEditor({ path: entry.path, selectTitle: true })
|
||||
}
|
||||
|
||||
interface RelationshipCreateDeps {
|
||||
|
||||
@@ -36,6 +36,25 @@ function untitledRow(page: Page, typeLabel: string) {
|
||||
return page.getByText(new RegExp(`^Untitled ${typeLabel}(?: \\d+)?$`, 'i')).first()
|
||||
}
|
||||
|
||||
async function expectReadyEmptyTitleHeading(page: Page): Promise<void> {
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
const firstBlock = document.querySelector('.bn-block-content') as HTMLElement | null
|
||||
const inlineHeading = firstBlock?.querySelector('.bn-inline-content') as HTMLElement | null
|
||||
return {
|
||||
editorFocused: Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]')),
|
||||
contentType: firstBlock?.getAttribute('data-content-type') ?? null,
|
||||
placeholder: inlineHeading ? getComputedStyle(inlineHeading, '::before').content : null,
|
||||
}
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toEqual({
|
||||
editorFocused: true,
|
||||
contentType: 'heading',
|
||||
placeholder: '"Title"',
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Create note crash fix', () => {
|
||||
test.beforeEach(() => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
@@ -53,6 +72,7 @@ test.describe('Create note crash fix', () => {
|
||||
await selectSection(page, 'Projects')
|
||||
await createNoteFromListHeader(page)
|
||||
await expect(untitledRow(page, 'project')).toBeVisible({ timeout: 5_000 })
|
||||
await expectReadyEmptyTitleHeading(page)
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -66,6 +86,7 @@ test.describe('Create note crash fix', () => {
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'n', ['Control'])
|
||||
await expect(untitledRow(page, 'note')).toBeVisible({ timeout: 5_000 })
|
||||
await expectReadyEmptyTitleHeading(page)
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -78,6 +99,7 @@ test.describe('Create note crash fix', () => {
|
||||
await selectSection(page, 'Events')
|
||||
await createNoteFromListHeader(page)
|
||||
await expect(untitledRow(page, 'event')).toBeVisible({ timeout: 5_000 })
|
||||
await expectReadyEmptyTitleHeading(page)
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -93,6 +115,7 @@ test.describe('Create note crash fix', () => {
|
||||
await executeCommand(page, 'new procedure')
|
||||
|
||||
await expect(untitledRow(page, 'procedure')).toBeVisible({ timeout: 5_000 })
|
||||
await expectReadyEmptyTitleHeading(page)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,21 +18,17 @@ function slugifyTitle(title: string): string {
|
||||
}
|
||||
|
||||
async function createUntitledNote(page: Page): Promise<void> {
|
||||
await page.locator('body').click()
|
||||
await triggerMenuCommand(page, 'file-new-note')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+(?:-\d+)?/i, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toBe(true)
|
||||
await expectReadyEmptyTitleHeading(page)
|
||||
}
|
||||
|
||||
async function writeNewHeading(page: Page, title: string): Promise<void> {
|
||||
await page.keyboard.type(`# ${title}`)
|
||||
await page.keyboard.type(title)
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
@@ -62,6 +58,23 @@ async function expectEditorFocused(page: Page): Promise<void> {
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
async function expectReadyEmptyTitleHeading(page: Page): Promise<void> {
|
||||
await expectEditorFocused(page)
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const firstBlock = document.querySelector('.bn-block-content') as HTMLElement | null
|
||||
const inlineHeading = firstBlock?.querySelector('.bn-inline-content') as HTMLElement | null
|
||||
return {
|
||||
contentType: firstBlock?.getAttribute('data-content-type') ?? null,
|
||||
placeholder: inlineHeading ? getComputedStyle(inlineHeading, '::before').content : null,
|
||||
}
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toEqual({
|
||||
contentType: 'heading',
|
||||
placeholder: '"Title"',
|
||||
})
|
||||
}
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
@@ -90,11 +103,11 @@ test('@smoke new-note H1 auto-rename keeps the editor usable and leaves no untit
|
||||
|
||||
for (const [index, title] of titles.entries()) {
|
||||
await createUntitledNote(page)
|
||||
await expectEditorFocused(page)
|
||||
await writeNewHeading(page, title)
|
||||
await expectActiveFilename(page, slugifyTitle(title))
|
||||
await expectRenamedFile(tempVaultDir, `${slugifyTitle(title)}.md`)
|
||||
await expectEditorFocused(page)
|
||||
await expectFileContentContains(tempVaultDir, `${slugifyTitle(title)}.md`, `# ${title}`)
|
||||
|
||||
if (index === 0) {
|
||||
await page.keyboard.type(' focus-probe')
|
||||
|
||||
Reference in New Issue
Block a user