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
|
||||
|
||||
Reference in New Issue
Block a user