diff --git a/web/src/app/drafts.ts b/web/src/app/drafts.ts index 37af86b..8472e3e 100644 --- a/web/src/app/drafts.ts +++ b/web/src/app/drafts.ts @@ -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('readonly', (s) => s.getAll()), + async move(from: string, to: string): Promise { + const transaction = (await db()).transaction('drafts', 'readwrite') + return new Promise((resolve, reject) => { + const store = transaction.objectStore('drafts') + const request: IDBRequest = 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 })) + }) + }, } diff --git a/web/src/app/workspace.test.ts b/web/src/app/workspace.test.ts index 1de3954..7bab06f 100644 --- a/web/src/app/workspace.test.ts +++ b/web/src/app/workspace.test.ts @@ -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', '

不应插入

') + 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') + }) }) diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index b00e00d..c8ea92b 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -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) { diff --git a/web/src/editor/paste.test.ts b/web/src/editor/paste.test.ts index d697a89..8625ad0 100644 --- a/web/src/editor/paste.test.ts +++ b/web/src/editor/paste.test.ts @@ -20,6 +20,7 @@ function editor(importImage: (url: string) => Promise = 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) }, diff --git a/web/src/editor/paste.ts b/web/src/editor/paste.ts index 9716457..e4de19e 100644 --- a/web/src/editor/paste.ts +++ b/web/src/editor/paste.ts @@ -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 importImage: (url: string) => Promise @@ -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 }