feat(artifact): 添加内容预览缩放选择器和全局format命令

This commit is contained in:
肖应宇 2026-03-17 15:26:59 +08:00
parent bc20208d0f
commit a8d1c8367f
5 changed files with 293 additions and 13 deletions

View File

@ -0,0 +1,149 @@
# Artifact 缩放功能文档
## 功能概述
Artifact 缩放功能允许用户在预览 Markdown 和 HTML 内容时调整缩放比例50% - 200%),提供更灵活的阅读体验。
## 文件结构
```
frontend/src/
├── components/
│ └── workspace/
│ └── artifacts/
│ └── artifact-file-detail.tsx # 缩放组件定义与使用
└── styles/
└── globals.css # 缩放相关 CSS 变量
```
## 核心实现
### 1. 缩放选择器组件 (`ArtifactZoomSelector`)
**位置**: `artifact-file-detail.tsx`
```tsx
// 缩放比例选项
const ZOOM_LEVELS = [50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 175, 200];
export const ArtifactZoomSelector = ({
value = 100,
onChange,
}: ArtifactZoomSelectorProps) => {
// 放大/缩小逻辑
// 返回: [ZoomOut图标] [百分比文本] [ZoomIn图标]
};
```
**UI 设计**:
- 白色圆角容器,带轻微阴影和模糊效果
- 水平三元素布局:缩小按钮 | 百分比显示 | 放大按钮
- 支持深色模式
- 边界值时按钮自动禁用
### 2. CSS 变量缩放 (`globals.css`)
```css
/* 缩放变量,默认为 1 (100%) */
:root {
--zoom-scale: 1;
}
/* 字体大小使用 calc() 计算 */
p {
font-size: calc(14px * var(--zoom-scale));
}
[data-streamdown="heading-1"] {
font-size: calc(20px * var(--zoom-scale));
}
[data-streamdown="heading-2"],
[data-streamdown="heading-3"] {
font-size: calc(16px * var(--zoom-scale));
}
```
### 3. 预览组件集成 (`ArtifactFilePreview`)
```tsx
export function ArtifactFilePreview({
content,
language,
zoom = 100,
}: {
content: string;
language: string;
zoom?: number;
}) {
const zoomScale = zoom / 100;
// Markdown: 使用 CSS 变量
if (language === "markdown") {
return (
<div style={{ "--zoom-scale": zoomScale }}>
<Streamdown>...</Streamdown>
</div>
);
}
// HTML: 使用 CSS zoom 属性
if (language === "html") {
return <iframe style={{ zoom: zoomScale }} />;
}
}
```
## 使用方式
### 在 ArtifactHeader 中使用
```tsx
function ArtifactFileDetail() {
const [zoom, setZoom] = useState(100);
return (
<Artifact>
<ArtifactHeader>
<div className="flex items-center gap-2">
{/* 其他控件 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
</div>
{/* 中间标题 */}
{/* 右侧操作按钮 */}
</ArtifactHeader>
<ArtifactContent>
<ArtifactFilePreview
content={content}
language="markdown"
zoom={zoom}
/>
</ArtifactContent>
</Artifact>
);
}
```
## 缩放级别
| 级别 | 比例 |
|------|------|
| 最小 | 50% |
| | 60%, 70%, 80%, 90% |
| 默认 | 100% |
| | 110%, 120%, 130%, 150%, 175% |
| 最大 | 200% |
## 技术要点
1. **CSS 变量方式** - 适用于 Markdown 内容,通过 `--zoom-scale` 变量控制所有字体和间距
2. **CSS zoom 属性** - 适用于 HTML iframe直接缩放整个内容
3. **状态提升** - `zoom` 状态在父组件管理,通过 props 传递给预览组件
4. **响应式** - 缩放变化时所有相关样式自动重新计算
## 扩展建议
- 可添加快捷键支持(如 `Ctrl++` / `Ctrl+-`
- 可支持双击重置为 100%
- 可将缩放状态持久化到 localStorage

View File

@ -8,6 +8,8 @@
"build": "next build",
"check": "next lint && tsc --noEmit",
"dev": "next dev --turbo",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json}\"",
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"preview": "next build && next start",

View File

@ -7,8 +7,17 @@ import {
PackageIcon,
SquareArrowOutUpRightIcon,
XIcon,
type LucideIcon,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useState,
type HTMLAttributes,
} from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@ -94,6 +103,7 @@ export function ArtifactFileDetail({
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false);
const [zoom, setZoom] = useState(100);
const { isMock } = useThread();
useEffect(() => {
if (isSupportPreview) {
@ -163,8 +173,11 @@ export function ArtifactFileDetail({
</ArtifactTitle>
</div>
<div className="flex items-center justify-end gap-2">
{/* 放大缩小选择器 */}
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
<ArtifactActions>
{!isWriteFile && filepath.endsWith(".skill") && (
{/* 新界面打开的按钮 */}
{/* {!isWriteFile && filepath.endsWith(".skill") && (
<Tooltip content={t.toolCalls.skillInstallTooltip}>
<ArtifactAction
icon={isInstalling ? LoaderIcon : PackageIcon}
@ -177,7 +190,7 @@ export function ArtifactFileDetail({
onClick={handleInstallSkill}
/>
</Tooltip>
)}
)} */}
{!isWriteFile && (
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
<ArtifactAction
@ -225,7 +238,7 @@ export function ArtifactFileDetail({
</ArtifactActions>
</div>
</ArtifactHeader>
{/* 主内容区 */}
<ArtifactContent className="rounded-[10px] bg-white p-0">
{isSupportPreview &&
viewMode === "preview" &&
@ -233,12 +246,14 @@ export function ArtifactFileDetail({
<ArtifactFilePreview
content={displayContent}
language={language ?? "text"}
zoom={zoom}
/>
)}
{isCodeFile && viewMode === "code" && (
<CodeEditor
className="size-full resize-none rounded-none border-none"
value={displayContent ?? ""}
zoom={zoom}
readonly
/>
)}
@ -256,13 +271,20 @@ export function ArtifactFileDetail({
export function ArtifactFilePreview({
content,
language,
zoom = 100,
}: {
content: string;
language: string;
zoom?: number;
}) {
const zoomScale = zoom / 100;
if (language === "markdown") {
return (
<div className={cn("size-full p-[20px]")}>
<div
className={cn("size-full p-[20px]")}
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
@ -280,8 +302,95 @@ export function ArtifactFilePreview({
title="Artifact preview"
srcDoc={content}
sandbox="allow-scripts allow-forms"
style={{ zoom: zoomScale }}
/>
);
}
return null;
}
// 缩放比例选项
const ZOOM_LEVELS = [50, 60, 70, 80, 90, 100, 110, 120, 130, 150, 175, 200];
export type ArtifactZoomSelectorProps = Omit<
HTMLAttributes<HTMLDivElement>,
"onChange"
> & {
value?: number;
onChange?: (value: number) => void;
};
export const ArtifactZoomSelector = ({
value = 100,
onChange,
className,
...props
}: ArtifactZoomSelectorProps) => {
const handleZoomIn = () => {
const currentIndex = ZOOM_LEVELS.indexOf(value);
const nextValue = ZOOM_LEVELS[currentIndex + 1];
if (currentIndex < ZOOM_LEVELS.length - 1 && nextValue !== undefined) {
onChange?.(nextValue);
}
};
const handleZoomOut = () => {
const currentIndex = ZOOM_LEVELS.indexOf(value);
const prevValue = ZOOM_LEVELS[currentIndex - 1];
if (currentIndex > 0 && prevValue !== undefined) {
onChange?.(prevValue);
}
};
const canZoomIn = ZOOM_LEVELS.indexOf(value) < ZOOM_LEVELS.length - 1;
const canZoomOut = ZOOM_LEVELS.indexOf(value) > 0;
return (
<div
className={cn(
"inline-flex items-center gap-2 rounded-[10px] bg-white px-2 py-1 backdrop-blur-sm",
"border border-gray-200/50",
"dark:border-gray-700/50 dark:bg-gray-800/90",
className,
)}
{...props}
>
<button
type="button"
onClick={handleZoomIn}
disabled={!canZoomIn}
className={cn(
"flex h-6 w-6 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)}
aria-label="放大"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<span
className={cn(
"min-w-[42px] text-center text-xs font-medium text-gray-600",
"dark:text-gray-300",
)}
>
{value}%
</span>
<button
type="button"
onClick={handleZoomOut}
disabled={!canZoomOut}
className={cn(
"flex h-6 w-6 items-center justify-center rounded transition-colors",
"text-gray-400 hover:bg-gray-100 hover:text-gray-600",
"disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent",
"dark:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-300",
)}
aria-label="缩小"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
</div>
);
};

View File

@ -42,6 +42,7 @@ export function CodeEditor({
disabled,
autoFocus,
settings,
zoom = 100,
}: {
className?: string;
placeholder?: string;
@ -50,6 +51,7 @@ export function CodeEditor({
disabled?: boolean;
autoFocus?: boolean;
settings?: unknown;
zoom?: number;
}) {
const {
thread: { isLoading },
@ -69,13 +71,14 @@ export function CodeEditor({
python(),
];
}, []);
const zoomScale = zoom / 100;
return (
<div
className={cn(
"flex cursor-text flex-col overflow-hidden rounded-md",
className,
)}
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
>
{isLoading ? (
<Textarea

View File

@ -414,25 +414,42 @@
/* ========================================
Streamdown Markdown Styles
使用 data-streamdown 属性选择器统一定义
支持 zoom-scale CSS 变量进行缩放
======================================== */
/* 缩放变量,默认为 1 (100%) */
:root {
--zoom-scale: 1;
}
/* p标签没有标识data-streamdown暂时只能这么写 */
p {
font-size: 14px;
font-size: calc(14px * var(--zoom-scale));
}
/* 列表项 - 14px */
[data-streamdown="list-item"] {
font-size: 14px;
padding-top: 4px;
padding-bottom: 4px;
font-size: calc(14px * var(--zoom-scale));
padding-top: calc(4px * var(--zoom-scale));
padding-bottom: calc(4px * var(--zoom-scale));
}
/* 一级标题 - 20px */
/* 一级标题 - 20px */
[data-streamdown="heading-1"] {
font-size: 20px;
font-size: calc(20px * var(--zoom-scale));
}
/* 二三级标题 - 16px */
[data-streamdown="heading-2"],
[data-streamdown="heading-3"] {
font-size: 16px;
font-size: calc(16px * var(--zoom-scale));
}
/* 二三级标题 - 16px */
[data-streamdown="code-block"] pre {
font-size: calc(16px * var(--zoom-scale));
}
.cm-line {
font-size: calc(14px * var(--zoom-scale));
}