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

+28
View File
@@ -18,9 +18,11 @@
"katex": "^0.18.11",
"mermaid": "^12.2.0",
"shiki": "^4.5.0",
"turndown": "^7.2.4",
"vue": "^3.5.44"
},
"devDependencies": {
"@types/turndown": "^5.0.6",
"@vitejs/plugin-vue": "^6.0.10",
"commonmark-spec": "^0.31.2",
"happy-dom": "^21.0.3",
@@ -398,6 +400,12 @@
"node": ">=22.12.0"
}
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@oxc-project/types": {
"version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
@@ -1107,6 +1115,13 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/turndown": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -3099,6 +3114,19 @@
"node": ">=6.10"
}
},
"node_modules/turndown": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
"license": "MIT",
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
},
"engines": {
"node": ">=18",
"npm": ">=9"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+2
View File
@@ -21,9 +21,11 @@
"katex": "^0.18.11",
"mermaid": "^12.2.0",
"shiki": "^4.5.0",
"turndown": "^7.2.4",
"vue": "^3.5.44"
},
"devDependencies": {
"@types/turndown": "^5.0.6",
"@vitejs/plugin-vue": "^6.0.10",
"commonmark-spec": "^0.31.2",
"happy-dom": "^21.0.3",
+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(() => {
+74
View File
@@ -0,0 +1,74 @@
import TurndownService from 'turndown'
export async function htmlToMarkdown(html: string, importImage: (url: string) => Promise<string>, onError: (error: unknown) => void): Promise<string> {
// template 中的外部资源和脚本保持惰性,不让剪贴板 HTML 进入活动页面。
const template = document.createElement('template')
template.innerHTML = html
const fragment = template.content
const base = fragment.querySelector('base[href]')?.getAttribute('href')
fragment.querySelectorAll('script,style,iframe,object,embed,template,meta,link,base').forEach((node) => node.remove())
if (base) {
for (const node of fragment.querySelectorAll('a[href],img[src]')) {
const attribute = node.tagName === 'A' ? 'href' : 'src'
try { node.setAttribute(attribute, new URL(node.getAttribute(attribute)!, base).href) }
catch (error) { onError(new Error('来源基址无效,已保留原链接', { cause: error })) }
}
}
const images = new Map<string, HTMLImageElement[]>()
for (const image of fragment.querySelectorAll('img')) {
let src = image.getAttribute('src') ?? ''
if (src.startsWith('//')) { src = 'https:' + src; image.setAttribute('src', src) }
if (!/^https?:\/\//i.test(src)) continue
const group = images.get(src) ?? []
group.push(image)
images.set(src, group)
}
const sources = [...images]
// 网页常有多张图片;限制并行数量,避免一次粘贴占满浏览器连接和服务端下载内存。
for (let offset = 0; offset < sources.length; offset += 3) {
await Promise.all(sources.slice(offset, offset + 3).map(async ([src, nodes]) => {
try {
const link = await importImage(src)
for (const node of nodes) node.setAttribute('src', link)
} catch (error) { onError(error) }
}))
}
const service = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-', codeBlockStyle: 'fenced' })
service.addRule('strikethrough', { filter: (node) => ['DEL', 'S', 'STRIKE'].includes(node.tagName), replacement: (content) => '~~' + content + '~~' })
service.addRule('table', {
filter: 'table',
replacement: (_content, node) => {
const rows = [...node.querySelectorAll('tr')].filter((row) => row.closest('table') === node).map((row) =>
[...row.cells].map((cell) =>
service.turndown(cell).replace(/\|/g, '\\|').replace(/\n+/g, ' '),
),
)
if (!rows.length) return ''
const columns = Math.max(...rows.map((row) => row.length))
if (!columns) return ''
const line = (cells: string[]) => '| ' + Array.from({ length: columns }, (_, index) => cells[index] ?? '').join(' | ') + ' |'
return '\n\n' + [line(rows[0]), line(Array(columns).fill('---')), ...rows.slice(1).map(line)].join('\n') + '\n\n'
},
})
// 链接目标用尖括号包裹,避免 URL 中的括号截断 Markdown;不保留可执行协议。
service.addRule('link', {
filter: 'a',
replacement: (content, node) => {
const href = node.getAttribute('href') ?? ''
return href && !/^[\s\u0000-\u0020]*(?:javascript|vbscript|data|file):/i.test(href.replace(/[\u0000-\u0020]/g, ''))
? '[' + content + '](<' + href.replace(/</g, '%3C').replace(/>/g, '%3E') + '>)' : content
},
})
service.addRule('image', {
filter: 'img',
replacement: (_content, node) => {
const src = node.getAttribute('src') ?? ''
const alt = (node.getAttribute('alt') ?? '').replace(/[\[\]\\]/g, '\\$&').replace(/\n/g, ' ')
return src && !/^(?:javascript|vbscript|file):/i.test(src.replace(/[\u0000-\u0020]/g, ''))
? '![' + alt + '](<' + src.replace(/</g, '%3C').replace(/>/g, '%3E') + '>)' : alt
},
})
return service.turndown(fragment)
}
+114
View File
@@ -0,0 +1,114 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { history, undo } from '@codemirror/commands'
import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { htmlToMarkdown } from './html-paste'
import { pasteImages } from './paste'
let view: EditorView | undefined
afterEach(() => { view?.destroy(); view = undefined; vi.restoreAllMocks() })
function paste(html: string, text: string) {
const data = new DataTransfer()
data.setData('text/html', html)
data.setData('text/plain', text)
view!.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
}
function editor(importImage: (url: string) => Promise<string> = async () => { throw new Error('此用例不下载图片') }) {
let pending = 0
const errors: unknown[] = []
view = new EditorView({ parent: document.body, state: EditorState.create({ doc: '', extensions: [history(), pasteImages({
upload: async () => { throw new Error('此用例不上传文件') },
importImage,
update: (change) => { const spec = change(view!.state); if (spec) view!.dispatch(spec) },
onError: (error) => errors.push(error),
onPending: (delta) => { pending += delta },
})] }) })
return { pending: () => pending, errors }
}
describe('网页粘贴', () => {
it('保留标题、列表、链接、代码块、删除线和表格,并移除活动内容', async () => {
const result = await htmlToMarkdown('<h2>标题</h2><ul><li>一</li><li><del>二</del></li></ul><a href="https://example.com/a(b)">链接</a><pre><code class="language-js">const x = 1\n</code></pre><table><tr><th>名称</th><th>值</th></tr><tr><td><strong>甲</strong></td><td>a|b</td></tr></table><script>alert(1)</script><style>body{}</style><a href="java&#9;script:alert(1)">危险链接</a>', async () => '', () => {})
expect(result).toContain('## 标题')
expect(result).toMatch(/-\s+一/)
expect(result).toContain('~~二~~')
expect(result).toContain('[链接](<https://example.com/a(b)>)')
expect(result).toContain('```js\nconst x = 1\n```')
expect(result).toContain('| 名称 | 值 |\n| --- | --- |\n| **甲** | a\\|b |')
expect(result).not.toContain('alert(1)')
expect(result).not.toContain('body{}')
expect(result).toContain('危险链接')
})
it('相同来源只下载一次,失败保留原图链接并报告原始原因', async () => {
const failure = new Error('来源拒绝下载')
const errors: unknown[] = []
const urls: string[] = []
const result = await htmlToMarkdown('<base href="https://example.com/docs/"><img alt="图" src="../a.png"><img src="//example.com/a.png"><img src="bad.png">', async (url) => {
urls.push(url)
if (url.endsWith('bad.png')) throw failure
return '笔记.assets/hash.png'
}, (error) => errors.push(error))
expect(urls.sort()).toEqual(['https://example.com/a.png', 'https://example.com/docs/bad.png'])
expect(result.match(/笔记.assets\/hash.png/g)).toHaveLength(2)
expect(result).toContain('https://example.com/docs/bad.png')
expect(errors).toEqual([failure])
})
it('转换期间的新输入保持原样,撤销一次移除转换后的整次粘贴', async () => {
const state = editor()
paste('<h1>标题</h1>', '标题')
view!.dispatch({ changes: { from: 0, insert: '之后输入' }, userEvent: 'input.type' })
await vi.waitFor(() => expect(state.pending()).toBe(0))
expect(view!.state.doc.toString()).toBe('# 标题之后输入')
undo(view!)
expect(view!.state.doc.toString()).toBe('之后输入')
undo(view!)
expect(view!.state.doc.toString()).toBe('')
expect(state.errors).toEqual([])
})
it('用户已修改粘贴区域时不覆盖新输入,Shift 粘贴保持纯文本', async () => {
const state = editor()
view!.dispatch({ changes: { from: 0, insert: '原选区' }, selection: { anchor: 0, head: 3 } })
paste('<h1>标题</h1>', '标题')
view!.dispatch({ changes: { from: 1, to: 3, insert: '新输入' } })
await vi.waitFor(() => expect(state.pending()).toBe(0))
expect(view!.state.doc.toString()).toBe('原新输入')
view!.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key: 'V', shiftKey: true, ctrlKey: true, bubbles: true }))
paste('<h1>纯文本</h1>', '纯文本')
expect(view!.state.doc.toString()).not.toContain('#')
expect(view!.state.doc.toString()).toContain('纯文本')
expect(state.pending()).toBe(0)
})
it('同一位置连续发起的异步粘贴保持发起顺序', async () => {
const state = editor()
paste('<b>第一段</b>', '第一段')
paste('<b>第二段</b>', '第二段')
await vi.waitFor(() => expect(state.pending()).toBe(0))
expect(view!.state.doc.toString()).toBe('**第一段****第二段**')
})
it('后发粘贴的图片先完成时仍保持顺序,撤销会取消尚未插入的粘贴', async () => {
const downloads = new Map<string, (link: string) => void>()
const state = editor((url) => new Promise((resolve) => downloads.set(url, resolve)))
paste('<b>第一段</b><img src="https://example.com/1.png">', '第一段')
paste('<b>第二段</b><img src="https://example.com/2.png">', '第二段')
await vi.waitFor(() => expect(downloads.size).toBe(2))
downloads.get('https://example.com/2.png')!('n.assets/2.png')
await vi.waitFor(() => expect(view!.state.doc.toString()).toContain('第二段'))
downloads.get('https://example.com/1.png')!('n.assets/1.png')
await vi.waitFor(() => expect(state.pending()).toBe(0))
expect(view!.state.doc.toString().indexOf('第一段')).toBeLessThan(view!.state.doc.toString().indexOf('第二段'))
paste('<b>取消的粘贴</b><img src="https://example.com/3.png">', '取消的粘贴')
await vi.waitFor(() => expect(downloads.size).toBe(3))
undo(view!)
downloads.get('https://example.com/3.png')!('n.assets/3.png')
await vi.waitFor(() => expect(state.pending()).toBe(0))
expect(view!.state.doc.toString()).not.toContain('取消的粘贴')
})
})
+77 -5
View File
@@ -1,11 +1,13 @@
import { Facet, type Extension } from '@codemirror/state'
import { isolateHistory } from '@codemirror/commands'
import { Facet, StateEffect, StateField, type EditorState, type Extension, type TransactionSpec } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
export interface PasteImagesOptions {
// 上传文件,返回写入 Markdown 的链接
upload: (file: File) => Promise<string>
// 按笔记身份修改占位,上传结束时该笔记可能已切到后台
replace: (text: string, replacement: string) => void
importImage: (url: string) => Promise<string>
// 按笔记身份更新,异步粘贴或上传结束时该笔记可能已切到后台。
update: (change: (state: EditorState) => TransactionSpec | undefined) => void
onError: (err: unknown) => void
// 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件)
onPending: (delta: 1 | -1) => void
@@ -13,6 +15,63 @@ export interface PasteImagesOptions {
const uploadOptions = Facet.define<PasteImagesOptions, PasteImagesOptions | undefined>({ combine: (v) => v[0] })
const pasteRange = StateEffect.define<{ id: object; from: number; to: number } | { id: object }>()
const pendingPastes = StateField.define<Map<object, { from: number; to: number }>>({
create: () => new Map(),
update(value, transaction) {
const next = new Map<object, { from: number; to: number }>()
let completed: object | undefined
for (const effect of transaction.effects) if (effect.is(pasteRange) && !('from' in effect.value)) completed = effect.value.id
let afterCompleted = false
for (const [id, range] of value) {
if (id === completed) { afterCompleted = true; continue }
let edited = transaction.isUserEvent('undo')
transaction.changes.iterChangedRanges((from, to) => {
if (from < range.to && to > range.from || from === to && from > range.from && from < range.to) edited = true
})
// 用户在等待期间编辑或撤销了粘贴内容,迟到的转换结果不能覆盖输入。
// 同一位置连续粘贴时按发起顺序定位,与图片下载完成的先后无关。
const association = range.from === range.to ? (afterCompleted ? 1 : -1) : 1
if (!edited) next.set(id, { from: transaction.changes.mapPos(range.from, association), to: transaction.changes.mapPos(range.to, range.from === range.to ? association : -1) })
}
for (const effect of transaction.effects) if (effect.is(pasteRange)) {
if ('from' in effect.value) next.set(effect.value.id, { from: effect.value.from, to: effect.value.to })
else next.delete(effect.value.id)
}
return next
},
})
function replaceText(opts: PasteImagesOptions, text: string, replacement: string) {
opts.update((state) => {
const at = state.doc.toString().indexOf(text)
if (at >= 0) return { changes: { from: at, to: at + text.length, insert: replacement } }
})
}
function insertHTML(view: EditorView, html: string, text: string, opts: PasteImagesOptions) {
const id = {}
const range = view.state.selection.main
opts.onPending(1)
view.dispatch({ effects: pasteRange.of({ id, from: range.from, to: range.to }) })
const insert = (markdown: string) => {
opts.update((state) => {
const pending = state.field(pendingPastes).get(id)
if (pending) return {
changes: { from: pending.from, to: pending.to, insert: markdown },
selection: state.selection.main.from === pending.from && state.selection.main.to === pending.to ? { anchor: pending.from + markdown.length } : undefined,
effects: pasteRange.of({ id }),
userEvent: 'input.paste',
annotations: isolateHistory.of('full'),
}
})
}
void import('./html-paste').then(({ htmlToMarkdown }) => htmlToMarkdown(html, opts.importImage, opts.onError)).catch((error) => {
opts.onError(new Error('网页转换失败,已保留剪贴板纯文本', { cause: error }))
return text
}).then(insert).catch(opts.onError).finally(() => opts.onPending(-1))
}
/**
* 插入图片文件(粘贴、拖入、从相册选择共用):先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接。
* 返回是否有图片被插入;编辑器未配置上传(pasteImages)时不处理
@@ -35,9 +94,9 @@ export function insertImageFiles(view: EditorView, files: File[], pos?: number):
opts.onPending(1)
opts
.upload(file)
.then((link) => opts.replace(urls[i], link))
.then((link) => replaceText(opts, urls[i], link))
.catch((err) => {
opts.replace(`![](${urls[i]})`, '')
replaceText(opts, `![](${urls[i]})`, '')
opts.onError(err)
})
.finally(() => {
@@ -50,10 +109,22 @@ export function insertImageFiles(view: EditorView, files: File[], pos?: number):
// 粘贴或拖入图片
export function pasteImages(opts: PasteImagesOptions): Extension {
let plain = false
return [
uploadOptions.of(opts),
pendingPastes,
EditorView.domEventHandlers({
keydown(e) { plain = e.shiftKey },
keyup(e) { plain = e.shiftKey },
blur() { plain = false },
paste(e, view) {
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)
e.preventDefault()
return true
}
// 个别浏览器/来源只在 items 中提供图片
let files = [...(e.clipboardData?.files ?? [])]
if (!files.length) {
@@ -64,6 +135,7 @@ export function pasteImages(opts: PasteImagesOptions): Extension {
return true
},
drop(e, view) {
if (view.state.readOnly) return false
const files = [...(e.dataTransfer?.files ?? [])]
const pos = view.posAtCoords(e)
if (!files.length || !insertImageFiles(view, files, pos ?? undefined)) return false