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
@@ -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 =>