diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index a499a805..5b45b4b3 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -26,7 +26,7 @@ import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/li import { getProviderModelDisplayName } from '@/lib/modelDisplay'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import type { TurnGroupingContext } from './lib/turns/types'; -import { copyTextToClipboard } from '@/lib/clipboard'; +import { copyMarkdownToClipboard, copyTextToClipboard } from '@/lib/clipboard'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare'; @@ -771,7 +771,13 @@ const ChatMessage: React.FC = ({ const hasTextContent = messageTextContent.length > 0; const handleCopyMessage = React.useCallback(async () => { - const result = await copyTextToClipboard(messageTextContent); + let result; + if (isUser) { + result = await copyTextToClipboard(messageTextContent); + } else { + const { renderMarkdownSync } = await import('./markdown/markdownCore'); + result = await copyMarkdownToClipboard(messageTextContent, renderMarkdownSync(messageTextContent)); + } if (!result.ok) { return false; } diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 77606013..64d0d1b5 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -25,13 +25,13 @@ import { attachMarkdownInteractions, applyMarkdownCodeBlockWrapState, decorateMarkdown, - scheduleMarkdownCodeLineNumberSync, - syncMarkdownCodeLineNumbers, + getMarkdownCodeText, type DecorateContext, type DecorateLabels, type MermaidControlOptions, type MermaidRender, } from './markdown/decorate'; +import { findTextPosition } from './markdown/textPosition'; import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer'; import { BLOCK_PATH_TOKEN_RE, @@ -228,21 +228,6 @@ const isLikelyFilePath = (value: string): boolean => { return isLikelyFilePathValue(parsed.path); }; -const findTextPosition = (textNodes: Text[], targetOffset: number): { node: Text; offset: number } | null => { - let currentOffset = 0; - - for (const node of textNodes) { - const nextOffset = currentOffset + node.data.length; - if (targetOffset <= nextOffset) { - return { node, offset: Math.max(0, targetOffset - currentOffset) }; - } - currentOffset = nextOffset; - } - - const lastNode = textNodes.at(-1); - return lastNode ? { node: lastNode, offset: lastNode.data.length } : null; -}; - const unwrapBlockCodePathTokens = (container: HTMLElement): void => { const tokenSpans = container.querySelectorAll(BLOCK_PATH_TOKEN_SELECTOR); for (const span of Array.from(tokenSpans)) { @@ -304,11 +289,14 @@ const wrapBlockCodePathTokens = (container: HTMLElement): void => { const textNodes: Text[] = []; let currentNode = walker.nextNode(); while (currentNode) { - textNodes.push(currentNode as Text); + const textNode = currentNode as Text; + if (!textNode.parentElement?.closest('[data-md-code-line-number]')) { + textNodes.push(textNode); + } currentNode = walker.nextNode(); } - const fullText = codeBlock.textContent ?? ''; + const fullText = getMarkdownCodeText(codeBlock); if (!fullText.includes('.')) { codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true'); continue; @@ -326,8 +314,8 @@ const wrapBlockCodePathTokens = (container: HTMLElement): void => { } for (const { start, end, raw } of matches.reverse()) { - const startPosition = findTextPosition(textNodes, start); - const endPosition = findTextPosition(textNodes, end); + const startPosition = findTextPosition(textNodes, start, 'right'); + const endPosition = findTextPosition(textNodes, end, 'left'); if (!startPosition || !endPosition) { continue; } @@ -958,9 +946,6 @@ const useMorphdomMarkdown = ({ refreshMermaidViewers(); } - if (!ctx.deferCodeLineNumberSync) { - scheduleMarkdownCodeLineNumberSync(target); - } }); return () => { @@ -992,24 +977,6 @@ const useMorphdomMarkdown = ({ applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels); }, [containerRef, ctx.codeBlockLineWrap, ctx.deferCodeLineNumberSync, ctx.labels]); - React.useEffect(() => { - const container = containerRef.current; - const target = container?.querySelector('[data-markdown-content]') ?? container; - if (!target || typeof ResizeObserver === 'undefined') return; - let frame: number | null = null; - const observer = new ResizeObserver(() => { - if (frame !== null) window.cancelAnimationFrame(frame); - frame = window.requestAnimationFrame(() => { - frame = null; - syncMarkdownCodeLineNumbers(target); - }); - }); - observer.observe(target); - return () => { - observer.disconnect(); - if (frame !== null) window.cancelAnimationFrame(frame); - }; - }, [containerRef]); }; const markdownContentClassName = (variant: MarkdownVariant): string => diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts index 6af9efe7..51efc32c 100644 --- a/packages/ui/src/components/chat/markdown/decorate.ts +++ b/packages/ui/src/components/chat/markdown/decorate.ts @@ -81,6 +81,7 @@ const applyCodeBlockWrapState = (wrapper: HTMLElement, enabled: boolean, labels: const body = wrapper.querySelector('[data-md-code-body]'); const pre = wrapper.querySelector('pre'); const code = wrapper.querySelector('pre code'); + const lineContents = wrapper.querySelectorAll('[data-md-code-line-content]'); const wrapButton = wrapper.querySelector('[data-md-action="toggle-code-wrap"]'); wrapper.setAttribute('data-code-wrap', enabled ? 'true' : 'false'); body?.classList.toggle('overflow-x-auto', !enabled); @@ -97,6 +98,10 @@ const applyCodeBlockWrapState = (wrapper: HTMLElement, enabled: boolean, labels: code.style.whiteSpace = enabled ? 'pre-wrap' : 'pre'; code.style.overflowWrap = enabled ? 'anywhere' : 'normal'; } + for (const lineContent of Array.from(lineContents)) { + lineContent.style.whiteSpace = enabled ? 'pre-wrap' : 'pre'; + lineContent.style.overflowWrap = enabled ? 'anywhere' : 'normal'; + } if (wrapButton) { const title = enabled ? labels.disableCodeWrap : labels.enableCodeWrap; wrapButton.setAttribute('title', title); @@ -109,116 +114,72 @@ const applyCodeBlockWrapState = (wrapper: HTMLElement, enabled: boolean, labels: } }; -const createCodeLineNumbers = (pre: HTMLPreElement): HTMLDivElement => { - const gutter = document.createElement('div'); - gutter.setAttribute('data-md-code-line-numbers', ''); - gutter.setAttribute('aria-hidden', 'true'); - gutter.className = 'min-w-8 shrink-0 select-none border-r border-border/50 pr-3 text-right font-mono text-[13px] text-muted-foreground/45'; +const layoutCodeLines = (pre: HTMLPreElement): void => { + const code = pre.querySelector(':scope > code'); + if (!code || code.hasAttribute('data-md-code-lines')) return; - const text = pre.textContent ?? ''; - const lineCount = Math.max(1, text.endsWith('\n') ? text.split('\n').length - 1 : text.split('\n').length); - for (let index = 1; index <= lineCount; index += 1) { - const line = document.createElement('div'); - line.className = 'tabular-nums'; - line.textContent = String(index); - gutter.appendChild(line); + const text = code.textContent ?? ''; + const hasTrailingNewline = text.endsWith('\n'); + const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n'); + const sourceLines = lines.length > 0 ? lines : ['']; + const highlightedLines = Array.from(code.children).filter((child) => child.classList.contains('line')); + if ( + hasTrailingNewline + && highlightedLines.length === sourceLines.length + 1 + && highlightedLines.at(-1)?.textContent === '' + ) { + highlightedLines.pop(); } + const preserveHighlighting = highlightedLines.length === sourceLines.length; + const fragment = document.createDocumentFragment(); - return gutter; -}; + sourceLines.forEach((sourceLine, index) => { + const row = document.createElement('span'); + row.setAttribute('data-md-code-line', ''); -const collectTextNodes = (root: HTMLElement): Text[] => { - const nodes: Text[] = []; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); - let node = walker.nextNode(); - while (node) { - nodes.push(node as Text); - node = walker.nextNode(); - } - return nodes; -}; + const number = document.createElement('span'); + number.setAttribute('data-md-code-line-number', ''); + number.setAttribute('aria-hidden', 'true'); + number.textContent = String(index + 1); -const findTextPosition = (nodes: Text[], targetOffset: number): { node: Text; offset: number } | null => { - let offset = 0; - for (const node of nodes) { - const nextOffset = offset + node.data.length; - if (targetOffset <= nextOffset) { - return { node, offset: Math.max(0, targetOffset - offset) }; + const content = document.createElement('span'); + content.setAttribute('data-md-code-line-content', ''); + if (preserveHighlighting) { + const highlightedLine = highlightedLines[index]; + if (highlightedLine) content.append(...Array.from(highlightedLine.childNodes)); + } else { + content.textContent = sourceLine; } - offset = nextOffset; - } - const last = nodes.at(-1); - return last ? { node: last, offset: last.data.length } : null; -}; -export const syncMarkdownCodeLineNumbers = (root: HTMLElement): void => { - const wrappers = root.querySelectorAll('[data-component="markdown-code"]'); - for (const wrapper of Array.from(wrappers)) { - const code = wrapper.querySelector('pre code'); - const gutter = wrapper.querySelector('[data-md-code-line-numbers]'); - if (!code || !gutter) continue; - - const numbers = Array.from(gutter.children) as HTMLElement[]; - const text = code.textContent ?? ''; - const textNodes = collectTextNodes(code); - const codeStyle = window.getComputedStyle(code); - const lineHeight = Number.parseFloat(codeStyle.lineHeight) || 20; - gutter.style.fontFamily = codeStyle.fontFamily; - gutter.style.fontSize = codeStyle.fontSize; - gutter.style.lineHeight = `${lineHeight}px`; - let lineStart = 0; - - for (let index = 0; index < numbers.length; index += 1) { - const nextBreak = text.indexOf('\n', lineStart); - const lineEnd = nextBreak === -1 ? text.length : nextBreak; - const lineEl = numbers[index]; - if (!lineEl) continue; - - const start = findTextPosition(textNodes, lineStart); - const end = findTextPosition(textNodes, lineEnd); - if (!start || !end || lineStart === lineEnd) { - lineEl.style.height = `${lineHeight}px`; - lineEl.style.lineHeight = `${lineHeight}px`; - } else { - const range = document.createRange(); - range.setStart(start.node, start.offset); - range.setEnd(end.node, end.offset); - const rowTops: number[] = []; - for (const rect of Array.from(range.getClientRects())) { - if (rect.width === 0 && rect.height === 0) continue; - if (!rowTops.some((top) => Math.abs(top - rect.top) < 2)) { - rowTops.push(rect.top); - } - } - const height = Math.max(lineHeight, Math.max(1, rowTops.length) * lineHeight); - range.detach(); - lineEl.style.height = `${height}px`; - lineEl.style.lineHeight = `${lineHeight}px`; - } - - lineStart = lineEnd + 1; + row.append(number, content); + fragment.appendChild(row); + if (index < sourceLines.length - 1 || hasTrailingNewline) { + const lineBreak = document.createElement('span'); + lineBreak.setAttribute('data-md-code-line-break', ''); + lineBreak.textContent = '\n'; + fragment.appendChild(lineBreak); } - } -}; - -export const scheduleMarkdownCodeLineNumberSync = (root: HTMLElement): void => { - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => syncMarkdownCodeLineNumbers(root)); }); + + code.replaceChildren(fragment); + code.setAttribute('data-md-code-lines', ''); + code.toggleAttribute('data-md-code-trailing-newline', hasTrailingNewline); +}; + +export const getMarkdownCodeText = (code: HTMLElement): string => { + const lineContents = Array.from(code.querySelectorAll('[data-md-code-line-content]')); + if (lineContents.length === 0) return code.textContent ?? ''; + const text = lineContents.map((line) => line.textContent ?? '').join('\n'); + return code.hasAttribute('data-md-code-trailing-newline') ? `${text}\n` : text; }; export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: boolean, labels: DecorateLabels): void => { const wrappers = root.querySelectorAll('[data-component="markdown-code"]'); for (const wrapper of Array.from(wrappers)) { - const body = wrapper.querySelector('[data-md-code-body]'); const pre = wrapper.querySelector('pre'); - if (body && pre && !body.querySelector('[data-md-code-line-numbers]')) { - body.classList.add('flex', 'gap-3'); - body.insertBefore(createCodeLineNumbers(pre), pre); - } + if (pre) layoutCodeLines(pre); applyCodeBlockWrapState(wrapper, enabled, labels); } - scheduleMarkdownCodeLineNumberSync(root); }; const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => { @@ -276,28 +237,24 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => { header.appendChild(langLabel); const actions = document.createElement('div'); actions.className = 'flex items-center gap-1'; + actions.setAttribute('data-md-code-actions', ''); actions.appendChild(wrapBtn); actions.appendChild(copyBtn); header.appendChild(actions); const body = document.createElement('div'); body.setAttribute('data-md-code-body', ''); - body.className = ctx.deferCodeLineNumberSync ? 'px-3 py-2.5 overflow-x-auto' : 'flex gap-3 px-3 py-2.5 overflow-x-auto'; + body.className = 'px-3 py-2.5 overflow-x-auto'; parent.replaceChild(wrapper, pre); pre.style.margin = '0'; pre.style.background = 'transparent'; pre.classList.add('min-w-0', 'w-full', 'flex-1'); - if (!ctx.deferCodeLineNumberSync) { - body.appendChild(createCodeLineNumbers(pre)); - } + if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre); body.appendChild(pre); wrapper.appendChild(header); wrapper.appendChild(body); applyCodeBlockWrapState(wrapper, ctx.codeBlockLineWrap, ctx.labels); - if (!ctx.deferCodeLineNumberSync) { - scheduleMarkdownCodeLineNumberSync(wrapper); - } } }; @@ -582,7 +539,7 @@ export const attachMarkdownInteractions = ( // Copy code if (action === 'copy-code') { const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('code'); - const text = code?.textContent ?? ''; + const text = code ? getMarkdownCodeText(code) : ''; if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy)); return; } diff --git a/packages/ui/src/components/chat/markdown/textPosition.test.ts b/packages/ui/src/components/chat/markdown/textPosition.test.ts new file mode 100644 index 00000000..7d22c0ae --- /dev/null +++ b/packages/ui/src/components/chat/markdown/textPosition.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; + +import { findTextPosition } from './textPosition'; + +describe('findTextPosition', () => { + for (const { label, trailingNodes } of [ + { label: 'without a trailing newline', trailingNodes: [] }, + { label: 'with a trailing newline', trailingNodes: [{ data: '\n' }] }, + ]) { + test(`maps a second-line match start to visible content ${label}`, () => { + const firstLine = { data: 'output:' }; + const hiddenLineBreak = { data: '\n' }; + const secondLine = { data: 'src/index.ts:12' }; + const nodes = [firstLine, hiddenLineBreak, secondLine, ...trailingNodes]; + const startOffset = firstLine.data.length + hiddenLineBreak.data.length; + + expect(findTextPosition(nodes, startOffset, 'right')).toEqual({ node: secondLine, offset: 0 }); + expect(findTextPosition(nodes, startOffset + secondLine.data.length, 'left')).toEqual({ + node: secondLine, + offset: secondLine.data.length, + }); + }); + } + + test('keeps an end boundary on the preceding text node', () => { + const firstLine = { data: 'src/index.ts:12' }; + const hiddenLineBreak = { data: '\n' }; + + expect(findTextPosition([firstLine, hiddenLineBreak], firstLine.data.length, 'left')).toEqual({ + node: firstLine, + offset: firstLine.data.length, + }); + }); +}); diff --git a/packages/ui/src/components/chat/markdown/textPosition.ts b/packages/ui/src/components/chat/markdown/textPosition.ts new file mode 100644 index 00000000..dfa36e39 --- /dev/null +++ b/packages/ui/src/components/chat/markdown/textPosition.ts @@ -0,0 +1,22 @@ +type TextNodeLike = { data: string }; + +type TextBoundaryAffinity = 'left' | 'right'; + +export const findTextPosition = ( + textNodes: T[], + targetOffset: number, + affinity: TextBoundaryAffinity, +): { node: T; offset: number } | null => { + let currentOffset = 0; + + for (const node of textNodes) { + const nextOffset = currentOffset + node.data.length; + if (targetOffset < nextOffset || (targetOffset === nextOffset && affinity === 'left')) { + return { node, offset: Math.max(0, targetOffset - currentOffset) }; + } + currentOffset = nextOffset; + } + + const lastNode = textNodes.at(-1); + return lastNode ? { node: lastNode, offset: lastNode.data.length } : null; +}; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 6b580e6e..56b3bd70 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1541,6 +1541,7 @@ const AssistantMessageBody = React.memo(({ const paddingSize = 24; wrapper = document.createElement('div'); + wrapper.setAttribute('data-message-image-export', 'true'); wrapper.style.cssText = ` padding: ${paddingSize}px; background-color: ${resolvedBackgroundColor}; diff --git a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx index 469642cf..48aa5f79 100644 --- a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx @@ -236,7 +236,9 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti "[&_[data-component='markdown-code']>div]:p-0", "[&_[data-component='markdown-code']_pre]:inline", "[&_[data-component='markdown-code']_code]:inline", - "[&_[data-md-code-line-numbers]]:hidden", + "[&_[data-md-code-line]]:!inline", + "[&_[data-md-code-line-number]]:hidden", + "[&_[data-md-code-line-break]]:!inline", ] )} disableLinkSafety diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index d55fa2ab..afcc7d05 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1324,6 +1324,49 @@ html:not(.dark) .chat-scroll { background: transparent !important; } +.markdown-content [data-md-code-lines] { + display: block; + min-width: 100%; +} + +.markdown-content [data-md-code-line] { + display: grid; + grid-template-columns: 2rem minmax(0, 1fr); + column-gap: 0.75rem; + min-width: 100%; +} + +.markdown-content [data-md-code-line-number] { + align-self: stretch; + padding-right: 0.75rem; + border-right: 1px solid color-mix(in srgb, var(--border) 50%, transparent); + color: color-mix(in srgb, var(--muted-foreground) 45%, transparent); + font-variant-numeric: tabular-nums; + text-align: right; + user-select: none; +} + +.markdown-content [data-md-code-line-content] { + min-width: 0; +} + +.markdown-content [data-md-code-line-break] { + display: none; +} + +[data-message-image-export="true"] [data-md-code-body] { + overflow: visible !important; +} + +[data-message-image-export="true"] [data-md-code-line-content] { + white-space: pre-wrap !important; + overflow-wrap: anywhere !important; +} + +[data-message-image-export="true"] [data-md-code-actions] { + display: none !important; +} + .markdown-content [data-markdown="code-block-actions"] { pointer-events: auto; transition: opacity 0.15s ease; diff --git a/packages/ui/src/lib/clipboard.test.ts b/packages/ui/src/lib/clipboard.test.ts new file mode 100644 index 00000000..db92ba5c --- /dev/null +++ b/packages/ui/src/lib/clipboard.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, test } from 'bun:test'; + +import { copyMarkdownToClipboard } from './clipboard'; + +const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); +const originalClipboardItem = Object.getOwnPropertyDescriptor(globalThis, 'ClipboardItem'); + +const restoreGlobal = (name: 'navigator' | 'ClipboardItem', descriptor?: PropertyDescriptor): void => { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + Reflect.deleteProperty(globalThis, name); + } +}; + +afterEach(() => { + restoreGlobal('navigator', originalNavigator); + restoreGlobal('ClipboardItem', originalClipboardItem); +}); + +describe('copyMarkdownToClipboard', () => { + test('offers rich HTML and Markdown source in one clipboard item', async () => { + let writtenItem: { data: Record } | undefined; + class FakeClipboardItem { + static supports(type: string): boolean { + return type === 'text/markdown'; + } + + readonly data: Record; + + constructor(data: Record) { + this.data = data; + } + } + + Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, value: FakeClipboardItem }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + clipboard: { + write: async (items: Array<{ data: Record }>) => { + writtenItem = items[0]; + }, + }, + }, + }); + + const result = await copyMarkdownToClipboard('**bold**', '

bold

'); + + expect(result).toEqual({ ok: true, method: 'clipboard' }); + expect(Object.keys(writtenItem?.data ?? {}).sort()).toEqual(['text/html', 'text/markdown', 'text/plain']); + expect(await writtenItem?.data['text/plain']?.text()).toBe('**bold**'); + expect(await writtenItem?.data['text/markdown']?.text()).toBe('**bold**'); + expect(await writtenItem?.data['text/html']?.text()).toBe('

bold

'); + }); + + test('falls back to plain Markdown when a rich clipboard write fails', async () => { + let fallbackText = ''; + class FakeClipboardItem { + static supports(): boolean { + return false; + } + + constructor() {} + } + + Object.defineProperty(globalThis, 'ClipboardItem', { configurable: true, value: FakeClipboardItem }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + clipboard: { + write: async () => { + throw new Error('unsupported'); + }, + writeText: async (text: string) => { + fallbackText = text; + }, + }, + }, + }); + + const result = await copyMarkdownToClipboard('# title', '

title

'); + + expect(result).toEqual({ ok: true, method: 'clipboard' }); + expect(fallbackText).toBe('# title'); + }); +}); diff --git a/packages/ui/src/lib/clipboard.ts b/packages/ui/src/lib/clipboard.ts index 1002dea6..20b926dd 100644 --- a/packages/ui/src/lib/clipboard.ts +++ b/packages/ui/src/lib/clipboard.ts @@ -37,3 +37,27 @@ export async function copyTextToClipboard(text: string): Promise { + if ( + typeof navigator !== 'undefined' + && navigator.clipboard?.write + && typeof ClipboardItem !== 'undefined' + ) { + try { + const payload: Record = { + 'text/plain': new Blob([markdown], { type: 'text/plain' }), + 'text/html': new Blob([html], { type: 'text/html' }), + }; + if (typeof ClipboardItem.supports === 'function' && ClipboardItem.supports('text/markdown')) { + payload['text/markdown'] = new Blob([markdown], { type: 'text/markdown' }); + } + await navigator.clipboard.write([new ClipboardItem(payload)]); + return { ok: true, method: 'clipboard' }; + } catch { + // Fall back to plain Markdown when rich clipboard writes are unavailable. + } + } + + return copyTextToClipboard(markdown); +}