Skip to content

Commit 40cee07

Browse files
authored
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.
1 parent 709557d commit 40cee07

13 files changed

Lines changed: 199 additions & 32 deletions

File tree

packages/ui/src/components/markdown.tsx

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ interface ResolvedMarkdownSnapshot {
1515
themeKey: string
1616
highlightEnabled: boolean
1717
escapeRawHtml: boolean
18+
defaultCodeBlockWrap: boolean
1819
partId: string | undefined
1920
cacheId: string
2021
version: string
@@ -96,6 +97,7 @@ interface MarkdownProps {
9697
size?: "base" | "sm" | "tight"
9798
disableHighlight?: boolean
9899
escapeRawHtml?: boolean
100+
defaultCodeBlockWrap?: boolean
99101
onRendered?: () => void
100102
}
101103

@@ -105,32 +107,87 @@ export function Markdown(props: MarkdownProps) {
105107
let containerRef: HTMLDivElement | undefined
106108
let latestRequestKey = ""
107109
let cleanupLanguageListener: (() => void) | undefined
110+
const codeBlockWrapOverrides = new Map<string, boolean>()
108111

109112
const notifyRendered = () => {
110113
Promise.resolve().then(() => props.onRendered?.())
111114
}
112115

116+
const codeBlockWrapKey = (codeBlock: HTMLElement): string | null => {
117+
const key = codeBlock.getAttribute("data-code-block-key")
118+
if (!key) {
119+
return null
120+
}
121+
return `${resolved().cacheId}:${key}`
122+
}
123+
124+
const applyCodeBlockWrapState = (codeBlock: HTMLElement, enabled: boolean) => {
125+
codeBlock.setAttribute("data-wrap-lines", enabled ? "true" : "false")
126+
127+
const button = codeBlock.querySelector<HTMLButtonElement>(".code-block-wrap")
128+
if (!button) {
129+
return
130+
}
131+
132+
const label = enabled ? t("markdown.codeBlock.wrap.disable") : t("markdown.codeBlock.wrap.enable")
133+
button.classList.toggle("active", enabled)
134+
button.setAttribute("aria-pressed", enabled ? "true" : "false")
135+
button.setAttribute("aria-label", label)
136+
button.setAttribute("title", label)
137+
138+
const text = button.querySelector(".wrap-text")
139+
if (text) {
140+
text.textContent = label
141+
}
142+
}
143+
144+
const syncCodeBlockWrapStates = () => {
145+
if (!containerRef) {
146+
return
147+
}
148+
149+
const codeBlocks = containerRef.querySelectorAll<HTMLElement>(".markdown-code-block")
150+
for (const codeBlock of codeBlocks) {
151+
const key = codeBlockWrapKey(codeBlock)
152+
const defaultEnabled = codeBlock.getAttribute("data-wrap-lines") !== "false"
153+
const enabled = key ? (codeBlockWrapOverrides.get(key) ?? defaultEnabled) : defaultEnabled
154+
applyCodeBlockWrapState(codeBlock, enabled)
155+
}
156+
}
157+
113158
const resolved = createMemo(() => {
114159
const part = props.part
115160
const rawText = typeof part.text === "string" ? part.text : ""
116161
const text = decodeHtmlEntitiesLocally(rawText)
117162
const themeKey = Boolean(props.isDark) ? "dark" : "light"
118163
const highlightEnabled = !props.disableHighlight
119164
const escapeRawHtml = Boolean(props.escapeRawHtml)
165+
const defaultCodeBlockWrap = props.defaultCodeBlockWrap ?? true
120166
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
121167
const cacheId = resolvePartCacheId(part, text)
122168
const version = resolvePartVersion(part, text)
123-
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
124-
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
169+
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${defaultCodeBlockWrap ? 1 : 0}:${version}`
170+
return {
171+
part,
172+
text,
173+
themeKey,
174+
highlightEnabled,
175+
escapeRawHtml,
176+
defaultCodeBlockWrap,
177+
partId,
178+
cacheId,
179+
version,
180+
requestKey,
181+
}
125182
})
126183

127184
const cacheHandle = useGlobalCache({
128185
instanceId: () => props.instanceId,
129186
sessionId: () => props.sessionId,
130187
scope: "markdown",
131188
cacheId: () => {
132-
const { cacheId, themeKey, highlightEnabled } = resolved()
133-
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
189+
const { cacheId, themeKey, highlightEnabled, escapeRawHtml, defaultCodeBlockWrap } = resolved()
190+
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${defaultCodeBlockWrap ? 1 : 0}`
134191
},
135192
version: () => resolved().version,
136193
})
@@ -144,7 +201,7 @@ export function Markdown(props: MarkdownProps) {
144201
text: snapshot.text,
145202
html: renderedHtml,
146203
theme: snapshot.themeKey,
147-
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
204+
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}:${snapshot.defaultCodeBlockWrap ? "wrap" : "nowrap"}`,
148205
}
149206
setHtml(renderedHtml)
150207
if (options?.cache ?? true) {
@@ -159,6 +216,7 @@ export function Markdown(props: MarkdownProps) {
159216
const rendered = await markdown.renderMarkdown(snapshot.text, {
160217
suppressHighlight: !snapshot.highlightEnabled,
161218
escapeRawHtml: snapshot.escapeRawHtml,
219+
defaultCodeBlockWrap: snapshot.defaultCodeBlockWrap,
162220
})
163221
const shouldCache = !snapshot.highlightEnabled || !markdown.hasPendingCodeHighlight(snapshot.text)
164222

@@ -170,7 +228,7 @@ export function Markdown(props: MarkdownProps) {
170228
createEffect(() => {
171229
const snapshot = resolved()
172230
latestRequestKey = snapshot.requestKey
173-
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
231+
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}:${snapshot.defaultCodeBlockWrap ? "wrap" : "nowrap"}`
174232

175233
const cacheMatches = (cache: RenderCache | undefined) => {
176234
if (!cache) return false
@@ -202,9 +260,33 @@ export function Markdown(props: MarkdownProps) {
202260
})
203261
})
204262

263+
createEffect(() => {
264+
html()
265+
Promise.resolve().then(syncCodeBlockWrapStates)
266+
})
267+
205268
onMount(() => {
206269
const handleClick = async (event: Event) => {
207270
const target = event.target as HTMLElement
271+
const wrapButton = target.closest(".code-block-wrap") as HTMLButtonElement
272+
if (wrapButton) {
273+
event.preventDefault()
274+
const codeBlock = wrapButton.closest(".markdown-code-block") as HTMLElement | null
275+
if (!codeBlock) {
276+
return
277+
}
278+
279+
const key = codeBlockWrapKey(codeBlock)
280+
const current = codeBlock.getAttribute("data-wrap-lines") !== "false"
281+
const next = !current
282+
if (key) {
283+
codeBlockWrapOverrides.set(key, next)
284+
}
285+
applyCodeBlockWrapState(codeBlock, next)
286+
props.onRendered?.()
287+
return
288+
}
289+
208290
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
209291

210292
if (!copyButton) {

packages/ui/src/components/tool-call/markdown-render.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ export function createMarkdownContentRenderer(params: {
3030

3131
const size = options.size || "default"
3232
const disableHighlight = options.disableHighlight || false
33-
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}${options.wrap ? " tool-call-markdown-wrap" : ""}`
33+
const wrapEnabled = options.wrap ?? true
34+
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}${wrapEnabled ? " tool-call-markdown-wrap" : ""}`
3435
const state = params.toolState()
3536
const disableScrollTracking = options.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending")
3637
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
@@ -73,6 +74,7 @@ export function createMarkdownContentRenderer(params: {
7374
sessionId={params.sessionId}
7475
isDark={params.isDark()}
7576
disableHighlight={disableHighlight}
77+
defaultCodeBlockWrap={wrapEnabled}
7678
onRendered={handleMarkdownRendered}
7779
/>
7880
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}

packages/ui/src/lib/i18n/messages/de/markdown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const markdownMessages = {
22
"markdown.codeBlock.copy.label": "Kopieren",
33
"markdown.codeBlock.copy.copied": "Kopiert!",
44
"markdown.codeBlock.copy.failed": "Fehlgeschlagen",
5+
"markdown.codeBlock.wrap.enable": "Zeilenumbruch aktivieren",
6+
"markdown.codeBlock.wrap.disable": "Zeilenumbruch deaktivieren",
57

68
"markdown.copy": "Kopieren",
79
} as const

packages/ui/src/lib/i18n/messages/en/markdown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const markdownMessages = {
22
"markdown.codeBlock.copy.label": "Copy",
33
"markdown.codeBlock.copy.copied": "Copied!",
44
"markdown.codeBlock.copy.failed": "Failed",
5+
"markdown.codeBlock.wrap.enable": "Enable word wrap",
6+
"markdown.codeBlock.wrap.disable": "Disable word wrap",
57

68
"markdown.copy": "Copy",
79
} as const

packages/ui/src/lib/i18n/messages/es/markdown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const markdownMessages = {
22
"markdown.codeBlock.copy.label": "Copiar",
33
"markdown.codeBlock.copy.copied": "¡Copiado!",
44
"markdown.codeBlock.copy.failed": "Error",
5+
"markdown.codeBlock.wrap.enable": "Activar ajuste de línea",
6+
"markdown.codeBlock.wrap.disable": "Desactivar ajuste de línea",
57

68
"markdown.copy": "Copiar",
79
} as const

packages/ui/src/lib/i18n/messages/fr/markdown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const markdownMessages = {
22
"markdown.codeBlock.copy.label": "Copier",
33
"markdown.codeBlock.copy.copied": "Copié !",
44
"markdown.codeBlock.copy.failed": "Échec",
5+
"markdown.codeBlock.wrap.enable": "Activer le retour à la ligne",
6+
"markdown.codeBlock.wrap.disable": "Désactiver le retour à la ligne",
57

68
"markdown.copy": "Copier",
79
} as const

packages/ui/src/lib/i18n/messages/he/markdown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const markdownMessages = {
22
"markdown.codeBlock.copy.label": "העתק",
33
"markdown.codeBlock.copy.copied": "הועתק!",
44
"markdown.codeBlock.copy.failed": "נכשל",
5+
"markdown.codeBlock.wrap.enable": "הפעל גלישת שורות",
6+
"markdown.codeBlock.wrap.disable": "כבה גלישת שורות",
57

68
"markdown.copy": "העתק",
79
} as const

packages/ui/src/lib/i18n/messages/ja/markdown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const markdownMessages = {
22
"markdown.codeBlock.copy.label": "コピー",
33
"markdown.codeBlock.copy.copied": "コピーしました!",
44
"markdown.codeBlock.copy.failed": "失敗",
5+
"markdown.codeBlock.wrap.enable": "折り返しを有効にする",
6+
"markdown.codeBlock.wrap.disable": "折り返しを無効にする",
57

68
"markdown.copy": "コピー",
79
} as const

packages/ui/src/lib/i18n/messages/ne/markdown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const markdownMessages = {
22
"markdown.codeBlock.copy.label": "प्रतिलिपि गर्नुहोस्",
33
"markdown.codeBlock.copy.copied": "प्रतिलिपि गरियो!",
44
"markdown.codeBlock.copy.failed": "असफल",
5+
"markdown.codeBlock.wrap.enable": "लाइन र्‍याप सक्षम गर्नुहोस्",
6+
"markdown.codeBlock.wrap.disable": "लाइन र्‍याप असक्षम गर्नुहोस्",
57

68
"markdown.copy": "प्रतिलिपि गर्नुहोस्",
79
} as const

packages/ui/src/lib/i18n/messages/ru/markdown.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ export const markdownMessages = {
22
"markdown.codeBlock.copy.label": "Копировать",
33
"markdown.codeBlock.copy.copied": "Скопировано!",
44
"markdown.codeBlock.copy.failed": "Не удалось",
5+
"markdown.codeBlock.wrap.enable": "Включить перенос строк",
6+
"markdown.codeBlock.wrap.disable": "Отключить перенос строк",
57

68
"markdown.copy": "Копировать",
79
} as const

0 commit comments

Comments
 (0)