Preserve drafts and asset paths across note moves
This commit is contained in:
1 parent
8c13dfeaec
commit
3a8248e560
5 files changed
+127
-6
No files matched your search
@@ -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 }))
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
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)
|
||||
try {
|
||||
await api.move(from, to)
|
||||
} catch (err) {
|
||||
if (remapped) {
|
||||
ws.entries = snapshot
|
||||
remapOpenNotes(to, from)
|
||||
remapRecent(to, from)
|
||||
toast('移动失败:' + errorText(err))
|
||||
}
|
||||
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') {
|
||||
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) {
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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) => ``).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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user