From 0d690371d2bcb8a2729f13b75a2e6b9c8f64c20a Mon Sep 17 00:00:00 2001 From: liangxu Date: Sun, 24 May 2026 00:27:24 +0800 Subject: [PATCH] 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) --- .../admin-web/src/lib/article-list-actions.ts | 25 +++++- apps/admin-web/src/lib/clipboard.ts | 76 ++++++++++++++++++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/apps/admin-web/src/lib/article-list-actions.ts b/apps/admin-web/src/lib/article-list-actions.ts index 46be15f..ca033a1 100644 --- a/apps/admin-web/src/lib/article-list-actions.ts +++ b/apps/admin-web/src/lib/article-list-actions.ts @@ -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, -): 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 } diff --git a/apps/admin-web/src/lib/clipboard.ts b/apps/admin-web/src/lib/clipboard.ts index 9281d4a..ee8dba6 100644 --- a/apps/admin-web/src/lib/clipboard.ts +++ b/apps/admin-web/src/lib/clipboard.ts @@ -1,7 +1,37 @@ -export async function copyTextToClipboard(text: string): Promise { +export interface ClipboardPayload { + text: string + html?: string +} + +export async function copyTextToClipboard(content: string | ClipboardPayload): Promise { + 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 { } 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 { 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) + } +}