feat(article-copy): copy articles as rich HTML with plain-text fallback

Render markdown to sanitized HTML and write both text/html and text/plain
to the clipboard so pasted articles preserve formatting in rich editors,
falling back to selection-based copy and then plain text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 00:27:24 +08:00
parent 3e806a5a02
commit 0d690371d2
2 changed files with 97 additions and 4 deletions
+24 -1
View File
@@ -1,4 +1,7 @@
import DOMPurify from 'dompurify'
import MarkdownIt from 'markdown-it'
import type { ArticleDetail } from '@geo/shared-types'
import type { ClipboardPayload } from '@/lib/clipboard'
export interface ArticleActionState {
showPublish: boolean
@@ -69,12 +72,32 @@ export function resolveArticleActionState(
}
}
const markdownRenderer = new MarkdownIt({
html: true,
linkify: true,
breaks: true,
})
export function buildArticleClipboardContent(
detail: Pick<ArticleDetail, 'title' | 'markdown_content'>,
): string {
): ClipboardPayload | null {
const title = detail.title?.trim() ?? ''
const markdown = detail.markdown_content?.trim() ?? ''
const text = buildArticleMarkdownText(title, markdown)
if (!text) {
return null
}
const html = DOMPurify.sanitize(markdownRenderer.render(text))
return {
text,
html,
}
}
function buildArticleMarkdownText(title: string, markdown: string): string {
if (!title) {
return markdown
}
+73 -3
View File
@@ -1,7 +1,37 @@
export async function copyTextToClipboard(text: string): Promise<void> {
export interface ClipboardPayload {
text: string
html?: string
}
export async function copyTextToClipboard(content: string | ClipboardPayload): Promise<void> {
const payload = normalizeClipboardPayload(content)
if (payload.html?.trim()) {
try {
if (navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([payload.html], { type: 'text/html' }),
'text/plain': new Blob([payload.text], { type: 'text/plain' }),
}),
])
return
}
} catch {
// Fall back to the selection-based copy path below.
}
try {
copyHtmlWithSelection(payload)
return
} catch {
// Fall back to plain text copy below.
}
}
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
await navigator.clipboard.writeText(payload.text)
return
}
} catch {
@@ -9,7 +39,7 @@ export async function copyTextToClipboard(text: string): Promise<void> {
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.value = payload.text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.top = '0'
@@ -30,3 +60,43 @@ export async function copyTextToClipboard(text: string): Promise<void> {
document.body.removeChild(textarea)
}
}
function normalizeClipboardPayload(content: string | ClipboardPayload): ClipboardPayload {
if (typeof content === 'string') {
return { text: content }
}
return {
text: content.text,
html: content.html,
}
}
function copyHtmlWithSelection(payload: ClipboardPayload): void {
const container = document.createElement('div')
container.innerHTML = payload.html ?? ''
container.setAttribute('contenteditable', 'true')
container.style.position = 'fixed'
container.style.top = '0'
container.style.left = '-9999px'
container.style.opacity = '0'
container.style.pointerEvents = 'none'
document.body.appendChild(container)
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(container)
selection?.removeAllRanges()
selection?.addRange(range)
try {
const copied = document.execCommand('copy')
if (!copied) {
throw new Error('copy command failed')
}
} finally {
selection?.removeAllRanges()
document.body.removeChild(container)
}
}