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:
@@ -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<ChatMessageProps> = ({
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>(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<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 =>
|
||||
|
||||
@@ -81,6 +81,7 @@ const applyCodeBlockWrapState = (wrapper: HTMLElement, enabled: boolean, labels:
|
||||
const body = wrapper.querySelector<HTMLElement>('[data-md-code-body]');
|
||||
const pre = wrapper.querySelector<HTMLElement>('pre');
|
||||
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"]');
|
||||
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<HTMLElement>(':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<HTMLElement>('[data-component="markdown-code"]');
|
||||
for (const wrapper of Array.from(wrappers)) {
|
||||
const code = wrapper.querySelector<HTMLElement>('pre code');
|
||||
const gutter = wrapper.querySelector<HTMLElement>('[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<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 => {
|
||||
const wrappers = root.querySelectorAll<HTMLElement>('[data-component="markdown-code"]');
|
||||
for (const wrapper of Array.from(wrappers)) {
|
||||
const body = wrapper.querySelector<HTMLElement>('[data-md-code-body]');
|
||||
const pre = wrapper.querySelector<HTMLPreElement>('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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-message-image-export', 'true');
|
||||
wrapper.style.cssText = `
|
||||
padding: ${paddingSize}px;
|
||||
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']_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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -37,3 +37,27 @@ export async function copyTextToClipboard(text: string): Promise<ClipboardCopyRe
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user