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 { 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
+43
View File
@@ -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;
+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',
};
}
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);
}