feat: support markdown in memos
ci / gitleaks (push) Successful in 16s
ci / docker (push) Successful in 4m16s

This commit is contained in:
2026-09-15 14:39:44 +08:00
parent 9e1847329b
commit fa11e5f432
4 changed files with 101 additions and 3 deletions
+11
View File
@@ -35,6 +35,17 @@ describe('memo shell integration', () => {
expect(app).toContain('<div class="field-label"><span>任务备注</span>') expect(app).toContain('<div class="field-label"><span>任务备注</span>')
}) })
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', () => { 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('const memoDetailOpen = ref(false)')
expect(app).toContain("'memo-detail-open': activeView==='memos' && memoDetailOpen") expect(app).toContain("'memo-detail-open': activeView==='memos' && memoDetailOpen")
@@ -24,6 +24,49 @@ async function mount(overrides: Record<string, unknown> = {}) {
afterEach(() => { vi.restoreAllMocks(); cleanups.splice(0).forEach((cleanup) => cleanup()) }) afterEach(() => { vi.restoreAllMocks(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
describe('MemoEditor', () => { describe('MemoEditor', () => {
it('renders and formats memo Markdown locally before the explicit save', async () => {
const request = vi.fn(async (): Promise<unknown> => 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<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
body.setSelectionRange(2, 4)
host.querySelector<HTMLButtonElement>('[aria-label="粗体"]')!.click()
await nextTick()
expect(body.value).toContain('# **标题**')
expect(request).not.toHaveBeenCalled()
host.querySelectorAll<HTMLButtonElement>('.markdown-mode-switch button')[1].click()
await nextTick()
const preview = host.querySelector<HTMLElement>('[aria-label="备忘录 Markdown 预览"]')!
expect(preview.innerHTML).toContain('<h1>')
expect(preview.innerHTML).toContain('<ul>')
expect(preview.innerHTML).toContain('<pre><code class="language-js">')
expect(request).not.toHaveBeenCalled()
host.querySelector<HTMLButtonElement>('.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<HTMLTextAreaElement>('[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('<strong>只读正文</strong>')
})
it('creates a local draft only on first save and locks click plus shortcut re-entry', async () => { it('creates a local draft only on first save and locks click plus shortcut re-entry', async () => {
const pending = deferred<Memo>() const pending = deferred<Memo>()
const request = vi.fn(() => pending.promise) const request = vi.fn(() => pending.promise)
+46 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ArchiveRestore, Trash2, X } from 'lucide-vue-next' import { ArchiveRestore, Bold, Code, Heading2, Italic, Link, List, ListChecks, ListOrdered, Quote, Trash2, X } from 'lucide-vue-next'
import { applyMarkdownFormat, renderMarkdown, type MarkdownFormat } from '../lib/task-utils'
export type MemoRecord = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null } export type MemoRecord = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
export type MemoDraft = { id: null; title: string; content: string; version: null; created_at: null; updated_at: null; deleted_at: null } export type MemoDraft = { id: null; title: string; content: string; version: null; created_at: null; updated_at: null; deleted_at: null }
@@ -19,11 +20,14 @@ const conflict = ref(false)
const titleInput = ref<HTMLInputElement | null>(null) const titleInput = ref<HTMLInputElement | null>(null)
const root = ref<HTMLElement | null>(null) const root = ref<HTMLElement | null>(null)
const initial = ref({ title: '', content: '' }) const initial = ref({ title: '', content: '' })
const memoPreview = ref(false)
const memoBodyEditor = ref<HTMLTextAreaElement | null>(null)
const dirty = computed(() => title.value !== initial.value.title || content.value !== initial.value.content) const dirty = computed(() => title.value !== initial.value.title || content.value !== initial.value.content)
function loadDraft(memo: MemoEditorValue) { function loadDraft(memo: MemoEditorValue) {
title.value = memo.title; content.value = memo.content title.value = memo.title; content.value = memo.content
initial.value = { title: memo.title, content: memo.content } initial.value = { title: memo.title, content: memo.content }
memoPreview.value = Boolean(memo.deleted_at)
saving.value = false; error.value = ''; conflict.value = false saving.value = false; error.value = ''; conflict.value = false
} }
watch(() => props.memo, loadDraft, { immediate: true }) watch(() => props.memo, loadDraft, { immediate: true })
@@ -143,6 +147,28 @@ async function purge() {
if (isCurrentOperation()) lifecycleBusy.value = false if (isCurrentOperation()) lifecycleBusy.value = false
} }
} }
function formatMemoBody(format: MarkdownFormat) {
if (props.memo.deleted_at) return
const editor = memoBodyEditor.value
const start = editor?.selectionStart ?? content.value.length
const end = editor?.selectionEnd ?? start
const formatted = applyMarkdownFormat(content.value, start, end, format)
content.value = formatted.value
memoPreview.value = false
void nextTick(() => {
const target = memoBodyEditor.value
if (!target) return
target.focus()
target.setSelectionRange(formatted.start, formatted.end)
})
}
function handleMemoBodyShortcut(event: KeyboardEvent) {
if (!(event.metaKey || event.ctrlKey)) return
const format = event.key.toLowerCase() === 'b' ? 'bold' : event.key.toLowerCase() === 'i' ? 'italic' : event.key.toLowerCase() === 'k' ? 'link' : null
if (!format) return
event.preventDefault()
formatMemoBody(format)
}
function keydown(event: KeyboardEvent) { function keydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
if (event.key === 'Escape') { event.preventDefault(); close(); return } if (event.key === 'Escape') { event.preventDefault(); close(); return }
@@ -164,7 +190,25 @@ defineExpose({ dirty, requestClose: close })
<header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header> <header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header>
<div class="memo-editor__fields"> <div class="memo-editor__fields">
<label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></label> <label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></label>
<label>正文<textarea v-model="content" rows="16" aria-label="备忘录正文" placeholder="写点什么…" :disabled="Boolean(memo.deleted_at)"></textarea></label> <div class="memo-field memo-markdown-field">
<div class="memo-field__label"><span>正文</span><span v-if="!memo.deleted_at" class="markdown-mode-switch"><button type="button" :aria-pressed="!memoPreview" :class="{active:!memoPreview}" @click="memoPreview=false">编辑</button><button type="button" :aria-pressed="memoPreview" :class="{active:memoPreview}" @click="memoPreview=true">预览</button></span></div>
<div v-if="!memoPreview && !memo.deleted_at" class="markdown-editor-shell memo-markdown-editor">
<div class="markdown-toolbar" role="toolbar" aria-label="备忘录 Markdown 格式">
<button type="button" aria-label="标题" title="标题" @click="formatMemoBody('heading')"><Heading2/></button>
<button type="button" aria-label="粗体" title="粗体" @click="formatMemoBody('bold')"><Bold/></button>
<button type="button" aria-label="斜体" title="斜体" @click="formatMemoBody('italic')"><Italic/></button>
<button type="button" aria-label="无序列表" title="无序列表" @click="formatMemoBody('bullet')"><List/></button>
<button type="button" aria-label="有序列表" title="有序列表" @click="formatMemoBody('ordered')"><ListOrdered/></button>
<button type="button" aria-label="待办" title="待办" @click="formatMemoBody('task')"><ListChecks/></button>
<button type="button" aria-label="链接" title="链接" @click="formatMemoBody('link')"><Link/></button>
<button type="button" aria-label="行内代码" title="行内代码" @click="formatMemoBody('code')"><Code/></button>
<button type="button" aria-label="代码块" title="代码块" class="markdown-codeblock" @click="formatMemoBody('codeblock')">{ }</button>
<button type="button" aria-label="引用" title="引用" @click="formatMemoBody('quote')"><Quote/></button>
</div>
<textarea ref="memoBodyEditor" v-model="content" rows="16" aria-label="备忘录正文" placeholder="写点什么…支持 Markdown" @keydown="handleMemoBodyShortcut"/>
</div>
<div v-else class="markdown-preview memo-markdown-preview" :class="{'markdown-preview-empty':!content.trim()}" role="region" aria-label="备忘录 Markdown 预览" v-html="content.trim() ? renderMarkdown(content) : '<p>暂无正文,切回编辑开始书写。</p>'"/>
</div>
<p v-if="error" role="alert" class="memo-editor__error">{{ error }} <button v-if="conflict" class="memo-reload" type="button" @click="reload">重新载入</button></p> <p v-if="error" role="alert" class="memo-editor__error">{{ error }} <button v-if="conflict" class="memo-reload" type="button" @click="reload">重新载入</button></p>
</div> </div>
<footer v-if="!memo.deleted_at"><button v-if="memo.id !== null" type="button" class="danger-text" :disabled="saving || lifecycleBusy" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || lifecycleBusy || !dirty" @click="save">{{ saving ? '正在保存' : '保存' }}</button></footer> <footer v-if="!memo.deleted_at"><button v-if="memo.id !== null" type="button" class="danger-text" :disabled="saving || lifecycleBusy" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || lifecycleBusy || !dirty" @click="save">{{ saving ? '正在保存' : '保存' }}</button></footer>
+1 -1
View File
@@ -1,3 +1,3 @@
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor>header,.memo-editor>footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor>header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor>header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-editor>footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor>footer button{min-height:44px}.memo-editor-scrim{display:none} .memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor>header,.memo-editor>footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor>header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor>header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor>footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor>footer button{min-height:44px}.memo-editor-scrim{display:none}
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor>footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}} @media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor>footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}} @media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}