diff --git a/frontend/src/MemoIntegration.test.ts b/frontend/src/MemoIntegration.test.ts
index dc5193d..ca44ce8 100644
--- a/frontend/src/MemoIntegration.test.ts
+++ b/frontend/src/MemoIntegration.test.ts
@@ -35,6 +35,17 @@ describe('memo shell integration', () => {
expect(app).toContain('
任务备注')
})
+ it('adds a compact, reusable Markdown editor and preview to memo details', () => {
+ expect(editor).toContain("import { applyMarkdownFormat, renderMarkdown")
+ expect(editor).toContain('aria-label="备忘录 Markdown 格式"')
+ expect(editor).toContain('aria-label="备忘录 Markdown 预览"')
+ expect(editor).toContain('@keydown="handleMemoBodyShortcut"')
+ expect(editor).toContain('v-if="!memoPreview && !memo.deleted_at"')
+ expect(css).toContain('.memo-markdown-field{display:grid;gap:7px')
+ expect(css).toContain('.memo-markdown-editor .markdown-toolbar{')
+ expect(css).toContain('.memo-markdown-preview{min-height:250px')
+ })
+
it('tracks editor state in the shell, reserves desktop space, hides the FAB, and marks mobile background regions inert', () => {
expect(app).toContain('const memoDetailOpen = ref(false)')
expect(app).toContain("'memo-detail-open': activeView==='memos' && memoDetailOpen")
diff --git a/frontend/src/components/MemoEditor.test.ts b/frontend/src/components/MemoEditor.test.ts
index ed92587..8b2e069 100644
--- a/frontend/src/components/MemoEditor.test.ts
+++ b/frontend/src/components/MemoEditor.test.ts
@@ -24,6 +24,49 @@ async function mount(overrides: Record
= {}) {
afterEach(() => { vi.restoreAllMocks(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
describe('MemoEditor', () => {
+ it('renders and formats memo Markdown locally before the explicit save', async () => {
+ const request = vi.fn(async (): Promise => memo)
+ const markdownMemo = { ...memo, content: '# 标题\n\n- 父项\n - 子项\n\n```js\nconst x = 1\n```' }
+ const { host } = await mount({ memo: markdownMemo, request })
+ const body = host.querySelector('[aria-label="备忘录正文"]')!
+ body.setSelectionRange(2, 4)
+ host.querySelector('[aria-label="粗体"]')!.click()
+ await nextTick()
+ expect(body.value).toContain('# **标题**')
+ expect(request).not.toHaveBeenCalled()
+
+ host.querySelectorAll('.markdown-mode-switch button')[1].click()
+ await nextTick()
+ const preview = host.querySelector('[aria-label="备忘录 Markdown 预览"]')!
+ expect(preview.innerHTML).toContain('')
+ expect(preview.innerHTML).toContain('')
+ expect(preview.innerHTML).toContain('
')
+ expect(request).not.toHaveBeenCalled()
+
+ host.querySelector('.memo-save')!.click()
+ await flush()
+ expect(request).toHaveBeenCalledWith('/memos/m1', expect.objectContaining({
+ method: 'PATCH',
+ body: JSON.stringify({ title: '旧标题', content: body.value, version: 3 }),
+ }))
+ })
+
+ it('supports Markdown shortcuts and keeps deleted memos preview-only', async () => {
+ const active = await mount({ memo: { ...memo, content: '正文' } })
+ const body = active.host.querySelector('[aria-label="备忘录正文"]')!
+ body.setSelectionRange(0, 2)
+ const shortcut = new KeyboardEvent('keydown', { key: 'i', ctrlKey: true, bubbles: true, cancelable: true })
+ body.dispatchEvent(shortcut)
+ await nextTick()
+ expect(shortcut.defaultPrevented).toBe(true)
+ expect(body.value).toBe('*正文*')
+
+ const deleted = await mount({ memo: { ...memo, content: '**只读正文**', deleted_at: '2026-09-15T00:00:00Z' } })
+ expect(deleted.host.querySelector('[aria-label="备忘录正文"]')).toBeNull()
+ expect(deleted.host.querySelector('[aria-label="备忘录 Markdown 格式"]')).toBeNull()
+ expect(deleted.host.querySelector('[aria-label="备忘录 Markdown 预览"]')?.innerHTML).toContain('只读正文')
+ })
+
it('creates a local draft only on first save and locks click plus shortcut re-entry', async () => {
const pending = deferred()
const request = vi.fn(() => pending.promise)
diff --git a/frontend/src/components/MemoEditor.vue b/frontend/src/components/MemoEditor.vue
index 4a67464..6354055 100644
--- a/frontend/src/components/MemoEditor.vue
+++ b/frontend/src/components/MemoEditor.vue
@@ -1,6 +1,7 @@