feat(web): add line numbers and wrap toggle for code blocks (#2933)

* feat(web): add line numbers and wrap toggle for code blocks

* fix(web): preserve markdown code block copy semantics
This commit is contained in:
LC
2026-05-26 14:57:52 +08:00
committed by GitHub
parent c5a016ccc6
commit 28ec5793a8
10 changed files with 402 additions and 33 deletions
@@ -13,6 +13,10 @@ import rehypeSanitize from "rehype-sanitize"
import remarkGfm from "remark-gfm"
import type { SkillDetailResponse, SkillSupportItem } from "@/api/skills"
import {
MarkdownCodeBlock,
MessageCodeBlock,
} from "@/components/chat/message-code-block"
import {
Sheet,
SheetContent,
@@ -176,6 +180,9 @@ export function DetailSheet({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
components={{
pre: MarkdownCodeBlock,
}}
>
{selectedSkillDetail.content}
</ReactMarkdown>
@@ -183,11 +190,12 @@ export function DetailSheet({
) : null}
{detailView === "raw" ? (
<div className="border-border/50 overflow-x-auto rounded-xl border bg-zinc-950 p-5 shadow-sm">
<pre className="font-mono text-[13px] leading-relaxed break-words whitespace-pre-wrap text-zinc-100/90">
<code>{selectedSkillDetail.content}</code>
</pre>
</div>
<MessageCodeBlock
code={selectedSkillDetail.content}
label={t("pages.agent.skills.detail_tabs.raw")}
className="my-0"
bodyClassName="text-[13px] leading-relaxed"
/>
) : null}
{detailView === "meta" ? (
@@ -1,4 +1,5 @@
import type { ChannelConfig } from "@/api/channels"
import { MessageCodeBlock } from "@/components/chat/message-code-block"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput } from "@/components/shared-form"
import {
@@ -180,9 +181,12 @@ export function MqttForm({
{t("channels.mqtt.uplink")}
</p>
<CodeLine>{`${topicBase}/request`}</CodeLine>
<pre className="bg-muted text-foreground rounded px-3 py-2 font-mono text-xs leading-relaxed">
{`{\n "text": "your message"\n}`}
</pre>
<MessageCodeBlock
code={`{\n "text": "your message"\n}`}
language="json"
className="my-0"
bodyClassName="px-3 py-2 text-xs leading-relaxed"
/>
<div className="text-muted-foreground space-y-1 text-xs">
<p>
<span className="text-foreground font-medium">
@@ -199,9 +203,12 @@ export function MqttForm({
{t("channels.mqtt.downlink")}
</p>
<CodeLine>{`${topicBase}/response`}</CodeLine>
<pre className="bg-muted text-foreground rounded px-3 py-2 font-mono text-xs leading-relaxed">
{`{\n "text": "agent response"\n}`}
</pre>
<MessageCodeBlock
code={`{\n "text": "agent response"\n}`}
language="json"
className="my-0"
bodyClassName="px-3 py-2 text-xs leading-relaxed"
/>
<div className="text-muted-foreground space-y-1 text-xs">
<p>
<span className="text-foreground font-medium">
@@ -197,7 +197,6 @@ export function AssistantMessage({
label={toolName || t("chat.toolCallArgumentsLabel")}
className="my-0 shadow-none"
bodyClassName="px-3 py-2 text-[12px] leading-relaxed"
wrapLongLines
/>
)}
</div>
@@ -3,17 +3,30 @@ import {
IconChevronDown,
IconCopy,
} from "@tabler/icons-react"
import { useAtom } from "jotai"
import hljs from "highlight.js/lib/core"
import json from "highlight.js/lib/languages/json"
import { type ComponentProps, type ReactNode, useState } from "react"
import {
type ComponentProps,
type CSSProperties,
type ReactNode,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
import { cn } from "@/lib/utils"
import { codeBlockWrapAtom } from "@/store/code-block"
import {
extractCodeBlockFromPreNode,
extractCodeBlockRenderState,
type MarkdownNode,
splitCodeIntoLines,
splitHighlightedHtmlIntoLines,
splitRenderedCodeContentIntoLines,
trimTrailingEmptyRenderedCodeLine,
trimTrailingEmptyStringLine,
} from "./message-code-block.utils"
import { Button } from "@/components/ui/button"
@@ -27,10 +40,10 @@ interface MessageCodeBlockProps {
code: string
language?: string | null
label?: string
children?: ReactNode
className?: string
bodyClassName?: string
wrapLongLines?: boolean
children?: ReactNode
trimTrailingEmptyLine?: boolean
}
interface MarkdownCodeBlockProps extends ComponentProps<"pre"> {
@@ -53,13 +66,14 @@ export function MessageCodeBlock({
code,
language = null,
label,
children,
className,
bodyClassName,
wrapLongLines = false,
children,
trimTrailingEmptyLine = false,
}: MessageCodeBlockProps) {
const { t } = useTranslation()
const { copy, isCopied } = useCopyToClipboard()
const [wrapLongLines, setWrapLongLines] = useAtom(codeBlockWrapAtom)
const [isExpanded, setIsExpanded] = useState(true)
const blockLabel =
label ??
@@ -68,7 +82,31 @@ export function MessageCodeBlock({
: t("chat.codeLabel").toLocaleLowerCase())
const copyLabel = isCopied ? t("chat.copiedLabel") : t("chat.copyCode")
const expandLabel = isExpanded ? t("chat.collapseCode") : t("chat.expandCode")
const wrapLabel = wrapLongLines
? t("chat.disableCodeWrap")
: t("chat.enableCodeWrap")
const renderedCodeState = children
? extractCodeBlockRenderState(children)
: {
renderedContent: null,
className: undefined,
}
const highlightedHtml = !children ? getHighlightedHtml(code, language) : null
const highlightedLines = highlightedHtml
? splitHighlightedHtmlIntoLines(highlightedHtml)
: null
const codeLines = children
? (trimTrailingEmptyLine
? trimTrailingEmptyRenderedCodeLine(
splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent),
)
: splitRenderedCodeContentIntoLines(renderedCodeState.renderedContent))
: (trimTrailingEmptyLine
? trimTrailingEmptyStringLine(
highlightedLines ?? splitCodeIntoLines(code),
)
: (highlightedLines ?? splitCodeIntoLines(code)))
const lineNumberWidth = `${String(codeLines.length).length + 1}ch`
return (
<div
@@ -102,6 +140,18 @@ export function MessageCodeBlock({
)}
<span className="hidden sm:inline">{copyLabel}</span>
</Button>
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 px-2 text-[11px] text-zinc-600 hover:bg-zinc-300/70 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
onClick={() => setWrapLongLines((current) => !current)}
aria-pressed={wrapLongLines}
aria-label={wrapLabel}
title={wrapLabel}
>
{wrapLabel}
</Button>
<Button
type="button"
variant="ghost"
@@ -123,23 +173,56 @@ export function MessageCodeBlock({
{isExpanded && (
<pre
className={cn(
"m-0 overflow-x-auto bg-transparent px-4 py-3 font-mono text-[13px] leading-6 [&_code]:block [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-inherit",
wrapLongLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
"m-0 overflow-x-auto bg-transparent px-4 py-3 font-mono text-[13px] leading-6",
bodyClassName,
)}
>
{children ?? (
highlightedHtml ? (
<code
className={cn("hljs", language && `language-${language}`)}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
) : (
<code className={language ? `language-${language}` : undefined}>
{code}
</code>
)
)}
<code
className={cn(
"block bg-transparent p-0 text-inherit",
children
? renderedCodeState.className
: cn(highlightedHtml && "hljs", language && `language-${language}`),
)}
>
{codeLines.map((line, index) => (
<span
key={`${index}-${line.length}`}
className="grid grid-cols-[var(--code-line-number-width)_minmax(0,1fr)] items-start gap-x-3"
style={
{
"--code-line-number-width": lineNumberWidth,
} as CSSProperties
}
>
<span className="sticky left-0 z-1 select-none bg-[#f6f8fa] text-right text-zinc-500/80 dark:bg-[#0d1117] dark:text-zinc-500">
{index + 1}
</span>
{!children && highlightedLines ? (
<span
className={cn(
"min-w-0",
wrapLongLines
? "break-words whitespace-pre-wrap"
: "whitespace-pre",
)}
dangerouslySetInnerHTML={{ __html: line }}
/>
) : (
<span
className={cn(
"min-w-0",
wrapLongLines
? "break-words whitespace-pre-wrap"
: "whitespace-pre",
)}
>
{line}
</span>
)}
</span>
))}
</code>
</pre>
)}
</div>
@@ -158,6 +241,7 @@ export function MarkdownCodeBlock({
code={code}
language={language}
bodyClassName={className}
trimTrailingEmptyLine
>
{children}
</MessageCodeBlock>
@@ -1,3 +1,11 @@
import {
Children,
cloneElement,
Fragment,
isValidElement,
type ReactNode,
} from "react"
export interface MarkdownNode {
type?: string
value?: string
@@ -6,7 +14,7 @@ export interface MarkdownNode {
children?: MarkdownNode[]
}
function toClassNameTokens(className: unknown): string[] {
export function toClassNameTokens(className: unknown): string[] {
if (typeof className === "string") {
return className.split(/\s+/).filter(Boolean)
}
@@ -72,6 +80,10 @@ export function extractCodeBlockLanguage(className: unknown): string | null {
return languageToken ? languageToken.slice("language-".length) : null
}
export function stripSingleTrailingLineBreak(value: string): string {
return value.replace(/\r?\n$/, "")
}
export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
code: string
language: string | null
@@ -79,7 +91,248 @@ export function extractCodeBlockFromPreNode(node: MarkdownNode | undefined): {
const codeNode = findFirstDescendantByTagName(node, "code")
return {
code: extractTextFromMarkdownNode(codeNode ?? node),
code: stripSingleTrailingLineBreak(extractTextFromMarkdownNode(codeNode ?? node)),
language: extractCodeBlockLanguage(codeNode?.properties?.className),
}
}
export function extractCodeBlockRenderState(children: ReactNode): {
renderedContent: ReactNode
className: string | undefined
} {
const childNodes = Children.toArray(children)
const codeChild = childNodes.find(
(child) =>
isValidElement<{ children?: ReactNode; className?: unknown }>(child) &&
typeof child.type === "string" &&
child.type === "code",
)
if (
isValidElement<{ children?: ReactNode; className?: unknown }>(codeChild)
) {
const classNameTokens = toClassNameTokens(codeChild.props.className)
return {
renderedContent: codeChild.props.children,
className:
classNameTokens.length > 0 ? classNameTokens.join(" ") : undefined,
}
}
return {
renderedContent: children,
className: undefined,
}
}
function mergeNodeLineGroups(
currentLines: Node[][],
nextLines: Node[][],
): Node[][] {
if (nextLines.length === 0) {
return currentLines
}
const mergedLines = currentLines.map((line) => [...line])
mergedLines[mergedLines.length - 1].push(...nextLines[0])
for (const line of nextLines.slice(1)) {
mergedLines.push([...line])
}
return mergedLines
}
function splitDomNodeIntoLines(node: Node, ownerDocument: Document): Node[][] {
if (node.nodeType === Node.TEXT_NODE) {
return (node.textContent ?? "").split("\n").map((line) =>
line.length > 0 ? [ownerDocument.createTextNode(line)] : [],
)
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return [[]]
}
const element = node as Element
if (element.tagName.toLowerCase() === "br") {
return [
[],
[],
]
}
const childLines = splitHighlightedHtmlIntoNodeLines(
Array.from(element.childNodes),
ownerDocument,
)
return childLines.map((lineChildren) => {
const clonedElement = element.cloneNode(false)
for (const child of lineChildren) {
clonedElement.appendChild(child)
}
return [clonedElement]
})
}
function splitHighlightedHtmlIntoNodeLines(
nodes: Node[],
ownerDocument: Document,
): Node[][] {
let lines: Node[][] = [[]]
for (const node of nodes) {
lines = mergeNodeLineGroups(
lines,
splitDomNodeIntoLines(node, ownerDocument),
)
}
return lines
}
export function splitCodeIntoLines(code: string): string[] {
return code.split("\n")
}
export function splitHighlightedHtmlIntoLines(highlightedHtml: string): string[] {
if (typeof document === "undefined") {
return splitCodeIntoLines(highlightedHtml)
}
const container = document.createElement("div")
container.innerHTML = highlightedHtml
return splitHighlightedHtmlIntoNodeLines(
Array.from(container.childNodes),
document,
).map((lineNodes) => {
const lineContainer = document.createElement("div")
for (const node of lineNodes) {
lineContainer.appendChild(node)
}
return lineContainer.innerHTML
})
}
export function trimTrailingEmptyStringLine(lines: string[]): string[] {
if (lines.length > 1 && lines[lines.length - 1] === "") {
return lines.slice(0, -1)
}
return lines
}
function isEmptyRenderedCodeNode(node: ReactNode): boolean {
if (node === null || node === undefined || typeof node === "boolean") {
return true
}
if (typeof node === "string" || typeof node === "number") {
return String(node).length === 0
}
if (Array.isArray(node)) {
return node.every(isEmptyRenderedCodeNode)
}
if (!isValidElement<{ children?: ReactNode }>(node)) {
return false
}
return Children.toArray(node.props.children).every(isEmptyRenderedCodeNode)
}
export function trimTrailingEmptyRenderedCodeLine(
lines: ReactNode[][],
): ReactNode[][] {
if (
lines.length > 1 &&
lines[lines.length - 1].every(isEmptyRenderedCodeNode)
) {
return lines.slice(0, -1)
}
return lines
}
function mergeReactLineGroups(
currentLines: ReactNode[][],
nextLines: ReactNode[][],
): ReactNode[][] {
if (nextLines.length === 0) {
return currentLines
}
const mergedLines = currentLines.map((line) => [...line])
mergedLines[mergedLines.length - 1].push(...nextLines[0])
for (const line of nextLines.slice(1)) {
mergedLines.push([...line])
}
return mergedLines
}
function splitTextNodeIntoLines(value: string | number): ReactNode[][] {
return String(value).split("\n").map((line) => (line.length > 0 ? [line] : []))
}
function splitReactNodeIntoLines(node: ReactNode): ReactNode[][] {
if (node === null || node === undefined || typeof node === "boolean") {
return [[]]
}
if (typeof node === "string" || typeof node === "number") {
return splitTextNodeIntoLines(node)
}
if (Array.isArray(node)) {
return splitRenderedCodeContentIntoLines(node)
}
if (!isValidElement<{ children?: ReactNode }>(node)) {
return [[node]]
}
if (node.type === Fragment) {
return splitRenderedCodeContentIntoLines(Children.toArray(node.props.children))
}
if (typeof node.type === "string" && node.type === "br") {
return [
[],
[],
]
}
const childLines = splitRenderedCodeContentIntoLines(
Children.toArray(node.props.children),
)
return childLines.map((lineChildren, lineIndex) => [
cloneElement(
node,
{
key: `${node.key ?? "code-line"}-${lineIndex}`,
},
...lineChildren,
),
])
}
export function splitRenderedCodeContentIntoLines(
content: ReactNode,
): ReactNode[][] {
const contentNodes = Array.isArray(content) ? content : [content]
let lines: ReactNode[][] = [[]]
for (const node of contentNodes) {
lines = mergeReactLineGroups(lines, splitReactNodeIntoLines(node))
}
return lines
}
+2
View File
@@ -76,6 +76,8 @@
"copyMessage": "Copy message",
"copyCode": "Copy code",
"copiedLabel": "Copied",
"enableCodeWrap": "Wrap lines",
"disableCodeWrap": "Disable wrap",
"expandCode": "Expand code",
"collapseCode": "Collapse code",
"history": "History",
+2
View File
@@ -76,6 +76,8 @@
"copyMessage": "Copiar mensagem",
"copyCode": "Copiar código",
"copiedLabel": "Copiado",
"enableCodeWrap": "Quebrar linhas",
"disableCodeWrap": "Desativar quebra",
"expandCode": "Expandir código",
"collapseCode": "Recolher código",
"history": "Histórico",
+2
View File
@@ -76,6 +76,8 @@
"copyMessage": "复制消息",
"copyCode": "复制代码",
"copiedLabel": "已复制",
"enableCodeWrap": "开启换行",
"disableCodeWrap": "关闭换行",
"expandCode": "展开代码",
"collapseCode": "折叠代码",
"history": "历史记录",
+11
View File
@@ -0,0 +1,11 @@
import { atomWithStorage } from "jotai/utils"
export const CODE_BLOCK_WRAP_STORAGE_KEY = "picoclaw:code-block-wrap"
export const DEFAULT_CODE_BLOCK_WRAP = false
export const codeBlockWrapAtom = atomWithStorage<boolean>(
CODE_BLOCK_WRAP_STORAGE_KEY,
DEFAULT_CODE_BLOCK_WRAP,
undefined,
{ getOnInit: true },
)
+1
View File
@@ -1,3 +1,4 @@
export * from "./gateway"
export * from "./chat"
export * from "./code-block"
export * from "./tour"