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 { ArticleDetail } from '@geo/shared-types'
|
||||||
|
import type { ClipboardPayload } from '@/lib/clipboard'
|
||||||
|
|
||||||
export interface ArticleActionState {
|
export interface ArticleActionState {
|
||||||
showPublish: boolean
|
showPublish: boolean
|
||||||
@@ -69,12 +72,32 @@ export function resolveArticleActionState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markdownRenderer = new MarkdownIt({
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
breaks: true,
|
||||||
|
})
|
||||||
|
|
||||||
export function buildArticleClipboardContent(
|
export function buildArticleClipboardContent(
|
||||||
detail: Pick<ArticleDetail, 'title' | 'markdown_content'>,
|
detail: Pick<ArticleDetail, 'title' | 'markdown_content'>,
|
||||||
): string {
|
): ClipboardPayload | null {
|
||||||
const title = detail.title?.trim() ?? ''
|
const title = detail.title?.trim() ?? ''
|
||||||
const markdown = detail.markdown_content?.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) {
|
if (!title) {
|
||||||
return markdown
|
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 {
|
try {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(payload.text)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -9,7 +39,7 @@ export async function copyTextToClipboard(text: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const textarea = document.createElement('textarea')
|
const textarea = document.createElement('textarea')
|
||||||
textarea.value = text
|
textarea.value = payload.text
|
||||||
textarea.setAttribute('readonly', 'true')
|
textarea.setAttribute('readonly', 'true')
|
||||||
textarea.style.position = 'fixed'
|
textarea.style.position = 'fixed'
|
||||||
textarea.style.top = '0'
|
textarea.style.top = '0'
|
||||||
@@ -30,3 +60,43 @@ export async function copyTextToClipboard(text: string): Promise<void> {
|
|||||||
document.body.removeChild(textarea)
|
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