Preserve drafts and asset paths across note moves

This commit is contained in:
eezd committed 2026-10-20 18:59:26 +08:00
1 parent 8c13dfeaec
commit 3a8248e560
5 files changed
+134 -13

No files matched your search

+15
View File
@@ -34,4 +34,19 @@ export const drafts = {
put: (d: Draft) => tx('readwrite', (s) => s.put(d)),
delete: (path: string) => tx('readwrite', (s) => s.delete(path)),
all: () => tx<Draft[]>('readonly', (s) => s.getAll()),
async move(from: string, to: string): Promise<void> {
const transaction = (await db()).transaction('drafts', 'readwrite')
return new Promise((resolve, reject) => {
const store = transaction.objectStore('drafts')
const request: IDBRequest<Draft[]> = store.getAll()
request.onsuccess = () => {
const moving = request.result.filter((draft) => draft.path === from || draft.path.startsWith(from + '/'))
for (const draft of moving) store.delete(draft.path)
// add 遇到目标路径已有草稿时中止整个事务,不能覆盖另一份未保存内容。
for (const draft of moving) store.add({ ...draft, path: to + draft.path.slice(from.length) })
}
transaction.oncomplete = () => resolve()
transaction.onabort = () => reject(new Error('迁移本地草稿事务失败', { cause: transaction.error }))
})
},
}
+66 -2
View File
@@ -1,16 +1,17 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EditorView } from '@codemirror/view'
import { insertImageFiles } from '../editor/paste'
import { api, ApiError } from './api'
import { drafts, type Draft } from './drafts'
import { connectEvents } from './events'
import { pluginEvents } from '../plugins/registry'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, openNote, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, move, openNote, refreshTree, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
let view: EditorView | undefined
beforeEach(() => { vi.spyOn(drafts, 'move').mockResolvedValue(undefined) })
afterEach(() => {
stopWorkspace()
attachView(null)
@@ -34,6 +35,7 @@ describe('事件同步', () => {
tree.mockResolvedValue([{ path: 'to/note.md', dir: false, size: 0, mtime: 0 }])
vi.mocked(connectEvents).mock.lastCall![0].onEvents([{ type: 'moved', from: 'from', path: 'to' }, { type: 'tree' }])
await vi.waitFor(() => expect(ws.active).toBe('to/note.md'))
expect(drafts.move).toHaveBeenCalledWith('from', 'to')
expect(editorStateOf('from/note.md')).toBeUndefined()
expect(editorStateOf('to/note.md')!.doc.toString()).toBe('尚未保存的输入')
await flush('to/note.md')
@@ -183,4 +185,66 @@ describe('保存与关闭', () => {
await closeTab('image.md')
await closeTab('other.md')
})
it('图片上传期间不能移动笔记或其父目录', async () => {
const v = await edit('upload/note.md')
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'upload/note.md', dir: false, size: 0, mtime: 0 }])
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
vi.spyOn(api, 'writeFile').mockResolvedValue('r2')
const moving = vi.spyOn(api, 'move').mockResolvedValue(new Response())
let finish!: (link: string) => void
vi.spyOn(api, 'upload').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:pending-move')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])
await move('upload/note.md', 'renamed.md')
await move('upload', 'renamed-folder')
expect(moving).not.toHaveBeenCalled()
expect(ws.active).toBe('upload/note.md')
await refreshTree()
finish('note.assets/hash.png')
await vi.waitFor(() => expect(v.state.doc.toString()).toContain('note.assets/hash.png'))
await closeTab('upload/note.md')
})
it('移动期间拒绝新的图片和网页粘贴,保留原选区内容', async () => {
const v = await edit('moving.md')
v.dispatch({ selection: { anchor: 0, head: v.state.doc.length } })
let finish!: (response: Response) => void
vi.spyOn(api, 'move').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'moved.md', dir: false, size: 0, mtime: 0 }])
const upload = vi.spyOn(api, 'upload')
const importing = vi.spyOn(api, 'importImage')
const moving = move('moving.md', 'moved.md')
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
expect(insertImageFiles(v, [new File(['image'], 'image.png', { type: 'image/png' })])).toBe(true)
const data = new DataTransfer()
data.setData('text/html', '<h1>不应插入</h1>')
data.setData('text/plain', '不应插入')
v.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
expect(v.state.doc.toString()).toBe('原文')
expect(v.state.selection.main.from).toBe(0)
expect(v.state.selection.main.to).toBe(2)
expect(upload).not.toHaveBeenCalled()
expect(importing).not.toHaveBeenCalled()
finish(new Response())
await moving
await refreshTree()
await closeTab('moved.md')
})
it('草稿迁移失败时不移动文件,服务端失败时恢复草稿路径', async () => {
await edit('draft-move.md')
vi.spyOn(api, 'tree').mockResolvedValue([{ path: 'draft-move.md', dir: false, size: 0, mtime: 0 }])
const moving = vi.spyOn(api, 'move').mockRejectedValue(new ApiError(500, '移动失败'))
vi.mocked(drafts.move).mockRejectedValueOnce(new Error('草稿不可写'))
await move('draft-move.md', 'draft-moved.md')
expect(moving).not.toHaveBeenCalled()
expect(ws.active).toBe('draft-move.md')
await move('draft-move.md', 'draft-moved.md')
expect(ws.active).toBe('draft-move.md')
expect(drafts.move).toHaveBeenLastCalledWith('draft-moved.md', 'draft-move.md')
await refreshTree()
await closeTab('draft-move.md')
})
})
+47 -9
View File
@@ -187,6 +187,11 @@ function buildState(path: string, doc: string): EditorState {
if (u.docChanged || u.selectionSet) editorListeners.forEach((fn) => fn())
}),
pasteImages({
canPaste: () => {
if (!slot(ref.path).paused) return true
toast('笔记正在移动,请完成后再粘贴或上传图片')
return false
},
upload: (file) => api.upload(ref.path, file),
importImage: (url) => api.importImage(ref.path, url),
update: (change) => {
@@ -688,21 +693,43 @@ export async function move(from: string, to: string) {
return
}
await withOp(async () => {
const busy = () => affected(from).some((p) => slots.get(p)?.uploads || slots.get(p)?.paused)
if (busy()) {
toast('笔记正在处理粘贴、上传或移动,请完成后再移动')
return
}
// 先保存待保存的修改,确保移动的是最新内容;移动期间暂停保存,避免写到旧路径
await Promise.all(affected(from).map((p) => flush(p)))
// 等待保存时仍可能开始粘贴,须在取得移动占用前再次检查。
if (busy()) {
toast('笔记正在处理粘贴、上传或移动,请完成后再移动')
return
}
const paused = affected(from).map(slot)
paused.forEach((s) => s.paused++)
const snapshot = ws.entries
ws.entries = ws.entries.map((e) => ({ ...e, path: remapPath(e.path, from, to) ?? e.path }))
remapOpenNotes(from, to)
remapRecent(from, to)
let draftsMoved = false
let remapped = false
try {
// 先确认持久草稿能够迁移;失败时服务器与界面均保留原路径。
await drafts.move(from, to)
draftsMoved = true
remapped = true
ws.entries = ws.entries.map((e) => ({ ...e, path: remapPath(e.path, from, to) ?? e.path }))
remapOpenNotes(from, to)
remapRecent(from, to)
await api.move(from, to)
} catch (err) {
ws.entries = snapshot
remapOpenNotes(to, from)
remapRecent(to, from)
toast('移动失败:' + errorText(err))
if (remapped) {
ws.entries = snapshot
remapOpenNotes(to, from)
remapRecent(to, from)
}
if (draftsMoved) {
try { await drafts.move(to, from) }
catch (cause) { toast('恢复原路径草稿失败:' + (cause instanceof Error ? cause.message : errorText(cause))) }
}
toast('移动失败:' + (err instanceof Error ? err.message : errorText(err)))
return
} finally {
paused.forEach((s) => s.paused--)
@@ -785,8 +812,19 @@ async function handleEvents(evs: VaultEvent[]) {
// 自己保存产生的事件 rev 与本地一致;有本地修改时不覆盖,交由下次保存的冲突检测处理
if (tab && tab.rev !== ev.rev) await syncOpenTab(tab)
} else if (ev.type === 'moved') {
if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!)
remapRecent(ev.from!, ev.path!)
const paused = affected(ev.from!).map(slot)
paused.forEach((s) => s.paused++)
try {
// 已发出的保存仍可能写入旧键,先等其草稿事务结束。
await Promise.all(paused.map((s) => s.inflight))
try { await drafts.move(ev.from!, ev.path!) }
catch (cause) {
// 外部文件已移动,草稿失败不能阻止编辑器跟随;保留原记录并明确提示。
toast('迁移本地草稿失败,原路径草稿已保留:' + (cause instanceof Error ? cause.message : errorText(cause)))
}
if (affected(ev.from!).length && !affected(ev.path!).length) remapOpenNotes(ev.from!, ev.path!)
remapRecent(ev.from!, ev.path!)
} finally { paused.forEach((s) => s.paused--) }
} else if (ev.type === 'deleted') {
const tab = tabOf(ev.path!)
if (tab && tab.status === 'ready' && opsInFlight === 0) {
+1
View File
@@ -20,6 +20,7 @@ function editor(importImage: (url: string) => Promise<string> = async () => { th
let pending = 0
const errors: unknown[] = []
view = new EditorView({ parent: document.body, state: EditorState.create({ doc: '', extensions: [history(), pasteImages({
canPaste: () => true,
upload: async () => { throw new Error('此用例不上传文件') },
importImage,
update: (change) => { const spec = change(view!.state); if (spec) view!.dispatch(spec) },
+5 -2
View File
@@ -3,6 +3,8 @@ import { Facet, StateEffect, StateField, type EditorState, type Extension, type
import { EditorView } from '@codemirror/view'
export interface PasteImagesOptions {
// 文件移动期间不能开始异步粘贴或上传,避免响应中的资源链接属于旧路径。
canPaste: () => boolean
// 上传文件,返回写入 Markdown 的链接
upload: (file: File) => Promise<string>
importImage: (url: string) => Promise<string>
@@ -74,12 +76,13 @@ function insertHTML(view: EditorView, html: string, text: string, opts: PasteIma
/**
* 插入图片文件(粘贴、拖入、从相册选择共用):先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接。
* 返回是否有图片被插入;编辑器未配置上传(pasteImages)时不处理
* 返回是否已处理图片插入(包括因操作冲突被拒绝);未配置上传时不处理
*/
export function insertImageFiles(view: EditorView, files: File[], pos?: number): boolean {
const opts = view.state.facet(uploadOptions)
const images = files.filter((f) => f.type.startsWith('image/'))
if (!opts || !images.length) return false
if (!opts.canPaste()) return true
const urls = images.map((f) => URL.createObjectURL(f))
const text = urls.map((u) => `![](${u})`).join('\n')
const range = view.state.selection.main
@@ -121,7 +124,7 @@ export function pasteImages(opts: PasteImagesOptions): Extension {
if (view.state.readOnly || plain) return false
const html = e.clipboardData?.getData('text/html')
if (html) {
insertHTML(view, html, e.clipboardData!.getData('text/plain'), opts)
if (opts.canPaste()) insertHTML(view, html, e.clipboardData!.getData('text/plain'), opts)
e.preventDefault()
return true
}