代码块
为代码块提供语法高亮、行号和复制到剪贴板功能。
CodeBlock 组件为代码块提供语法高亮、行号和复制到剪贴板功能。
使用 CLI 安装
AI Elements Vue
shadcn-vue CLI
npx ai-elements-vue@latest add code-block
npx shadcn-vue@latest add https://registry.ai-elements-vue.com/code-block.json
手动安装
将以下文件复制并粘贴到同一文件夹中。
CodeBlock.vue
CodeBlockCopyButton.vue
context.ts
utils.ts
index.ts
<script setup lang="ts">
import type { BundledLanguage } from 'shiki'
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { reactiveOmit } from '@vueuse/core'
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'
import { CodeBlockKey } from './context'
import { highlightCode } from './utils'
const props = withDefaults(
defineProps<{
code: string
language: BundledLanguage
showLineNumbers?: boolean
class?: HTMLAttributes['class']
}>(),
{
showLineNumbers: false,
},
)
const delegatedProps = reactiveOmit(props, 'code', 'language', 'showLineNumbers', 'class')
const html = ref('')
const darkHtml = ref('')
provide(CodeBlockKey, {
code: computed(() => props.code),
})
let requestId = 0
let isUnmounted = false
watch(
() => [props.code, props.language, props.showLineNumbers] as const,
async ([code, language, showLineNumbers]) => {
requestId += 1
const currentId = requestId
try {
const [light, dark] = await highlightCode(code, language, showLineNumbers)
if (currentId === requestId && !isUnmounted) {
html.value = light
darkHtml.value = dark
}
}
catch (error) {
console.error('[CodeBlock] highlight failed', error)
}
},
{ immediate: true },
)
onBeforeUnmount(() => {
isUnmounted = true
})
</script>
<template>
<div
data-slot="code-block"
v-bind="delegatedProps"
:class="cn('group relative w-full overflow-hidden rounded-md border bg-background text-foreground', props.class)"
>
<div class="relative">
<div
class="overflow-hidden dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
v-html="html"
/>
<div
class="hidden overflow-hidden dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
v-html="darkHtml"
/>
<div v-if="$slots.default" class="absolute top-2 right-2 flex items-center gap-2">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { Button } from '@repo/shadcn-vue/components/ui/button'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { reactiveOmit } from '@vueuse/core'
import { CheckIcon, CopyIcon } from 'lucide-vue-next'
import { computed, inject, onBeforeUnmount, ref } from 'vue'
import { CodeBlockKey } from './context'
const props = withDefaults(
defineProps<{
timeout?: number
class?: HTMLAttributes['class']
}>(),
{
timeout: 2000,
},
)
const emit = defineEmits<{
(event: 'copy'): void
(event: 'error', error: Error): void
}>()
const delegatedProps = reactiveOmit(props, 'timeout', 'class')
const context = inject(CodeBlockKey)
if (!context)
throw new Error('CodeBlockCopyButton must be used within a <CodeBlock />')
const { code } = context
const isCopied = ref(false)
let resetTimer: ReturnType<typeof setTimeout> | undefined
const icon = computed(() => (isCopied.value ? CheckIcon : CopyIcon))
async function copyToClipboard() {
if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) {
const error = new Error('Clipboard API not available')
emit('error', error)
return
}
try {
await navigator.clipboard.writeText(code.value)
isCopied.value = true
emit('copy')
if (resetTimer) {
clearTimeout(resetTimer)
}
resetTimer = setTimeout(() => {
isCopied.value = false
}, props.timeout)
}
catch (error) {
emit('error', error instanceof Error ? error : new Error('Copy failed'))
}
}
onBeforeUnmount(() => {
if (resetTimer) {
clearTimeout(resetTimer)
}
})
</script>
<template>
<Button
data-slot="code-block-copy-button"
v-bind="delegatedProps"
:class="cn('shrink-0', props.class)"
size="icon"
variant="ghost"
@click="copyToClipboard"
>
<slot>
<component :is="icon" :size="14" />
</slot>
</Button>
</template>
import type { ComputedRef, InjectionKey } from 'vue'
export interface CodeBlockContext {
code: ComputedRef<string>
}
export const CodeBlockKey: InjectionKey<CodeBlockContext> = Symbol('CodeBlock')
import type { Element } from 'hast'
import type { BundledLanguage, ShikiTransformer } from 'shiki'
import { codeToHtml } from 'shiki'
const lineNumberTransformer: ShikiTransformer = {
name: 'line-numbers',
line(node: Element, line: number) {
node.children.unshift({
type: 'element',
tagName: 'span',
properties: {
className: [
'inline-block',
'min-w-10',
'mr-4',
'text-right',
'select-none',
'text-muted-foreground',
],
},
children: [{ type: 'text', value: String(line) }],
})
},
}
export async function highlightCode(
code: string,
language: BundledLanguage,
showLineNumbers = false,
) {
const transformers: ShikiTransformer[] = showLineNumbers
? [lineNumberTransformer]
: []
return await Promise.all([
codeToHtml(code, {
lang: language,
theme: 'one-light',
transformers,
}),
codeToHtml(code, {
lang: language,
theme: 'one-dark-pro',
transformers,
}),
])
}
export { default as CodeBlock } from './CodeBlock.vue'
export { default as CodeBlockCopyButton } from './CodeBlockCopyButton.vue'
与 AI SDK 一起使用
使用 experimental_useObject hook 构建简单的代码生成工具。
将以下组件添加到你的前端:
app/page.vue
<script setup lang="ts">
import { experimental_useObject as useObject } from '@ai-sdk/vue'
import { ref } from 'vue'
import { z } from 'zod'
import { CodeBlock, CodeBlockCopyButton } from '@/components/ai-elements/code-block'
import { PromptInput, PromptInputSubmit, PromptInputTextarea } from '@/components/ai-elements/prompt-input'
const codeBlockSchema = z.object({
language: z.string(),
filename: z.string(),
code: z.string(),
})
const input = ref('')
const { object, submit, isLoading } = useObject({
api: '/api/codegen',
schema: codeBlockSchema,
})
function handleSubmit(e: Event) {
e.preventDefault()
if (input.value.trim()) {
submit(input.value)
}
}
</script>
<template>
<div class="max-w-4xl mx-auto p-6 relative size-full rounded-lg border h-[600px]">
<div class="flex flex-col h-full">
<div class="flex-1 overflow-auto mb-4">
<CodeBlock
v-if="object?.code && object?.language"
:code="object.code"
:language="object.language"
:show-line-numbers="true"
>
<CodeBlockCopyButton />
</CodeBlock>
</div>
<PromptInput
class="mt-4 w-full max-w-2xl mx-auto relative"
@submit="handleSubmit"
>
<PromptInputTextarea
v-model="input"
placeholder="Generate a Vue todolist component"
class="pr-12"
/>
<PromptInputSubmit
:status="isLoading ? 'streaming' : 'ready'"
:disabled="!input.trim()"
class="absolute bottom-1 right-1"
/>
</PromptInput>
</div>
</div>
</template>
将以下路由添加到你的后端:
server/api/codegen.post.ts
import { openai } from '@ai-sdk/openai'
import { streamObject } from 'ai'
import { z } from 'zod'
const codeBlockSchema = z.object({
language: z.string(),
filename: z.string(),
code: z.string(),
})
export default defineEventHandler(async (event) => {
const { prompt } = await readBody(event)
const result = streamObject({
model: openai('gpt-4o'),
schema: codeBlockSchema,
prompt:
`You are a helpful coding assitant. Only generate code, no markdown formatting or backticks, or text.${
prompt}`,
})
return result.toTextStreamResponse()
})
特性
- 使用 Shiki 进行语法高亮
- 行号(可选)
- 复制到剪贴板功能
- 自动浅色/深色主题切换
- 可自定义样式
- 可访问的设计
示例
深色模式
要在深色模式中使用 CodeBlock 组件,可以将其包装在带有 dark 类的 div 中。
Props
<CodeBlock />
coderequiredstring
languagerequiredBundledLanguage
showLineNumbersboolean
classstring
<CodeBlockCopyButton />
timeoutnumber
classstring
@copy() => void
@error(error: Error) => void