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",
|
"build": "next build",
|
||||||
"check": "next lint && tsc --noEmit",
|
"check": "next lint && tsc --noEmit",
|
||||||
"dev": "next dev --turbo",
|
"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": "eslint . --ext .ts,.tsx",
|
||||||
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,17 @@ import {
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
SquareArrowOutUpRightIcon,
|
SquareArrowOutUpRightIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type HTMLAttributes,
|
||||||
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
|
|
@ -94,6 +103,7 @@ export function ArtifactFileDetail({
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||||
const [isInstalling, setIsInstalling] = useState(false);
|
const [isInstalling, setIsInstalling] = useState(false);
|
||||||
|
const [zoom, setZoom] = useState(100);
|
||||||
const { isMock } = useThread();
|
const { isMock } = useThread();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSupportPreview) {
|
if (isSupportPreview) {
|
||||||
|
|
@ -163,8 +173,11 @@ export function ArtifactFileDetail({
|
||||||
</ArtifactTitle>
|
</ArtifactTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{/* 放大缩小选择器 */}
|
||||||
|
<ArtifactZoomSelector value={zoom} onChange={setZoom} />
|
||||||
<ArtifactActions>
|
<ArtifactActions>
|
||||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
{/* 新界面打开的按钮 */}
|
||||||
|
{/* {!isWriteFile && filepath.endsWith(".skill") && (
|
||||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||||
|
|
@ -177,7 +190,7 @@ export function ArtifactFileDetail({
|
||||||
onClick={handleInstallSkill}
|
onClick={handleInstallSkill}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)} */}
|
||||||
{!isWriteFile && (
|
{!isWriteFile && (
|
||||||
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
|
<a href={urlOfArtifact({ filepath, threadId })} target="_blank">
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
|
|
@ -225,7 +238,7 @@ export function ArtifactFileDetail({
|
||||||
</ArtifactActions>
|
</ArtifactActions>
|
||||||
</div>
|
</div>
|
||||||
</ArtifactHeader>
|
</ArtifactHeader>
|
||||||
|
{/* 主内容区 */}
|
||||||
<ArtifactContent className="rounded-[10px] bg-white p-0">
|
<ArtifactContent className="rounded-[10px] bg-white p-0">
|
||||||
{isSupportPreview &&
|
{isSupportPreview &&
|
||||||
viewMode === "preview" &&
|
viewMode === "preview" &&
|
||||||
|
|
@ -233,12 +246,14 @@ export function ArtifactFileDetail({
|
||||||
<ArtifactFilePreview
|
<ArtifactFilePreview
|
||||||
content={displayContent}
|
content={displayContent}
|
||||||
language={language ?? "text"}
|
language={language ?? "text"}
|
||||||
|
zoom={zoom}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCodeFile && viewMode === "code" && (
|
{isCodeFile && viewMode === "code" && (
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
className="size-full resize-none rounded-none border-none"
|
className="size-full resize-none rounded-none border-none"
|
||||||
value={displayContent ?? ""}
|
value={displayContent ?? ""}
|
||||||
|
zoom={zoom}
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -256,13 +271,20 @@ export function ArtifactFileDetail({
|
||||||
export function ArtifactFilePreview({
|
export function ArtifactFilePreview({
|
||||||
content,
|
content,
|
||||||
language,
|
language,
|
||||||
|
zoom = 100,
|
||||||
}: {
|
}: {
|
||||||
content: string;
|
content: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
zoom?: number;
|
||||||
}) {
|
}) {
|
||||||
|
const zoomScale = zoom / 100;
|
||||||
|
|
||||||
if (language === "markdown") {
|
if (language === "markdown") {
|
||||||
return (
|
return (
|
||||||
<div className={cn("size-full p-[20px]")}>
|
<div
|
||||||
|
className={cn("size-full p-[20px]")}
|
||||||
|
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
|
||||||
|
>
|
||||||
<Streamdown
|
<Streamdown
|
||||||
className="size-full"
|
className="size-full"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
|
|
@ -280,8 +302,95 @@ export function ArtifactFilePreview({
|
||||||
title="Artifact preview"
|
title="Artifact preview"
|
||||||
srcDoc={content}
|
srcDoc={content}
|
||||||
sandbox="allow-scripts allow-forms"
|
sandbox="allow-scripts allow-forms"
|
||||||
|
style={{ zoom: zoomScale }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
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,
|
disabled,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
settings,
|
settings,
|
||||||
|
zoom = 100,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|
@ -50,6 +51,7 @@ export function CodeEditor({
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
settings?: unknown;
|
settings?: unknown;
|
||||||
|
zoom?: number;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
thread: { isLoading },
|
thread: { isLoading },
|
||||||
|
|
@ -69,13 +71,14 @@ export function CodeEditor({
|
||||||
python(),
|
python(),
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
const zoomScale = zoom / 100;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-text flex-col overflow-hidden rounded-md",
|
"flex cursor-text flex-col overflow-hidden rounded-md",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={{ "--zoom-scale": zoomScale } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
|
||||||
|
|
@ -414,25 +414,42 @@
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Streamdown Markdown Styles
|
Streamdown Markdown Styles
|
||||||
使用 data-streamdown 属性选择器统一定义
|
使用 data-streamdown 属性选择器统一定义
|
||||||
|
支持 zoom-scale CSS 变量进行缩放
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
|
/* 缩放变量,默认为 1 (100%) */
|
||||||
|
:root {
|
||||||
|
--zoom-scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* p标签没有标识data-streamdown,暂时只能这么写 */
|
/* p标签没有标识data-streamdown,暂时只能这么写 */
|
||||||
p {
|
p {
|
||||||
font-size: 14px;
|
font-size: calc(14px * var(--zoom-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 列表项 - 14px */
|
/* 列表项 - 14px */
|
||||||
[data-streamdown="list-item"] {
|
[data-streamdown="list-item"] {
|
||||||
font-size: 14px;
|
font-size: calc(14px * var(--zoom-scale));
|
||||||
padding-top: 4px;
|
padding-top: calc(4px * var(--zoom-scale));
|
||||||
padding-bottom: 4px;
|
padding-bottom: calc(4px * var(--zoom-scale));
|
||||||
}
|
}
|
||||||
/* 一级标题 - 20px */
|
|
||||||
|
|
||||||
|
/* 一级标题 - 20px */
|
||||||
[data-streamdown="heading-1"] {
|
[data-streamdown="heading-1"] {
|
||||||
font-size: 20px;
|
font-size: calc(20px * var(--zoom-scale));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 二三级标题 - 16px */
|
/* 二三级标题 - 16px */
|
||||||
[data-streamdown="heading-2"],
|
[data-streamdown="heading-2"],
|
||||||
[data-streamdown="heading-3"] {
|
[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