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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user