From 1d0fdcd5578dcb8432354281fa7a06e6731ee900 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 15 Jul 2026 10:09:17 +0100 Subject: [PATCH 1/2] feat(ui): add line wrap toggle to markdown code blocks Render Markdown code blocks with a header-level word wrap control alongside the existing copy action, with wrapping enabled by default for both plain and Shiki-highlighted blocks. Track per-block wrap overrides in the Markdown component so user choices survive async Markdown and syntax-highlight re-renders for the same message part. The toggle updates aria labels, pressed state, and virtual-list measurements after layout changes. Add localized wrap labels for all existing Markdown message bundles and style the new control with the existing code block header patterns. Validation: npm run typecheck --workspace @codenomad/ui --- packages/ui/src/components/markdown.tsx | 66 +++++++++++++++++++ .../ui/src/lib/i18n/messages/de/markdown.ts | 2 + .../ui/src/lib/i18n/messages/en/markdown.ts | 2 + .../ui/src/lib/i18n/messages/es/markdown.ts | 2 + .../ui/src/lib/i18n/messages/fr/markdown.ts | 2 + .../ui/src/lib/i18n/messages/he/markdown.ts | 2 + .../ui/src/lib/i18n/messages/ja/markdown.ts | 2 + .../ui/src/lib/i18n/messages/ne/markdown.ts | 2 + .../ui/src/lib/i18n/messages/ru/markdown.ts | 2 + .../src/lib/i18n/messages/zh-Hans/markdown.ts | 2 + packages/ui/src/lib/markdown.ts | 50 +++++++++----- packages/ui/src/styles/markdown.css | 34 ++++++++-- 12 files changed, 145 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 4ec0c5d7e..d86916803 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -105,11 +105,53 @@ export function Markdown(props: MarkdownProps) { let containerRef: HTMLDivElement | undefined let latestRequestKey = "" let cleanupLanguageListener: (() => void) | undefined + const codeBlockWrapOverrides = new Map() const notifyRendered = () => { Promise.resolve().then(() => props.onRendered?.()) } + const codeBlockWrapKey = (codeBlock: HTMLElement): string | null => { + const index = codeBlock.getAttribute("data-code-block-index") + if (index === null) { + return null + } + return `${resolved().cacheId}:${index}` + } + + const applyCodeBlockWrapState = (codeBlock: HTMLElement, enabled: boolean) => { + codeBlock.setAttribute("data-wrap-lines", enabled ? "true" : "false") + + const button = codeBlock.querySelector(".code-block-wrap") + if (!button) { + return + } + + const label = enabled ? t("markdown.codeBlock.wrap.disable") : t("markdown.codeBlock.wrap.enable") + button.classList.toggle("active", enabled) + button.setAttribute("aria-pressed", enabled ? "true" : "false") + button.setAttribute("aria-label", label) + button.setAttribute("title", label) + + const text = button.querySelector(".wrap-text") + if (text) { + text.textContent = label + } + } + + const syncCodeBlockWrapStates = () => { + if (!containerRef) { + return + } + + const codeBlocks = containerRef.querySelectorAll(".markdown-code-block") + for (const codeBlock of codeBlocks) { + const key = codeBlockWrapKey(codeBlock) + const enabled = key ? (codeBlockWrapOverrides.get(key) ?? true) : true + applyCodeBlockWrapState(codeBlock, enabled) + } + } + const resolved = createMemo(() => { const part = props.part const rawText = typeof part.text === "string" ? part.text : "" @@ -202,9 +244,33 @@ export function Markdown(props: MarkdownProps) { }) }) + createEffect(() => { + html() + Promise.resolve().then(syncCodeBlockWrapStates) + }) + onMount(() => { const handleClick = async (event: Event) => { const target = event.target as HTMLElement + const wrapButton = target.closest(".code-block-wrap") as HTMLButtonElement + if (wrapButton) { + event.preventDefault() + const codeBlock = wrapButton.closest(".markdown-code-block") as HTMLElement | null + if (!codeBlock) { + return + } + + const key = codeBlockWrapKey(codeBlock) + const current = codeBlock.getAttribute("data-wrap-lines") !== "false" + const next = !current + if (key) { + codeBlockWrapOverrides.set(key, next) + } + applyCodeBlockWrapState(codeBlock, next) + props.onRendered?.() + return + } + const copyButton = target.closest(".code-block-copy") as HTMLButtonElement if (!copyButton) { diff --git a/packages/ui/src/lib/i18n/messages/de/markdown.ts b/packages/ui/src/lib/i18n/messages/de/markdown.ts index 83d1e7fc1..a37d77922 100644 --- a/packages/ui/src/lib/i18n/messages/de/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/de/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "Kopieren", "markdown.codeBlock.copy.copied": "Kopiert!", "markdown.codeBlock.copy.failed": "Fehlgeschlagen", + "markdown.codeBlock.wrap.enable": "Zeilenumbruch aktivieren", + "markdown.codeBlock.wrap.disable": "Zeilenumbruch deaktivieren", "markdown.copy": "Kopieren", } as const diff --git a/packages/ui/src/lib/i18n/messages/en/markdown.ts b/packages/ui/src/lib/i18n/messages/en/markdown.ts index c7aea2cc8..5a47fc0d6 100644 --- a/packages/ui/src/lib/i18n/messages/en/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/en/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "Copy", "markdown.codeBlock.copy.copied": "Copied!", "markdown.codeBlock.copy.failed": "Failed", + "markdown.codeBlock.wrap.enable": "Enable word wrap", + "markdown.codeBlock.wrap.disable": "Disable word wrap", "markdown.copy": "Copy", } as const diff --git a/packages/ui/src/lib/i18n/messages/es/markdown.ts b/packages/ui/src/lib/i18n/messages/es/markdown.ts index f92d46b97..f0b89ac0c 100644 --- a/packages/ui/src/lib/i18n/messages/es/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/es/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "Copiar", "markdown.codeBlock.copy.copied": "¡Copiado!", "markdown.codeBlock.copy.failed": "Error", + "markdown.codeBlock.wrap.enable": "Activar ajuste de línea", + "markdown.codeBlock.wrap.disable": "Desactivar ajuste de línea", "markdown.copy": "Copiar", } as const diff --git a/packages/ui/src/lib/i18n/messages/fr/markdown.ts b/packages/ui/src/lib/i18n/messages/fr/markdown.ts index 42a6915a5..c8766d120 100644 --- a/packages/ui/src/lib/i18n/messages/fr/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/fr/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "Copier", "markdown.codeBlock.copy.copied": "Copié !", "markdown.codeBlock.copy.failed": "Échec", + "markdown.codeBlock.wrap.enable": "Activer le retour à la ligne", + "markdown.codeBlock.wrap.disable": "Désactiver le retour à la ligne", "markdown.copy": "Copier", } as const diff --git a/packages/ui/src/lib/i18n/messages/he/markdown.ts b/packages/ui/src/lib/i18n/messages/he/markdown.ts index 5505c65bb..bfa236820 100644 --- a/packages/ui/src/lib/i18n/messages/he/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/he/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "העתק", "markdown.codeBlock.copy.copied": "הועתק!", "markdown.codeBlock.copy.failed": "נכשל", + "markdown.codeBlock.wrap.enable": "הפעל גלישת שורות", + "markdown.codeBlock.wrap.disable": "כבה גלישת שורות", "markdown.copy": "העתק", } as const diff --git a/packages/ui/src/lib/i18n/messages/ja/markdown.ts b/packages/ui/src/lib/i18n/messages/ja/markdown.ts index 786f471cb..e8f1cfafc 100644 --- a/packages/ui/src/lib/i18n/messages/ja/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/ja/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "コピー", "markdown.codeBlock.copy.copied": "コピーしました!", "markdown.codeBlock.copy.failed": "失敗", + "markdown.codeBlock.wrap.enable": "折り返しを有効にする", + "markdown.codeBlock.wrap.disable": "折り返しを無効にする", "markdown.copy": "コピー", } as const diff --git a/packages/ui/src/lib/i18n/messages/ne/markdown.ts b/packages/ui/src/lib/i18n/messages/ne/markdown.ts index 842ceff7b..defe0b095 100644 --- a/packages/ui/src/lib/i18n/messages/ne/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/ne/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "प्रतिलिपि गर्नुहोस्", "markdown.codeBlock.copy.copied": "प्रतिलिपि गरियो!", "markdown.codeBlock.copy.failed": "असफल", + "markdown.codeBlock.wrap.enable": "लाइन र्‍याप सक्षम गर्नुहोस्", + "markdown.codeBlock.wrap.disable": "लाइन र्‍याप असक्षम गर्नुहोस्", "markdown.copy": "प्रतिलिपि गर्नुहोस्", } as const diff --git a/packages/ui/src/lib/i18n/messages/ru/markdown.ts b/packages/ui/src/lib/i18n/messages/ru/markdown.ts index 1c4050380..ee4211f2f 100644 --- a/packages/ui/src/lib/i18n/messages/ru/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/ru/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "Копировать", "markdown.codeBlock.copy.copied": "Скопировано!", "markdown.codeBlock.copy.failed": "Не удалось", + "markdown.codeBlock.wrap.enable": "Включить перенос строк", + "markdown.codeBlock.wrap.disable": "Отключить перенос строк", "markdown.copy": "Копировать", } as const diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/markdown.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/markdown.ts index 0bae28249..dfcb3fe53 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/markdown.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/markdown.ts @@ -2,6 +2,8 @@ export const markdownMessages = { "markdown.codeBlock.copy.label": "复制", "markdown.codeBlock.copy.copied": "已复制!", "markdown.codeBlock.copy.failed": "失败", + "markdown.codeBlock.wrap.enable": "启用自动换行", + "markdown.codeBlock.wrap.disable": "禁用自动换行", "markdown.copy": "复制", } as const diff --git a/packages/ui/src/lib/markdown.ts b/packages/ui/src/lib/markdown.ts index 50b19161c..61eaea3b4 100644 --- a/packages/ui/src/lib/markdown.ts +++ b/packages/ui/src/lib/markdown.ts @@ -17,6 +17,7 @@ let escapeRawHtmlEnabled = false let rendererSetup = false let shikiModulePromise: Promise | null = null let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null +let codeBlockRenderIndex = 0 // Dollar-delimited math is handled by marked-katex-extension; bracket delimiters // use the small parser-native rules registered in setupRenderer. @@ -502,6 +503,7 @@ function setupRenderer(isDark: boolean) { const renderer = new marked.Renderer() renderer.code = (code: string, lang: string | undefined) => { + const codeBlockIndex = codeBlockRenderIndex++ const decodedCode = decodeHtmlEntities(code) const encodedCode = encodeURIComponent(decodedCode) @@ -509,27 +511,39 @@ function setupRenderer(isDark: boolean) { const resolvedLang = lang && lang.trim() ? lang.trim() : "text" const escapedLang = escapeHtml(resolvedLang) const copyLabel = escapeHtml(tGlobal("markdown.copy")) + const disableWrapLabel = escapeHtml(tGlobal("markdown.codeBlock.wrap.disable")) const header = `
${escapedLang} - + + + +
- `.trim() + `.trim() if (highlightSuppressed) { - return `
${header}
${escapeHtml(decodedCode)}
` + return `
${header}
${escapeHtml(decodedCode)}
` } // Skip highlighting for "text" language or when highlighter is not available if (resolvedLang === "text" || !highlighter) { - return `
${header}
${escapeHtml(decodedCode)}
` + return `
${header}
${escapeHtml(decodedCode)}
` } // Resolve language and check if it's loaded @@ -538,23 +552,23 @@ function setupRenderer(isDark: boolean) { // Skip highlighting for "text" aliases if (langKey === "text" || raw === "text") { - return `
${header}
${escapeHtml(decodedCode)}
` + return `
${header}
${escapeHtml(decodedCode)}
` } // Use highlighting if language is loaded, otherwise fall back to plain code if (loadedLanguages.has(langKey)) { try { - const html = highlighter!.codeToHtml(decodedCode, { - lang: langKey, - theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast", - }) - return `
${header}${html}
` + const html = highlighter!.codeToHtml(decodedCode, { + lang: langKey, + theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast", + }) + return `
${header}${html}
` } catch { // Fall through to plain code if highlighting fails } } - return `
${header}
${escapeHtml(decodedCode)}
` + return `
${header}
${escapeHtml(decodedCode)}
` } renderer.link = (href: string, title: string | null | undefined, text: string) => { @@ -622,8 +636,10 @@ export async function renderMarkdown( try { // Proceed to parse immediately - highlighting will be available on next render + codeBlockRenderIndex = 0 return marked.parse(decoded) as Promise } finally { + codeBlockRenderIndex = 0 highlightSuppressed = previousSuppressed escapeRawHtmlEnabled = previousEscapeRawHtml } diff --git a/packages/ui/src/styles/markdown.css b/packages/ui/src/styles/markdown.css index 5e95c4719..e79343698 100644 --- a/packages/ui/src/styles/markdown.css +++ b/packages/ui/src/styles/markdown.css @@ -242,6 +242,15 @@ border-bottom: 1px solid var(--border-base); } + .code-block-actions { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + flex: 0 0 auto; + margin-inline-start: auto; + margin-inline-end: var(--space-sm); + } + .code-block-language { font-family: var(--message-part-header-font-family); color: var(--text-primary); @@ -252,7 +261,8 @@ line-height: var(--message-part-header-title-line-height); } - .code-block-copy { + .code-block-copy, + .code-block-wrap { display: inline-flex; align-items: center; justify-content: center; @@ -269,21 +279,23 @@ transition: background-color 150ms ease, color 150ms ease; - margin-inline-start: auto; - margin-inline-end: var(--space-sm); } - .code-block-copy:hover { + .code-block-copy:hover, + .code-block-wrap:hover, + .code-block-wrap.active { background-color: transparent; color: var(--text-primary); } - .code-block-copy .copy-icon { + .code-block-copy .copy-icon, + .code-block-wrap .wrap-icon { width: 0.875rem; height: 0.875rem; } - .code-block-copy .copy-text { + .code-block-copy .copy-text, + .code-block-wrap .wrap-text { position: absolute; width: 1px; height: 1px; @@ -311,4 +323,14 @@ background: transparent !important; padding: 0 !important; } + + .markdown-code-block[data-wrap-lines="true"] pre, + .markdown-code-block[data-wrap-lines="true"] code, + .markdown-code-block[data-wrap-lines="true"] pre.shiki, + .markdown-code-block[data-wrap-lines="true"] pre.shiki code, + .markdown-code-block[data-wrap-lines="true"] .shiki { + white-space: pre-wrap !important; + word-break: break-word; + overflow-wrap: anywhere; + } } From 9929f829890f57b0663e34c085df131c3e51f8d6 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 15 Jul 2026 10:19:18 +0100 Subject: [PATCH 2/2] fix(ui): respect tool markdown wrap preference Plumb the default code block wrap state through the Markdown component and renderer so tool output Markdown can render code fences unwrapped when the existing output wrap toggle is disabled. Use stable renderer-emitted code block keys based on language, decoded code, and occurrence count so per-block wrap overrides survive async re-renders without drifting across unrelated inserted or removed code fences. Centralize Markdown code block wrapper creation and keep the wrap/copy controls square to match the UI styling guidelines. Validation: npm run typecheck --workspace @codenomad/ui --- packages/ui/src/components/markdown.tsx | 36 +++++++++---- .../components/tool-call/markdown-render.tsx | 4 +- packages/ui/src/lib/markdown.ts | 53 ++++++++++++++----- packages/ui/src/styles/markdown.css | 2 +- 4 files changed, 70 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index d86916803..91d40793d 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -15,6 +15,7 @@ interface ResolvedMarkdownSnapshot { themeKey: string highlightEnabled: boolean escapeRawHtml: boolean + defaultCodeBlockWrap: boolean partId: string | undefined cacheId: string version: string @@ -96,6 +97,7 @@ interface MarkdownProps { size?: "base" | "sm" | "tight" disableHighlight?: boolean escapeRawHtml?: boolean + defaultCodeBlockWrap?: boolean onRendered?: () => void } @@ -112,11 +114,11 @@ export function Markdown(props: MarkdownProps) { } const codeBlockWrapKey = (codeBlock: HTMLElement): string | null => { - const index = codeBlock.getAttribute("data-code-block-index") - if (index === null) { + const key = codeBlock.getAttribute("data-code-block-key") + if (!key) { return null } - return `${resolved().cacheId}:${index}` + return `${resolved().cacheId}:${key}` } const applyCodeBlockWrapState = (codeBlock: HTMLElement, enabled: boolean) => { @@ -147,7 +149,8 @@ export function Markdown(props: MarkdownProps) { const codeBlocks = containerRef.querySelectorAll(".markdown-code-block") for (const codeBlock of codeBlocks) { const key = codeBlockWrapKey(codeBlock) - const enabled = key ? (codeBlockWrapOverrides.get(key) ?? true) : true + const defaultEnabled = codeBlock.getAttribute("data-wrap-lines") !== "false" + const enabled = key ? (codeBlockWrapOverrides.get(key) ?? defaultEnabled) : defaultEnabled applyCodeBlockWrapState(codeBlock, enabled) } } @@ -159,11 +162,23 @@ export function Markdown(props: MarkdownProps) { const themeKey = Boolean(props.isDark) ? "dark" : "light" const highlightEnabled = !props.disableHighlight const escapeRawHtml = Boolean(props.escapeRawHtml) + const defaultCodeBlockWrap = props.defaultCodeBlockWrap ?? true const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined const cacheId = resolvePartCacheId(part, text) const version = resolvePartVersion(part, text) - const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}` - return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey } + const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${defaultCodeBlockWrap ? 1 : 0}:${version}` + return { + part, + text, + themeKey, + highlightEnabled, + escapeRawHtml, + defaultCodeBlockWrap, + partId, + cacheId, + version, + requestKey, + } }) const cacheHandle = useGlobalCache({ @@ -171,8 +186,8 @@ export function Markdown(props: MarkdownProps) { sessionId: () => props.sessionId, scope: "markdown", cacheId: () => { - const { cacheId, themeKey, highlightEnabled } = resolved() - return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}` + const { cacheId, themeKey, highlightEnabled, escapeRawHtml, defaultCodeBlockWrap } = resolved() + return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${defaultCodeBlockWrap ? 1 : 0}` }, version: () => resolved().version, }) @@ -186,7 +201,7 @@ export function Markdown(props: MarkdownProps) { text: snapshot.text, html: renderedHtml, theme: snapshot.themeKey, - mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`, + mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}:${snapshot.defaultCodeBlockWrap ? "wrap" : "nowrap"}`, } setHtml(renderedHtml) if (options?.cache ?? true) { @@ -201,6 +216,7 @@ export function Markdown(props: MarkdownProps) { const rendered = await markdown.renderMarkdown(snapshot.text, { suppressHighlight: !snapshot.highlightEnabled, escapeRawHtml: snapshot.escapeRawHtml, + defaultCodeBlockWrap: snapshot.defaultCodeBlockWrap, }) const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text) @@ -212,7 +228,7 @@ export function Markdown(props: MarkdownProps) { createEffect(() => { const snapshot = resolved() latestRequestKey = snapshot.requestKey - const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}` + const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}:${snapshot.defaultCodeBlockWrap ? "wrap" : "nowrap"}` const cacheMatches = (cache: RenderCache | undefined) => { if (!cache) return false diff --git a/packages/ui/src/components/tool-call/markdown-render.tsx b/packages/ui/src/components/tool-call/markdown-render.tsx index a2f385d1f..2c389ac48 100644 --- a/packages/ui/src/components/tool-call/markdown-render.tsx +++ b/packages/ui/src/components/tool-call/markdown-render.tsx @@ -30,7 +30,8 @@ export function createMarkdownContentRenderer(params: { const size = options.size || "default" const disableHighlight = options.disableHighlight || false - const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}${options.wrap ? " tool-call-markdown-wrap" : ""}` + const wrapEnabled = options.wrap ?? true + const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}${wrapEnabled ? " tool-call-markdown-wrap" : ""}` const state = params.toolState() const disableScrollTracking = options.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending") const registerRef = disableScrollTracking ? registerUntracked : registerTracked @@ -73,6 +74,7 @@ export function createMarkdownContentRenderer(params: { sessionId={params.sessionId} isDark={params.isDark()} disableHighlight={disableHighlight} + defaultCodeBlockWrap={wrapEnabled} onRendered={handleMarkdownRendered} /> {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} diff --git a/packages/ui/src/lib/markdown.ts b/packages/ui/src/lib/markdown.ts index 61eaea3b4..9d40506ae 100644 --- a/packages/ui/src/lib/markdown.ts +++ b/packages/ui/src/lib/markdown.ts @@ -14,10 +14,11 @@ let currentTheme: "light" | "dark" = "light" let isInitialized = false let highlightSuppressed = false let escapeRawHtmlEnabled = false +let defaultCodeBlockWrapEnabled = true let rendererSetup = false let shikiModulePromise: Promise | null = null let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null -let codeBlockRenderIndex = 0 +const codeBlockRenderOccurrences = new Map() // Dollar-delimited math is handled by marked-katex-extension; bracket delimiters // use the small parser-native rules registered in setupRenderer. @@ -179,6 +180,19 @@ function sanitizeRawHtmlFragment(html: string): string { return template.innerHTML } +function hashString(value: string): string { + let hash = 2166136261 + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(16) +} + +function resetCodeBlockRenderState() { + codeBlockRenderOccurrences.clear() +} + // Track loaded languages and queue for on-demand loading const loadedLanguages = new Set() const queuedLanguages = new Set() @@ -503,28 +517,34 @@ function setupRenderer(isDark: boolean) { const renderer = new marked.Renderer() renderer.code = (code: string, lang: string | undefined) => { - const codeBlockIndex = codeBlockRenderIndex++ const decodedCode = decodeHtmlEntities(code) const encodedCode = encodeURIComponent(decodedCode) // Use "text" as default when no language is specified const resolvedLang = lang && lang.trim() ? lang.trim() : "text" + const occurrenceBaseKey = `${resolvedLang}\u0000${decodedCode}` + const occurrence = codeBlockRenderOccurrences.get(occurrenceBaseKey) ?? 0 + codeBlockRenderOccurrences.set(occurrenceBaseKey, occurrence + 1) + const codeBlockKey = hashString(`${occurrenceBaseKey}\u0000${occurrence}`) const escapedLang = escapeHtml(resolvedLang) const copyLabel = escapeHtml(tGlobal("markdown.copy")) - const disableWrapLabel = escapeHtml(tGlobal("markdown.codeBlock.wrap.disable")) + const defaultWrapEnabled = defaultCodeBlockWrapEnabled + const wrapLabel = escapeHtml(tGlobal(defaultWrapEnabled ? "markdown.codeBlock.wrap.disable" : "markdown.codeBlock.wrap.enable")) + const wrapActiveClass = defaultWrapEnabled ? " active" : "" + const wrapPressed = defaultWrapEnabled ? "true" : "false" const header = `
${escapedLang} - -
+ `.trim() + const renderCodeBlock = (body: string) => `
${header}${body}
` + if (highlightSuppressed) { - return `
${header}
${escapeHtml(decodedCode)}
` + return renderCodeBlock(`
${escapeHtml(decodedCode)}
`) } // Skip highlighting for "text" language or when highlighter is not available if (resolvedLang === "text" || !highlighter) { - return `
${header}
${escapeHtml(decodedCode)}
` + return renderCodeBlock(`
${escapeHtml(decodedCode)}
`) } // Resolve language and check if it's loaded @@ -552,7 +574,7 @@ function setupRenderer(isDark: boolean) { // Skip highlighting for "text" aliases if (langKey === "text" || raw === "text") { - return `
${header}
${escapeHtml(decodedCode)}
` + return renderCodeBlock(`
${escapeHtml(decodedCode)}
`) } // Use highlighting if language is loaded, otherwise fall back to plain code @@ -562,13 +584,13 @@ function setupRenderer(isDark: boolean) { lang: langKey, theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast", }) - return `
${header}${html}
` + return renderCodeBlock(html) } catch { // Fall through to plain code if highlighting fails } } - return `
${header}
${escapeHtml(decodedCode)}
` + return renderCodeBlock(`
${escapeHtml(decodedCode)}
`) } renderer.link = (href: string, title: string | null | undefined, text: string) => { @@ -613,6 +635,7 @@ export async function renderMarkdown( options?: { suppressHighlight?: boolean escapeRawHtml?: boolean + defaultCodeBlockWrap?: boolean }, ): Promise { if (!isInitialized) { @@ -622,6 +645,7 @@ export async function renderMarkdown( const suppressHighlight = options?.suppressHighlight ?? false const escapeRawHtml = options?.escapeRawHtml ?? false + const defaultCodeBlockWrap = options?.defaultCodeBlockWrap ?? true const decoded = decodeHtmlEntities(content) if (!suppressHighlight) { @@ -631,17 +655,20 @@ export async function renderMarkdown( const previousSuppressed = highlightSuppressed const previousEscapeRawHtml = escapeRawHtmlEnabled + const previousDefaultCodeBlockWrap = defaultCodeBlockWrapEnabled highlightSuppressed = suppressHighlight escapeRawHtmlEnabled = escapeRawHtml + defaultCodeBlockWrapEnabled = defaultCodeBlockWrap try { // Proceed to parse immediately - highlighting will be available on next render - codeBlockRenderIndex = 0 + resetCodeBlockRenderState() return marked.parse(decoded) as Promise } finally { - codeBlockRenderIndex = 0 + resetCodeBlockRenderState() highlightSuppressed = previousSuppressed escapeRawHtmlEnabled = previousEscapeRawHtml + defaultCodeBlockWrapEnabled = previousDefaultCodeBlockWrap } } diff --git a/packages/ui/src/styles/markdown.css b/packages/ui/src/styles/markdown.css index e79343698..40a488aa7 100644 --- a/packages/ui/src/styles/markdown.css +++ b/packages/ui/src/styles/markdown.css @@ -272,7 +272,7 @@ padding: 0; background-color: transparent; border: none; - border-radius: 0.25rem; + border-radius: 0; cursor: pointer; color: var(--text-secondary); flex: 0 0 auto;