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

+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