fix: implement auto-save for editor content
The editor was not persisting changes to disk. Edits would be lost when closing and reopening a note. Changes: - Add save_note_content Tauri command (Rust) with read-only file check - Add save_note_content mock handler for browser testing - Add useAutoSave hook with 500ms debounce and flush-on-tab-switch - Wire up BlockNote onChange → markdown serialization → auto-save - Add restoreWikilinksInBlocks utility (reverse of injectWikilinks) to convert wikilink nodes back to [[target]] before markdown export - Extract shared walkBlocks helper to eliminate code duplication - Suppress onChange during programmatic content swaps (tab switching) - 12 new frontend tests + 5 new Rust tests (all 469 + 174 passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { preProcessWikilinks, injectWikilinks, splitFrontmatter, countWords } from './wikilinks'
|
||||
import { preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, splitFrontmatter, countWords } from './wikilinks'
|
||||
|
||||
interface TestBlock {
|
||||
type?: string
|
||||
@@ -196,3 +196,89 @@ describe('countWords', () => {
|
||||
expect(countWords(content)).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreWikilinksInBlocks', () => {
|
||||
it('converts wikilink nodes back to [[target]] text', () => {
|
||||
const blocks = [{
|
||||
content: [
|
||||
{ type: 'text', text: 'See ' },
|
||||
{ type: 'wikilink', props: { target: 'My Note' }, content: undefined },
|
||||
{ type: 'text', text: ' for details' },
|
||||
],
|
||||
}]
|
||||
|
||||
const result = restoreWikilinksInBlocks(blocks) as TestBlock[]
|
||||
expect(result[0].content).toHaveLength(3)
|
||||
expect(result[0].content![0]).toEqual({ type: 'text', text: 'See ' })
|
||||
expect(result[0].content![1]).toEqual({ type: 'text', text: '[[My Note]]' })
|
||||
expect(result[0].content![2]).toEqual({ type: 'text', text: ' for details' })
|
||||
})
|
||||
|
||||
it('handles multiple wikilinks in one block', () => {
|
||||
const blocks = [{
|
||||
content: [
|
||||
{ type: 'wikilink', props: { target: 'A' }, content: undefined },
|
||||
{ type: 'text', text: ' and ' },
|
||||
{ type: 'wikilink', props: { target: 'B' }, content: undefined },
|
||||
],
|
||||
}]
|
||||
|
||||
const result = restoreWikilinksInBlocks(blocks) as TestBlock[]
|
||||
expect(result[0].content![0]).toEqual({ type: 'text', text: '[[A]]' })
|
||||
expect(result[0].content![1]).toEqual({ type: 'text', text: ' and ' })
|
||||
expect(result[0].content![2]).toEqual({ type: 'text', text: '[[B]]' })
|
||||
})
|
||||
|
||||
it('recursively processes children blocks', () => {
|
||||
const blocks = [{
|
||||
content: [],
|
||||
children: [{
|
||||
content: [
|
||||
{ type: 'wikilink', props: { target: 'Nested' }, content: undefined },
|
||||
],
|
||||
}],
|
||||
}]
|
||||
|
||||
const result = restoreWikilinksInBlocks(blocks) as TestBlock[]
|
||||
expect(result[0].children![0].content![0]).toEqual({ type: 'text', text: '[[Nested]]' })
|
||||
})
|
||||
|
||||
it('passes through non-wikilink content unchanged', () => {
|
||||
const blocks = [{
|
||||
content: [
|
||||
{ type: 'text', text: 'plain text' },
|
||||
{ type: 'link', text: 'a link', href: 'http://example.com' },
|
||||
],
|
||||
}]
|
||||
|
||||
const result = restoreWikilinksInBlocks(blocks) as TestBlock[]
|
||||
expect(result[0].content![0]).toEqual({ type: 'text', text: 'plain text' })
|
||||
expect(result[0].content![1]).toEqual({ type: 'link', text: 'a link', href: 'http://example.com' })
|
||||
})
|
||||
|
||||
it('handles blocks without content', () => {
|
||||
const blocks = [{ type: 'heading', props: { level: 1 } }]
|
||||
const result = restoreWikilinksInBlocks(blocks as unknown[]) as TestBlock[]
|
||||
expect(result[0].type).toBe('heading')
|
||||
})
|
||||
|
||||
it('is the inverse of injectWikilinks for simple cases', () => {
|
||||
const WL_START = '\u2039WIKILINK:'
|
||||
const WL_END = '\u203A'
|
||||
|
||||
// Start with placeholder text
|
||||
const blocks = [{
|
||||
content: [
|
||||
{ type: 'text', text: `before ${WL_START}Target${WL_END} after` },
|
||||
],
|
||||
}]
|
||||
|
||||
// inject → restore should produce [[Target]] text
|
||||
const injected = injectWikilinks(blocks) as TestBlock[]
|
||||
const restored = restoreWikilinksInBlocks(injected) as TestBlock[]
|
||||
|
||||
// Find the text that was the wikilink
|
||||
const texts = restored[0].content!.map(n => n.text).join('')
|
||||
expect(texts).toContain('[[Target]]')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,17 +23,34 @@ interface InlineItem {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type ContentTransform = (content: InlineItem[]) => InlineItem[]
|
||||
|
||||
/** Walk blocks recursively, applying a transform to each block's inline content */
|
||||
function walkBlocks(blocks: unknown[], transform: ContentTransform, clone = false): unknown[] {
|
||||
return (blocks as BlockLike[]).map(block => {
|
||||
const b = clone ? { ...block } : block
|
||||
if (b.content && Array.isArray(b.content)) {
|
||||
b.content = transform(b.content)
|
||||
}
|
||||
if (b.children && Array.isArray(b.children)) {
|
||||
b.children = walkBlocks(b.children, transform, clone) as BlockLike[]
|
||||
}
|
||||
return b
|
||||
})
|
||||
}
|
||||
|
||||
/** Walk blocks and replace placeholder text with wikilink inline content */
|
||||
export function injectWikilinks(blocks: unknown[]): unknown[] {
|
||||
return (blocks as BlockLike[]).map(block => {
|
||||
if (block.content && Array.isArray(block.content)) {
|
||||
block.content = expandWikilinksInContent(block.content)
|
||||
}
|
||||
if (block.children && Array.isArray(block.children)) {
|
||||
block.children = injectWikilinks(block.children) as BlockLike[]
|
||||
}
|
||||
return block
|
||||
})
|
||||
return walkBlocks(blocks, expandWikilinksInContent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone blocks and convert wikilink inline content back to [[target]] text.
|
||||
* This is the reverse of injectWikilinks — used before blocksToMarkdownLossy
|
||||
* so that wikilinks survive the markdown round-trip.
|
||||
*/
|
||||
export function restoreWikilinksInBlocks(blocks: unknown[]): unknown[] {
|
||||
return walkBlocks(blocks, collapseWikilinksInContent, true)
|
||||
}
|
||||
|
||||
function expandWikilinksInContent(content: InlineItem[]): InlineItem[] {
|
||||
@@ -65,6 +82,18 @@ function expandWikilinksInContent(content: InlineItem[]): InlineItem[] {
|
||||
return result
|
||||
}
|
||||
|
||||
function collapseWikilinksInContent(content: InlineItem[]): InlineItem[] {
|
||||
const result: InlineItem[] = []
|
||||
for (const item of content) {
|
||||
if (item.type === 'wikilink' && item.props?.target) {
|
||||
result.push({ type: 'text', text: `[[${item.props.target}]]` })
|
||||
} else {
|
||||
result.push(item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Strip YAML frontmatter from markdown, returning [frontmatter, body] */
|
||||
export function splitFrontmatter(content: string): [string, string] {
|
||||
if (!content.startsWith('---')) return ['', content]
|
||||
|
||||
Reference in New Issue
Block a user