fix(ui): stabilize Markdown code block line layout (#2444)

* fix(ui): stabilize markdown code block layout

* fix(ui): respect text range boundary affinity

* test(ui): cover trailing text range boundaries
This commit is contained in:
ChangeHow
2026-07-26 20:40:33 +03:00
committed by GitHub
parent b6cdf7b6a7
commit e52d692ac0
10 changed files with 289 additions and 146 deletions
@@ -26,7 +26,7 @@ import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/li
import { getProviderModelDisplayName } from '@/lib/modelDisplay'; import { getProviderModelDisplayName } from '@/lib/modelDisplay';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { TurnGroupingContext } from './lib/turns/types'; import type { TurnGroupingContext } from './lib/turns/types';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyMarkdownToClipboard, copyTextToClipboard } from '@/lib/clipboard';
import { FadeInOnReveal } from './message/FadeInOnReveal'; import { FadeInOnReveal } from './message/FadeInOnReveal';
import { streamPerfCount } from '@/stores/utils/streamDebug'; import { streamPerfCount } from '@/stores/utils/streamDebug';
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare'; import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare';
@@ -771,7 +771,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const hasTextContent = messageTextContent.length > 0; const hasTextContent = messageTextContent.length > 0;
const handleCopyMessage = React.useCallback(async () => { 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) { if (!result.ok) {
return false; return false;
} }
@@ -25,13 +25,13 @@ import {
attachMarkdownInteractions, attachMarkdownInteractions,
applyMarkdownCodeBlockWrapState, applyMarkdownCodeBlockWrapState,
decorateMarkdown, decorateMarkdown,
scheduleMarkdownCodeLineNumberSync, getMarkdownCodeText,
syncMarkdownCodeLineNumbers,
type DecorateContext, type DecorateContext,
type DecorateLabels, type DecorateLabels,
type MermaidControlOptions, type MermaidControlOptions,
type MermaidRender, type MermaidRender,
} from './markdown/decorate'; } from './markdown/decorate';
import { findTextPosition } from './markdown/textPosition';
import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer'; import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer';
import { import {
BLOCK_PATH_TOKEN_RE, BLOCK_PATH_TOKEN_RE,
@@ -228,21 +228,6 @@ const isLikelyFilePath = (value: string): boolean => {
return isLikelyFilePathValue(parsed.path); 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 unwrapBlockCodePathTokens = (container: HTMLElement): void => {
const tokenSpans = container.querySelectorAll<HTMLElement>(BLOCK_PATH_TOKEN_SELECTOR); const tokenSpans = container.querySelectorAll<HTMLElement>(BLOCK_PATH_TOKEN_SELECTOR);
for (const span of Array.from(tokenSpans)) { for (const span of Array.from(tokenSpans)) {
@@ -304,11 +289,14 @@ const wrapBlockCodePathTokens = (container: HTMLElement): void => {
const textNodes: Text[] = []; const textNodes: Text[] = [];
let currentNode = walker.nextNode(); let currentNode = walker.nextNode();
while (currentNode) { 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(); currentNode = walker.nextNode();
} }
const fullText = codeBlock.textContent ?? ''; const fullText = getMarkdownCodeText(codeBlock);
if (!fullText.includes('.')) { if (!fullText.includes('.')) {
codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true'); codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
continue; continue;
@@ -326,8 +314,8 @@ const wrapBlockCodePathTokens = (container: HTMLElement): void => {
} }
for (const { start, end, raw } of matches.reverse()) { for (const { start, end, raw } of matches.reverse()) {
const startPosition = findTextPosition(textNodes, start); const startPosition = findTextPosition(textNodes, start, 'right');
const endPosition = findTextPosition(textNodes, end); const endPosition = findTextPosition(textNodes, end, 'left');
if (!startPosition || !endPosition) { if (!startPosition || !endPosition) {
continue; continue;
} }
@@ -958,9 +946,6 @@ const useMorphdomMarkdown = ({
refreshMermaidViewers(); refreshMermaidViewers();
} }
if (!ctx.deferCodeLineNumberSync) {
scheduleMarkdownCodeLineNumberSync(target);
}
}); });
return () => { return () => {
@@ -992,24 +977,6 @@ const useMorphdomMarkdown = ({
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels); applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
}, [containerRef, ctx.codeBlockLineWrap, ctx.deferCodeLineNumberSync, ctx.labels]); }, [containerRef, ctx.codeBlockLineWrap, ctx.deferCodeLineNumberSync, ctx.labels]);
React.useEffect(() => {
const container = containerRef.current;
const target = container?.querySelector<HTMLElement>('[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 => const markdownContentClassName = (variant: MarkdownVariant): string =>
@@ -81,6 +81,7 @@ const applyCodeBlockWrapState = (wrapper: HTMLElement, enabled: boolean, labels:
const body = wrapper.querySelector<HTMLElement>('[data-md-code-body]'); const body = wrapper.querySelector<HTMLElement>('[data-md-code-body]');
const pre = wrapper.querySelector<HTMLElement>('pre'); const pre = wrapper.querySelector<HTMLElement>('pre');
const code = wrapper.querySelector<HTMLElement>('pre code'); const code = wrapper.querySelector<HTMLElement>('pre code');
const lineContents = wrapper.querySelectorAll<HTMLElement>('[data-md-code-line-content]');
const wrapButton = wrapper.querySelector<HTMLButtonElement>('[data-md-action="toggle-code-wrap"]'); const wrapButton = wrapper.querySelector<HTMLButtonElement>('[data-md-action="toggle-code-wrap"]');
wrapper.setAttribute('data-code-wrap', enabled ? 'true' : 'false'); wrapper.setAttribute('data-code-wrap', enabled ? 'true' : 'false');
body?.classList.toggle('overflow-x-auto', !enabled); 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.whiteSpace = enabled ? 'pre-wrap' : 'pre';
code.style.overflowWrap = enabled ? 'anywhere' : 'normal'; 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) { if (wrapButton) {
const title = enabled ? labels.disableCodeWrap : labels.enableCodeWrap; const title = enabled ? labels.disableCodeWrap : labels.enableCodeWrap;
wrapButton.setAttribute('title', title); wrapButton.setAttribute('title', title);
@@ -109,116 +114,72 @@ const applyCodeBlockWrapState = (wrapper: HTMLElement, enabled: boolean, labels:
} }
}; };
const createCodeLineNumbers = (pre: HTMLPreElement): HTMLDivElement => { const layoutCodeLines = (pre: HTMLPreElement): void => {
const gutter = document.createElement('div'); const code = pre.querySelector<HTMLElement>(':scope > code');
gutter.setAttribute('data-md-code-line-numbers', ''); if (!code || code.hasAttribute('data-md-code-lines')) return;
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 text = pre.textContent ?? ''; const text = code.textContent ?? '';
const lineCount = Math.max(1, text.endsWith('\n') ? text.split('\n').length - 1 : text.split('\n').length); const hasTrailingNewline = text.endsWith('\n');
for (let index = 1; index <= lineCount; index += 1) { const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n');
const line = document.createElement('div'); const sourceLines = lines.length > 0 ? lines : [''];
line.className = 'tabular-nums'; const highlightedLines = Array.from(code.children).filter((child) => child.classList.contains('line'));
line.textContent = String(index); if (
gutter.appendChild(line); 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 number = document.createElement('span');
const nodes: Text[] = []; number.setAttribute('data-md-code-line-number', '');
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); number.setAttribute('aria-hidden', 'true');
let node = walker.nextNode(); number.textContent = String(index + 1);
while (node) {
nodes.push(node as Text);
node = walker.nextNode();
}
return nodes;
};
const findTextPosition = (nodes: Text[], targetOffset: number): { node: Text; offset: number } | null => { const content = document.createElement('span');
let offset = 0; content.setAttribute('data-md-code-line-content', '');
for (const node of nodes) { if (preserveHighlighting) {
const nextOffset = offset + node.data.length; const highlightedLine = highlightedLines[index];
if (targetOffset <= nextOffset) { if (highlightedLine) content.append(...Array.from(highlightedLine.childNodes));
return { node, offset: Math.max(0, targetOffset - offset) }; } 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 => { row.append(number, content);
const wrappers = root.querySelectorAll<HTMLElement>('[data-component="markdown-code"]'); fragment.appendChild(row);
for (const wrapper of Array.from(wrappers)) { if (index < sourceLines.length - 1 || hasTrailingNewline) {
const code = wrapper.querySelector<HTMLElement>('pre code'); const lineBreak = document.createElement('span');
const gutter = wrapper.querySelector<HTMLElement>('[data-md-code-line-numbers]'); lineBreak.setAttribute('data-md-code-line-break', '');
if (!code || !gutter) continue; lineBreak.textContent = '\n';
fragment.appendChild(lineBreak);
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;
} }
}
};
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<HTMLElement>('[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 => { export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: boolean, labels: DecorateLabels): void => {
const wrappers = root.querySelectorAll<HTMLElement>('[data-component="markdown-code"]'); const wrappers = root.querySelectorAll<HTMLElement>('[data-component="markdown-code"]');
for (const wrapper of Array.from(wrappers)) { for (const wrapper of Array.from(wrappers)) {
const body = wrapper.querySelector<HTMLElement>('[data-md-code-body]');
const pre = wrapper.querySelector<HTMLPreElement>('pre'); const pre = wrapper.querySelector<HTMLPreElement>('pre');
if (body && pre && !body.querySelector('[data-md-code-line-numbers]')) { if (pre) layoutCodeLines(pre);
body.classList.add('flex', 'gap-3');
body.insertBefore(createCodeLineNumbers(pre), pre);
}
applyCodeBlockWrapState(wrapper, enabled, labels); applyCodeBlockWrapState(wrapper, enabled, labels);
} }
scheduleMarkdownCodeLineNumberSync(root);
}; };
const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => { 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); header.appendChild(langLabel);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'flex items-center gap-1'; actions.className = 'flex items-center gap-1';
actions.setAttribute('data-md-code-actions', '');
actions.appendChild(wrapBtn); actions.appendChild(wrapBtn);
actions.appendChild(copyBtn); actions.appendChild(copyBtn);
header.appendChild(actions); header.appendChild(actions);
const body = document.createElement('div'); const body = document.createElement('div');
body.setAttribute('data-md-code-body', ''); 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); parent.replaceChild(wrapper, pre);
pre.style.margin = '0'; pre.style.margin = '0';
pre.style.background = 'transparent'; pre.style.background = 'transparent';
pre.classList.add('min-w-0', 'w-full', 'flex-1'); pre.classList.add('min-w-0', 'w-full', 'flex-1');
if (!ctx.deferCodeLineNumberSync) { if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre);
body.appendChild(createCodeLineNumbers(pre));
}
body.appendChild(pre); body.appendChild(pre);
wrapper.appendChild(header); wrapper.appendChild(header);
wrapper.appendChild(body); wrapper.appendChild(body);
applyCodeBlockWrapState(wrapper, ctx.codeBlockLineWrap, ctx.labels); applyCodeBlockWrapState(wrapper, ctx.codeBlockLineWrap, ctx.labels);
if (!ctx.deferCodeLineNumberSync) {
scheduleMarkdownCodeLineNumberSync(wrapper);
}
} }
}; };
@@ -582,7 +539,7 @@ export const attachMarkdownInteractions = (
// Copy code // Copy code
if (action === 'copy-code') { if (action === 'copy-code') {
const code = actionEl.closest('[data-component="markdown-code"]')?.querySelector('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)); if (text) void copyTextToClipboard(text).then(() => flashCopied(actionEl as HTMLButtonElement, ctx.labels.copied, 'copy', ctx.labels.copy));
return; return;
} }
@@ -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,
});
});
});
@@ -0,0 +1,22 @@
type TextNodeLike = { data: string };
type TextBoundaryAffinity = 'left' | 'right';
export const findTextPosition = <T extends TextNodeLike>(
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;
};
@@ -1541,6 +1541,7 @@ const AssistantMessageBody = React.memo(({
const paddingSize = 24; const paddingSize = 24;
wrapper = document.createElement('div'); wrapper = document.createElement('div');
wrapper.setAttribute('data-message-image-export', 'true');
wrapper.style.cssText = ` wrapper.style.cssText = `
padding: ${paddingSize}px; padding: ${paddingSize}px;
background-color: ${resolvedBackgroundColor}; background-color: ${resolvedBackgroundColor};
@@ -236,7 +236,9 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
"[&_[data-component='markdown-code']>div]:p-0", "[&_[data-component='markdown-code']>div]:p-0",
"[&_[data-component='markdown-code']_pre]:inline", "[&_[data-component='markdown-code']_pre]:inline",
"[&_[data-component='markdown-code']_code]: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 disableLinkSafety
+43
View File
@@ -1324,6 +1324,49 @@ html:not(.dark) .chat-scroll {
background: transparent !important; 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"] { .markdown-content [data-markdown="code-block-actions"] {
pointer-events: auto; pointer-events: auto;
transition: opacity 0.15s ease; transition: opacity 0.15s ease;
+87
View File
@@ -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<string, Blob> } | undefined;
class FakeClipboardItem {
static supports(type: string): boolean {
return type === 'text/markdown';
}
readonly data: Record<string, Blob>;
constructor(data: Record<string, Blob>) {
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<string, Blob> }>) => {
writtenItem = items[0];
},
},
},
});
const result = await copyMarkdownToClipboard('**bold**', '<p><strong>bold</strong></p>');
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('<p><strong>bold</strong></p>');
});
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', '<h1>title</h1>');
expect(result).toEqual({ ok: true, method: 'clipboard' });
expect(fallbackText).toBe('# title');
});
});
+24
View File
@@ -37,3 +37,27 @@ export async function copyTextToClipboard(text: string): Promise<ClipboardCopyRe
error: clipboardError ?? 'Clipboard access denied in current context', error: clipboardError ?? 'Clipboard access denied in current context',
}; };
} }
export async function copyMarkdownToClipboard(markdown: string, html: string): Promise<ClipboardCopyResult> {
if (
typeof navigator !== 'undefined'
&& navigator.clipboard?.write
&& typeof ClipboardItem !== 'undefined'
) {
try {
const payload: Record<string, Blob> = {
'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);
}