Convert pasted web content to Markdown and import remote images

This commit is contained in:
eezd committed 2026-10-20 18:18:00 +08:00
1 parent 84e89c8fe4
commit 8c13dfeaec
13 files changed
+820 -15

No files matched your search

+5 -1
View File
@@ -15,7 +15,7 @@ import { noteTitle } from './paths'
import { SIDEBAR_WIDTH, settings, updateSettings } from './settings'
import Sidebar from './Sidebar.vue'
import TabBar from './TabBar.vue'
import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace'
import { activeTab, flushAll, readyToReload, startWorkspace, stopWorkspace, toast, ws } from './workspace'
const emit = defineEmits<{ logout: [] }>()
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
@@ -43,6 +43,10 @@ function startResize(e: PointerEvent) {
async function logout() {
await flushAll()
if (!readyToReload()) {
toast('仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再退出')
return
}
try {
await api.logout()
} catch {
+3
View File
@@ -159,6 +159,9 @@ export const api = {
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
},
importImage: async (note: string, url: string): Promise<string> =>
(await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link,
shares: async (path?: string) =>
(await json<{ shares: Share[] }>(request('GET', '/api/v1/shares' + (path ? '?path=' + encodeURIComponent(path) : '')))).shares,
// expiresIn 为有效时长(秒),0 为永久
+27 -1
View File
@@ -6,7 +6,7 @@ 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, ws } from './workspace'
import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, openNote, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
@@ -136,6 +136,32 @@ describe('保存与关闭', () => {
await closeTab('race.md')
})
it('网页图片导入期间切换标签,转换内容仍保存到原笔记', async () => {
const v = await edit('网页.md')
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
const contents = new Map<string, string>()
vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { contents.set(path, content); return 'r2' })
let finish!: (link: string) => void
vi.spyOn(api, 'importImage').mockImplementation(() => new Promise<string>((resolve) => { finish = resolve }))
const data = new DataTransfer()
data.setData('text/plain', '网页标题')
data.setData('text/html', '<h1>网页标题</h1><img src="https://example.com/a.png">')
v.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
expect(await closeTab('网页.md')).toBe(false)
await openNote('other.md')
storeViewState('网页.md', v.state)
v.setState(editorStateOf('other.md')!)
finish('网页.assets/hash.png')
await vi.waitFor(() => expect(editorStateOf('网页.md')!.doc.toString()).toContain('网页.assets/hash.png'))
await flush('网页.md')
expect(contents.get('网页.md')).toContain('# 网页标题')
expect(contents.get('网页.md')).toContain('网页.assets/hash.png')
expect(v.state.doc.toString()).toBe('原文')
await closeTab('网页.md')
await closeTab('other.md')
})
it('切换标签后上传结果仍写回原笔记,上传期间不能关闭', async () => {
const v = await edit('image.md')
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
+13 -8
View File
@@ -188,21 +188,23 @@ function buildState(path: string, doc: string): EditorState {
}),
pasteImages({
upload: (file) => api.upload(ref.path, file),
replace: (text, replacement) => {
importImage: (url) => api.importImage(ref.path, url),
update: (change) => {
const shown = view?.state.facet(notePath) === ref
const state = shown ? view!.state : editorStates.get(ref.path)
if (!state) throw new Error('上传所属笔记的编辑状态不存在')
const at = state.doc.toString().indexOf(text)
if (at < 0) return // 用户已删除占位
const spec = { changes: { from: at, to: at + text.length, insert: replacement } }
if (!state) throw new Error('粘贴所属笔记的编辑状态不存在')
const spec = change(state)
if (!spec) return
if (shown) view!.dispatch(spec)
else {
editorStates.set(ref.path, state.update(spec).state)
scheduleSave(ref.path)
const transaction = state.update(spec)
editorStates.set(ref.path, transaction.state)
if (transaction.docChanged) scheduleSave(ref.path)
}
},
onError: (err) => toast('图片上传失败:' + errorText(err)),
onError: (err) => toast('粘贴或图片导入失败:' + (err instanceof Error ? err.message : errorText(err))),
onPending: (d) => {
if (d === 1 && slot(ref.path).uploads === 0) toast('正在处理粘贴或图片导入…')
slot(ref.path).uploads += d
},
}),
@@ -911,6 +913,9 @@ export async function startWorkspace() {
if (document.visibilityState === 'hidden') void flushAll(true)
})
window.addEventListener('pagehide', () => void flushAll(true))
window.addEventListener('beforeunload', (event) => {
if (!readyToReload()) event.preventDefault()
})
window.addEventListener('keydown', handleShortcut)
// 软键盘弹出、编辑区变矮后,让光标保持可见
trackVisualViewport(() => {