From f49f3a0d88697b33b56a5e2bff36930bf6854fbb Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 8 Jul 2026 14:59:11 +0300 Subject: [PATCH] feat: add synced code block line numbers Adds line-number gutters for markdown code blocks Keeps gutter heights in sync when wrapping or resizing changes Applies wrap styles directly to pre and code for better overflow handling --- .../components/chat/MarkdownRendererImpl.tsx | 23 ++++ .../src/components/chat/markdown/decorate.ts | 112 +++++++++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index fe8f757f..36617e22 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -25,6 +25,8 @@ import { attachMarkdownInteractions, applyMarkdownCodeBlockWrapState, decorateMarkdown, + scheduleMarkdownCodeLineNumberSync, + syncMarkdownCodeLineNumbers, type DecorateContext, type DecorateLabels, type MermaidRender, @@ -981,6 +983,8 @@ const useMorphdomMarkdown = ({ for (let i = existing.length - 1; i >= blocks.length; i -= 1) { existing[i]?.remove(); } + + scheduleMarkdownCodeLineNumberSync(target); }); return () => { @@ -1010,6 +1014,25 @@ const useMorphdomMarkdown = ({ if (!target) return; applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels); }, [containerRef, ctx.codeBlockLineWrap, ctx.labels]); + + React.useEffect(() => { + const container = containerRef.current; + const target = container?.querySelector('[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 => diff --git a/packages/ui/src/components/chat/markdown/decorate.ts b/packages/ui/src/components/chat/markdown/decorate.ts index 862938ef..8db291e5 100644 --- a/packages/ui/src/components/chat/markdown/decorate.ts +++ b/packages/ui/src/components/chat/markdown/decorate.ts @@ -73,6 +73,14 @@ const applyCodeBlockWrapState = (wrapper: HTMLElement, enabled: boolean, labels: pre?.classList.toggle('break-words', enabled); code?.classList.toggle('whitespace-pre-wrap', enabled); code?.classList.toggle('break-words', enabled); + if (pre) { + pre.style.whiteSpace = enabled ? 'pre-wrap' : 'pre'; + pre.style.overflowWrap = enabled ? 'anywhere' : 'normal'; + } + if (code) { + code.style.whiteSpace = enabled ? 'pre-wrap' : 'pre'; + code.style.overflowWrap = enabled ? 'anywhere' : 'normal'; + } if (wrapButton) { const title = enabled ? labels.disableCodeWrap : labels.enableCodeWrap; wrapButton.setAttribute('title', title); @@ -85,11 +93,110 @@ 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 = 'select-none border-r border-border/50 pr-3 text-right text-muted-foreground/45'; + + 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); + } + + return gutter; +}; + +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 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) }; + } + 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('[data-component="markdown-code"]'); + for (const wrapper of Array.from(wrappers)) { + const code = wrapper.querySelector('pre code'); + const gutter = wrapper.querySelector('[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; + } + } +}; + +export const scheduleMarkdownCodeLineNumberSync = (root: HTMLElement): void => { + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => syncMarkdownCodeLineNumbers(root)); + }); +}; + export const applyMarkdownCodeBlockWrapState = (root: HTMLElement, enabled: boolean, labels: DecorateLabels): void => { const wrappers = root.querySelectorAll('[data-component="markdown-code"]'); for (const wrapper of Array.from(wrappers)) { applyCodeBlockWrapState(wrapper, enabled, labels); } + scheduleMarkdownCodeLineNumberSync(root); }; const flashCopied = (button: HTMLButtonElement, copiedTitle: string, restore: keyof typeof ICONS, restoreTitle: string): void => { @@ -151,15 +258,18 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => { const body = document.createElement('div'); body.setAttribute('data-md-code-body', ''); - body.className = 'px-3 py-2.5 overflow-x-auto'; + body.className = 'flex gap-3 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'); + body.appendChild(createCodeLineNumbers(pre)); body.appendChild(pre); wrapper.appendChild(header); wrapper.appendChild(body); applyCodeBlockWrapState(wrapper, ctx.codeBlockLineWrap, ctx.labels); + scheduleMarkdownCodeLineNumberSync(wrapper); } };