mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -76,6 +76,8 @@
|
||||
"copyMessage": "复制消息",
|
||||
"copyCode": "复制代码",
|
||||
"copiedLabel": "已复制",
|
||||
"enableCodeWrap": "开启换行",
|
||||
"disableCodeWrap": "关闭换行",
|
||||
"expandCode": "展开代码",
|
||||
"collapseCode": "折叠代码",
|
||||
"history": "历史记录",
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./gateway"
|
||||
export * from "./chat"
|
||||
export * from "./code-block"
|
||||
export * from "./tour"
|
||||
|
||||
Reference in New Issue
Block a user