Convert pasted web content to Markdown and import remote images
This commit is contained in:
1 parent
84e89c8fe4
commit
8c13dfeaec
13 files changed
+820
-15
No files matched your search
@@ -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, ''))
|
||||
? '.replace(/>/g, '%3E') + '>)' : alt
|
||||
},
|
||||
})
|
||||
return service.turndown(fragment)
|
||||
}
|
||||
@@ -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	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
@@ -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(``, '')
|
||||
replaceText(opts, ``, '')
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user