feat(artifact): 添加内容预览缩放选择器和全局format命令
This commit is contained in:
parent
bc20208d0f
commit
a8d1c8367f
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue