feat(ui): add line wrap toggle to markdown code blocks (#599)

## Summary
- add a wrap toggle beside the copy action in Markdown code block
headers
- default Markdown code blocks to wrapped lines for both plain and
Shiki-highlighted output
- preserve per-block wrap choices across async Markdown and
syntax-highlight re-renders
- add localized labels for the new Markdown code block wrap action

## Validation
- npm run typecheck --workspace @codenomad/ui

## Notes
- Left unrelated untracked .opencode/package-lock.json out of the
commit.
This commit is contained in:
Shantur Rathore 2026-07-15 10:34:43 +01:00 committed by GitHub
parent 709557d12d
commit 40cee071a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 199 additions and 32 deletions

View file

@ -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
}
@ -105,11 +107,54 @@ export function Markdown(props: MarkdownProps) {
let containerRef: HTMLDivElement | undefined
let latestRequestKey = ""
let cleanupLanguageListener: (() => void) | undefined
const codeBlockWrapOverrides = new Map<string, boolean>()
const notifyRendered = () => {
Promise.resolve().then(() => props.onRendered?.())
}
const codeBlockWrapKey = (codeBlock: HTMLElement): string | null => {
const key = codeBlock.getAttribute("data-code-block-key")
if (!key) {
return null
}
return `${resolved().cacheId}:${key}`
}
const applyCodeBlockWrapState = (codeBlock: HTMLElement, enabled: boolean) => {
codeBlock.setAttribute("data-wrap-lines", enabled ? "true" : "false")
const button = codeBlock.querySelector<HTMLButtonElement>(".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<HTMLElement>(".markdown-code-block")
for (const codeBlock of codeBlocks) {
const key = codeBlockWrapKey(codeBlock)
const defaultEnabled = codeBlock.getAttribute("data-wrap-lines") !== "false"
const enabled = key ? (codeBlockWrapOverrides.get(key) ?? defaultEnabled) : defaultEnabled
applyCodeBlockWrapState(codeBlock, enabled)
}
}
const resolved = createMemo(() => {
const part = props.part
const rawText = typeof part.text === "string" ? part.text : ""
@ -117,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({
@ -129,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,
})
@ -144,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) {
@ -159,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)
@ -170,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
@ -202,9 +260,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) {

View file

@ -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 })}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,9 +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<typeof import("shiki/bundle/full")> | null = null
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
const codeBlockRenderOccurrences = new Map<string, number>()
// Dollar-delimited math is handled by marked-katex-extension; bracket delimiters
// use the small parser-native rules registered in setupRenderer.
@ -178,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<string>()
const queuedLanguages = new Set<string>()
@ -507,29 +522,50 @@ function setupRenderer(isDark: boolean) {
// 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 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 = `
<div class="code-block-header">
<span class="code-block-language">${escapedLang}</span>
<button class="code-block-copy" data-code="${encodedCode}">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">${copyLabel}</span>
</button>
</div>
`.trim()
<span class="code-block-actions">
<button type="button" class="code-block-wrap${wrapActiveClass}" data-code-block-key="${codeBlockKey}" aria-pressed="${wrapPressed}" aria-label="${wrapLabel}" title="${wrapLabel}">
<svg class="wrap-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"></path>
<path d="M3 12h15a3 3 0 1 1 0 6h-4"></path>
<path d="m16 16-2 2 2 2"></path>
<path d="M3 18h7"></path>
</svg>
<span class="wrap-text">${wrapLabel}</span>
</button>
<button type="button" class="code-block-copy" data-code="${encodedCode}">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">${copyLabel}</span>
</button>
</span>
</div>
`.trim()
const renderCodeBlock = (body: string) => `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}" data-code-block-key="${codeBlockKey}" data-wrap-lines="${defaultWrapEnabled ? "true" : "false"}">${header}${body}</div>`
if (highlightSuppressed) {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`
return renderCodeBlock(`<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre>`)
}
// Skip highlighting for "text" language or when highlighter is not available
if (resolvedLang === "text" || !highlighter) {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code>${escapeHtml(decodedCode)}</code></pre></div>`
return renderCodeBlock(`<pre><code>${escapeHtml(decodedCode)}</code></pre>`)
}
// Resolve language and check if it's loaded
@ -538,23 +574,23 @@ function setupRenderer(isDark: boolean) {
// Skip highlighting for "text" aliases
if (langKey === "text" || raw === "text") {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`
return renderCodeBlock(`<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre>`)
}
// 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 `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}${html}</div>`
const html = highlighter!.codeToHtml(decodedCode, {
lang: langKey,
theme: currentTheme === "dark" ? "github-dark" : "github-light-high-contrast",
})
return renderCodeBlock(html)
} catch {
// Fall through to plain code if highlighting fails
}
}
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${header}<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre></div>`
return renderCodeBlock(`<pre><code class="language-${escapedLang}">${escapeHtml(decodedCode)}</code></pre>`)
}
renderer.link = (href: string, title: string | null | undefined, text: string) => {
@ -599,6 +635,7 @@ export async function renderMarkdown(
options?: {
suppressHighlight?: boolean
escapeRawHtml?: boolean
defaultCodeBlockWrap?: boolean
},
): Promise<string> {
if (!isInitialized) {
@ -608,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) {
@ -617,15 +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
resetCodeBlockRenderState()
return marked.parse(decoded) as Promise<string>
} finally {
resetCodeBlockRenderState()
highlightSuppressed = previousSuppressed
escapeRawHtmlEnabled = previousEscapeRawHtml
defaultCodeBlockWrapEnabled = previousDefaultCodeBlockWrap
}
}

View file

@ -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;
@ -262,28 +272,30 @@
padding: 0;
background-color: transparent;
border: none;
border-radius: 0.25rem;
border-radius: 0;
cursor: pointer;
color: var(--text-secondary);
flex: 0 0 auto;
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;
}
}