feat(drawnix): enhance fill options with gradient and image support

- Added support for gradient and image fills in the drawing tools.
- Updated PopupFillButton to handle different fill types, including solid, gradient, and image.
- Introduced new functions for setting gradient and image fills in property transforms.
- Enhanced the popover component to manage fill properties more effectively.

These changes significantly improve the flexibility of fill options available to users, allowing for more creative designs.
This commit is contained in:
liquidliang committed 2026-01-16 18:20:56 +08:00
1 parent 470742f08f
commit 206af04b67
16 files changed
+3295 -24

No files matched your search

@@ -0,0 +1,245 @@
---
name: fill-gradient-image-support
overview: 为图形设置的填充功能增加渐变填充和图片填充支持。渐变填充包含线性和径向两种类型,支持多色标和角度调整;图片填充支持从素材库选择或上传图片,提供拉伸/平铺/适应三种模式及缩放、偏移、旋转参数调整。
design:
architecture:
framework: react
component: shadcn
styleKeywords:
- 深色主题
- 面板式布局
- 紧凑型控件
fontSystem:
fontFamily: PingFang SC
heading:
size: 14px
weight: 600
subheading:
size: 12px
weight: 500
body:
size: 12px
weight: 400
colorSystem:
primary:
- "#3B82F6"
- "#60A5FA"
background:
- "#1E1E1E"
- "#2D2D2D"
- "#3D3D3D"
text:
- "#FFFFFF"
- "#A0A0A0"
functional:
- "#10B981"
- "#EF4444"
- "#F59E0B"
todos:
- id: explore-codebase
content: 使用 [subagent:code-explorer] 探索现有填充面板组件结构、类型定义和Canvas渲染逻辑
status: completed
- id: extend-fill-types
content: 扩展填充类型定义,添加GradientFillConfig和ImageFillConfig数据结构
status: completed
dependencies:
- explore-codebase
- id: gradient-fill-ui
content: 实现渐变填充组件,包含类型切换、色标编辑器和角度控制
status: completed
dependencies:
- extend-fill-types
- id: image-fill-ui
content: 实现图片填充组件,包含图片选择、平铺模式和高级参数面板
status: completed
dependencies:
- extend-fill-types
- id: fill-renderer
content: 扩展Canvas渲染逻辑,实现渐变和图片填充的绑定渲染
status: completed
dependencies:
- gradient-fill-ui
- image-fill-ui
- id: tab-integration
content: 集成三标签页切换逻辑,与现有纯色填充面板整合
status: completed
dependencies:
- fill-renderer
---
## 产品概述
为图形编辑器的填充功能扩展渐变填充和图片填充两种新模式,与现有纯色填充形成完整的三标签页填充面板,提升用户创作的灵活性和表现力。
## 核心功能
### 渐变填充
- 支持线性渐变和径向渐变两种类型切换
- 多色标编辑:可添加、删除、拖拽调整色标位置
- 每个色标支持颜色选择和透明度调整
- 线性渐变支持角度调整(0-360度)
- 径向渐变支持中心点位置调整
### 图片填充
- 图片来源:支持从素材库选择或本地上传图片
- 三种平铺模式:
- 拉伸:图片拉伸填满整个形状
- 平铺:图片按原始尺寸重复平铺
- 适应:图片等比缩放适应形状
- 高级模式参数调整:
- 缩放:调整图片整体缩放比例
- 偏移:调整图片X/Y方向偏移量
- 旋转:调整图片旋转角度
### 界面交互
- 三标签页切换界面:纯色/渐变/图片
- 实时预览填充效果
- 参数变更即时应用到画布图形
## 技术栈
基于现有项目技术栈进行扩展开发,复用已有的填充面板组件和样式体系。
## 技术架构
### 系统架构
```mermaid
graph TD
A[填充面板 FillPanel] --> B[标签页切换]
B --> C[纯色填充 SolidFill]
B --> D[渐变填充 GradientFill]
B --> E[图片填充 ImageFill]
D --> D1[渐变类型选择]
D --> D2[色标编辑器]
D --> D3[角度/中心点控制]
E --> E1[图片选择器]
E --> E2[平铺模式选择]
E --> E3[高级参数面板]
F[Canvas渲染引擎] --> G[渐变渲染]
F --> H[图片填充渲染]
```
### 模块划分
- **GradientFill模块**:渐变填充UI组件,包含类型切换、色标编辑器、角度控制
- **ImageFill模块**:图片填充UI组件,包含图片选择、平铺模式、高级参数
- **FillRenderer模块**:Canvas层填充渲染逻辑,处理渐变和图片的实际绘制
### 数据流
用户操作填充面板 → 更新填充配置数据 → 触发Canvas重绘 → 应用新填充效果
## 实现详情
### 核心目录结构
```
src/
├── components/
│ └── fill-panel/
│ ├── GradientFill.tsx # 新增:渐变填充组件
│ ├── GradientSlider.tsx # 新增:色标滑块组件
│ ├── ImageFill.tsx # 新增:图片填充组件
│ └── ImageAdvanced.tsx # 新增:图片高级参数组件
├── types/
│ └── fill.ts # 修改:扩展填充类型定义
└── utils/
└── fillRenderer.ts # 修改:添加渐变和图片渲染逻辑
```
### 关键代码结构
**渐变填充数据结构**:定义渐变填充的完整配置,包含渐变类型、色标列表和角度/中心点参数。
```typescript
interface GradientStop {
offset: number; // 0-1 位置
color: string; // 颜色值
opacity: number; // 透明度 0-1
}
interface GradientFillConfig {
type: 'linear' | 'radial';
stops: GradientStop[];
angle?: number; // 线性渐变角度
centerX?: number; // 径向渐变中心X
centerY?: number; // 径向渐变中心Y
}
```
**图片填充数据结构**:定义图片填充的完整配置,包含图片源、平铺模式和变换参数。
```typescript
interface ImageFillConfig {
imageUrl: string;
mode: 'stretch' | 'tile' | 'fit';
scale?: number; // 缩放比例
offsetX?: number; // X偏移
offsetY?: number; // Y偏移
rotation?: number; // 旋转角度
}
```
**填充类型联合定义**:扩展现有填充类型,统一管理三种填充模式。
```typescript
type FillType = 'solid' | 'gradient' | 'image';
interface FillConfig {
type: FillType;
solid?: SolidFillConfig;
gradient?: GradientFillConfig;
image?: ImageFillConfig;
}
```
### 技术实现方案
1. **渐变渲染**:使用Canvas的createLinearGradient/createRadialGradient API创建渐变对象
2. **图片填充**:使用createPattern API创建图案,结合transform实现缩放、偏移、旋转
3. **色标编辑器**:自定义滑块组件,支持拖拽交互和颜色选择器集成
## 设计风格
延续现有编辑器的设计语言,采用深色主题面板设计,保持与纯色填充面板的视觉一致性。
## 页面设计
### 填充面板(扩展现有面板)
**标签页区域**
- 三个等宽标签:纯色、渐变、图片
- 当前选中标签高亮显示,底部指示条
**渐变填充内容区**
- 顶部:线性/径向类型切换按钮组
- 中部:渐变预览条,下方色标滑块可拖拽
- 色标操作:点击添加、拖拽移动、双击编辑颜色
- 底部:角度输入框(线性)或中心点XY输入(径向)
**图片填充内容区**
- 图片预览区:显示当前选中图片缩略图
- 图片来源:素材库按钮 + 上传按钮
- 平铺模式:三个图标按钮(拉伸/平铺/适应)
- 高级参数折叠面板:
- 缩放滑块:50%-200%
- X/Y偏移输入框
- 旋转角度滑块:0-360度
## Agent Extensions
### SubAgent
- **code-explorer**
- Purpose: 探索现有项目中填充面板的实现结构、类型定义和Canvas渲染逻辑
- Expected outcome: 了解现有填充功能的代码组织、数据结构和渲染方式,确保新功能与现有代码无缝集成
@@ -0,0 +1,197 @@
/**
* 填充面板组件 - 整合纯色、渐变、图片三种填充模式
* Fill Panel Component - Integrates solid, gradient, and image fill modes
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import classNames from 'classnames';
import { useI18n } from '../../i18n';
import { UnifiedColorPicker } from '../unified-color-picker';
import { GradientFillPanel } from './GradientFillPanel';
import { ImageFillPanel } from './ImageFillPanel';
import type {
FillConfig,
FillType,
GradientFillConfig,
ImageFillConfig,
} from '../../types/fill.types';
import {
DEFAULT_LINEAR_GRADIENT,
DEFAULT_IMAGE_FILL,
isSolidFill,
isFillConfig,
stringToFillConfig,
} from '../../types/fill.types';
import './fill-panel.scss';
export interface FillPanelProps {
/** 当前填充配置(支持旧的 string 格式和新的 FillConfig 格式) */
value?: string | FillConfig;
/** 纯色变更回调 */
onSolidChange?: (color: string) => void;
/** 透明度变更回调 */
onOpacityChange?: (opacity: number) => void;
/** 渐变变更回调 */
onGradientChange?: (config: GradientFillConfig) => void;
/** 图片填充变更回调 */
onImageChange?: (config: ImageFillConfig) => void;
/** 填充类型变更回调 */
onFillTypeChange?: (type: FillType) => void;
/** 是否显示关闭按钮 */
showClose?: boolean;
/** 关闭回调 */
onClose?: () => void;
/** 请求打开素材库的回调(用于将 Modal 渲染到 Popover 外部) */
onOpenMediaLibrary?: () => void;
}
type TabType = 'solid' | 'gradient' | 'image';
const TABS: { value: TabType; labelZh: string; labelEn: string }[] = [
{ value: 'solid', labelZh: '纯色', labelEn: 'Solid' },
{ value: 'gradient', labelZh: '渐变', labelEn: 'Gradient' },
{ value: 'image', labelZh: '图片', labelEn: 'Image' },
];
export const FillPanel: React.FC<FillPanelProps> = ({
value,
onSolidChange,
onOpacityChange,
onGradientChange,
onImageChange,
onFillTypeChange,
showClose = false,
onClose,
onOpenMediaLibrary,
}) => {
const { language } = useI18n();
// 解析当前值
const currentConfig = useMemo((): FillConfig => {
if (!value) {
return { type: 'solid', solid: { color: '#FFFFFF' } };
}
if (isSolidFill(value)) {
return stringToFillConfig(value);
}
if (isFillConfig(value)) {
return value;
}
return { type: 'solid', solid: { color: '#FFFFFF' } };
}, [value]);
// 当前标签页
const [activeTab, setActiveTab] = useState<TabType>(currentConfig.type);
// 同步外部值变化到标签页状态
useEffect(() => {
setActiveTab(currentConfig.type);
}, [currentConfig.type]);
// 内部状态 - 保存每种类型的配置
const [gradientConfig, setGradientConfig] = useState<GradientFillConfig>(
currentConfig.gradient || DEFAULT_LINEAR_GRADIENT
);
const [imageConfig, setImageConfig] = useState<ImageFillConfig>(
currentConfig.image || DEFAULT_IMAGE_FILL
);
// 切换标签
const handleTabChange = useCallback(
(tab: TabType) => {
setActiveTab(tab);
onFillTypeChange?.(tab);
},
[onFillTypeChange]
);
// 处理纯色变更
const handleSolidChange = useCallback(
(color: string) => {
onSolidChange?.(color);
},
[onSolidChange]
);
// 处理渐变变更
const handleGradientChange = useCallback(
(config: GradientFillConfig) => {
setGradientConfig(config);
onGradientChange?.(config);
},
[onGradientChange]
);
// 处理图片填充变更
const handleImageChange = useCallback(
(config: ImageFillConfig) => {
setImageConfig(config);
onImageChange?.(config);
},
[onImageChange]
);
// 获取纯色值
const solidColor = useMemo(() => {
if (isSolidFill(value)) return value;
if (isFillConfig(value) && value.solid) return value.solid.color;
return '#FFFFFF';
}, [value]);
return (
<div className="fill-panel">
{/* 头部 */}
<div className="fill-panel__header">
<span className="fill-panel__title">
{language === 'zh' ? '填充' : 'Fill'}
</span>
{showClose && (
<button className="fill-panel__close" onClick={onClose}>
×
</button>
)}
</div>
{/* 标签页 */}
<div className="fill-panel__tabs">
{TABS.map((tab) => (
<button
key={tab.value}
className={classNames('fill-panel__tab', { active: activeTab === tab.value })}
onClick={() => handleTabChange(tab.value)}
>
{language === 'zh' ? tab.labelZh : tab.labelEn}
</button>
))}
</div>
{/* 内容区域 */}
<div className="fill-panel__content">
{activeTab === 'solid' && (
<div className="fill-panel__solid">
<UnifiedColorPicker
value={solidColor}
onChange={handleSolidChange}
onOpacityChange={onOpacityChange}
/>
</div>
)}
{activeTab === 'gradient' && (
<GradientFillPanel value={gradientConfig} onChange={handleGradientChange} />
)}
{activeTab === 'image' && (
<ImageFillPanel
value={imageConfig}
onChange={handleImageChange}
onOpenMediaLibrary={onOpenMediaLibrary}
externalMediaLibraryControl={!!onOpenMediaLibrary}
/>
)}
</div>
</div>
);
};
export default FillPanel;
@@ -0,0 +1,386 @@
/**
* 渐变填充面板组件
* Gradient Fill Panel Component
*/
import React, { useState, useCallback, useMemo } from 'react';
import classNames from 'classnames';
import { useI18n } from '../../i18n';
import type {
GradientFillConfig,
GradientFillStop,
LinearGradientConfig,
RadialGradientConfig,
GradientFillPreset,
} from '../../types/fill.types';
import {
DEFAULT_LINEAR_GRADIENT,
DEFAULT_RADIAL_GRADIENT,
GRADIENT_FILL_PRESETS,
} from '../../types/fill.types';
import './gradient-fill-panel.scss';
export interface GradientFillPanelProps {
value?: GradientFillConfig;
onChange?: (config: GradientFillConfig) => void;
}
type GradientTab = 'presets' | 'custom';
/**
* 生成渐变 CSS
*/
function generateGradientCSS(config: GradientFillConfig): string {
const stopsStr = config.stops
.map((stop) => `${stop.color} ${Math.round(stop.offset * 100)}%`)
.join(', ');
if (config.type === 'linear') {
return `linear-gradient(${config.angle}deg, ${stopsStr})`;
} else {
const cx = Math.round(config.centerX * 100);
const cy = Math.round(config.centerY * 100);
return `radial-gradient(circle at ${cx}% ${cy}%, ${stopsStr})`;
}
}
export const GradientFillPanel: React.FC<GradientFillPanelProps> = ({
value,
onChange,
}) => {
const { language } = useI18n();
const [activeTab, setActiveTab] = useState<GradientTab>('presets');
const [gradient, setGradient] = useState<GradientFillConfig>(
value || DEFAULT_LINEAR_GRADIENT
);
const [selectedStopIndex, setSelectedStopIndex] = useState(0);
// 应用渐变
const applyGradient = useCallback(
(config: GradientFillConfig) => {
setGradient(config);
onChange?.(config);
},
[onChange]
);
// 应用预设
const applyPreset = useCallback(
(preset: GradientFillPreset) => {
applyGradient(preset.config);
},
[applyGradient]
);
// 更新渐变属性
const updateGradient = useCallback(
(updates: Partial<GradientFillConfig>) => {
const newGradient = { ...gradient, ...updates } as GradientFillConfig;
applyGradient(newGradient);
},
[gradient, applyGradient]
);
// 切换渐变类型
const switchGradientType = useCallback(
(type: 'linear' | 'radial') => {
if (gradient.type === type) return;
if (type === 'linear') {
const newConfig: LinearGradientConfig = {
type: 'linear',
angle: 90,
stops: gradient.stops,
};
applyGradient(newConfig);
} else {
const newConfig: RadialGradientConfig = {
type: 'radial',
centerX: 0.5,
centerY: 0.5,
stops: gradient.stops,
};
applyGradient(newConfig);
}
},
[gradient, applyGradient]
);
// 更新色标
const updateStop = useCallback(
(index: number, updates: Partial<GradientFillStop>) => {
const newStops = [...gradient.stops];
newStops[index] = { ...newStops[index], ...updates };
updateGradient({ stops: newStops });
},
[gradient.stops, updateGradient]
);
// 添加色标
const addStop = useCallback(() => {
if (gradient.stops.length >= 8) return;
const newOffset = 0.5;
const newColor = '#888888';
const newStops = [...gradient.stops, { color: newColor, offset: newOffset }].sort(
(a, b) => a.offset - b.offset
);
updateGradient({ stops: newStops });
setSelectedStopIndex(newStops.findIndex((s) => s.offset === newOffset));
}, [gradient.stops, updateGradient]);
// 删除色标
const removeStop = useCallback(
(index: number) => {
if (gradient.stops.length <= 2) return;
const newStops = gradient.stops.filter((_, i) => i !== index);
updateGradient({ stops: newStops });
setSelectedStopIndex(Math.min(selectedStopIndex, newStops.length - 1));
},
[gradient.stops, selectedStopIndex, updateGradient]
);
// 预览 CSS
const previewCSS = useMemo(() => generateGradientCSS(gradient), [gradient]);
// 预设分类
const presetCategories = useMemo(
() => [
{ key: 'basic', label: language === 'zh' ? '基础' : 'Basic' },
{ key: 'colorful', label: language === 'zh' ? '多彩' : 'Colorful' },
{ key: 'sunset', label: language === 'zh' ? '日落' : 'Sunset' },
{ key: 'nature', label: language === 'zh' ? '自然' : 'Nature' },
{ key: 'metal', label: language === 'zh' ? '金属' : 'Metal' },
],
[language]
);
// 渲染预设面板
const renderPresetsPanel = () => (
<div className="gfp-presets-panel">
{presetCategories.map(({ key, label }) => {
const presets = GRADIENT_FILL_PRESETS.filter((p) => p.category === key);
if (presets.length === 0) return null;
return (
<div key={key} className="gfp-preset-category">
<div className="gfp-category-title">{label}</div>
<div className="gfp-preset-grid">
{presets.map((preset) => (
<button
key={preset.id}
className="gfp-preset-item"
onClick={() => applyPreset(preset)}
title={language === 'zh' ? preset.nameZh : preset.name}
>
<div
className="gfp-preset-preview"
style={{ background: generateGradientCSS(preset.config) }}
/>
</button>
))}
</div>
</div>
);
})}
</div>
);
// 渲染自定义编辑器
const renderCustomEditor = () => (
<div className="gfp-custom-editor">
{/* 渐变类型 */}
<div className="gfp-control-section">
<div className="gfp-type-buttons">
<button
className={classNames('gfp-type-btn', { active: gradient.type === 'linear' })}
onClick={() => switchGradientType('linear')}
>
{language === 'zh' ? '线性' : 'Linear'}
</button>
<button
className={classNames('gfp-type-btn', { active: gradient.type === 'radial' })}
onClick={() => switchGradientType('radial')}
>
{language === 'zh' ? '径向' : 'Radial'}
</button>
</div>
</div>
{/* 渐变预览和色标 */}
<div className="gfp-control-section">
<div className="gfp-section-header">
<span className="gfp-section-title">
{language === 'zh' ? '颜色' : 'Colors'}
</span>
<div className="gfp-stop-actions">
<button
className="gfp-action-btn"
onClick={addStop}
disabled={gradient.stops.length >= 8}
title={language === 'zh' ? '添加色标' : 'Add stop'}
>
+
</button>
<button
className="gfp-action-btn"
onClick={() => removeStop(selectedStopIndex)}
disabled={gradient.stops.length <= 2}
title={language === 'zh' ? '删除色标' : 'Remove stop'}
>
−
</button>
</div>
</div>
{/* 渐变条与色标 */}
<div className="gfp-gradient-bar-container">
<div className="gfp-gradient-bar" style={{ background: previewCSS }} />
<div className="gfp-stops-track">
{gradient.stops.map((stop, index) => (
<button
key={index}
className={classNames('gfp-stop-handle', {
selected: selectedStopIndex === index,
})}
style={{
left: `${stop.offset * 100}%`,
backgroundColor: stop.color,
}}
onClick={() => setSelectedStopIndex(index)}
/>
))}
</div>
</div>
{/* 选中色标的编辑 */}
{gradient.stops[selectedStopIndex] && (
<div className="gfp-stop-editor">
<div className="gfp-stop-color">
<input
type="color"
value={gradient.stops[selectedStopIndex].color}
onChange={(e) => updateStop(selectedStopIndex, { color: e.target.value })}
className="gfp-color-input"
/>
<span className="gfp-color-value">
{gradient.stops[selectedStopIndex].color.toUpperCase()}
</span>
</div>
<div className="gfp-stop-position">
<input
type="range"
min={0}
max={100}
value={Math.round(gradient.stops[selectedStopIndex].offset * 100)}
onChange={(e) =>
updateStop(selectedStopIndex, { offset: Number(e.target.value) / 100 })
}
className="gfp-position-slider"
/>
</div>
</div>
)}
</div>
{/* 角度控制 (仅线性渐变) */}
{gradient.type === 'linear' && (
<div className="gfp-control-section">
<div className="gfp-section-header">
<span className="gfp-section-title">
{language === 'zh' ? '旋转' : 'Rotation'}
</span>
<div className="gfp-angle-input-wrapper">
<input
type="number"
min={0}
max={360}
value={(gradient as LinearGradientConfig).angle}
onChange={(e) => updateGradient({ angle: Number(e.target.value) })}
className="gfp-angle-input"
/>
</div>
</div>
<input
type="range"
min={0}
max={360}
value={(gradient as LinearGradientConfig).angle}
onChange={(e) => updateGradient({ angle: Number(e.target.value) })}
className="gfp-angle-slider"
/>
</div>
)}
{/* 中心点控制 (仅径向渐变) */}
{gradient.type === 'radial' && (
<div className="gfp-control-section">
<div className="gfp-section-title">
{language === 'zh' ? '中心点' : 'Center'}
</div>
<div className="gfp-center-controls">
<div className="gfp-center-control">
<label>X</label>
<input
type="range"
min={0}
max={100}
value={Math.round((gradient as RadialGradientConfig).centerX * 100)}
onChange={(e) =>
updateGradient({ centerX: Number(e.target.value) / 100 })
}
className="gfp-center-slider"
/>
<span className="gfp-center-value">
{Math.round((gradient as RadialGradientConfig).centerX * 100)}%
</span>
</div>
<div className="gfp-center-control">
<label>Y</label>
<input
type="range"
min={0}
max={100}
value={Math.round((gradient as RadialGradientConfig).centerY * 100)}
onChange={(e) =>
updateGradient({ centerY: Number(e.target.value) / 100 })
}
className="gfp-center-slider"
/>
<span className="gfp-center-value">
{Math.round((gradient as RadialGradientConfig).centerY * 100)}%
</span>
</div>
</div>
</div>
)}
</div>
);
return (
<div className="gradient-fill-panel">
<div className="gfp-tabs">
<button
className={classNames('gfp-tab', { active: activeTab === 'presets' })}
onClick={() => setActiveTab('presets')}
>
{language === 'zh' ? '预设' : 'Presets'}
</button>
<button
className={classNames('gfp-tab', { active: activeTab === 'custom' })}
onClick={() => setActiveTab('custom')}
>
{language === 'zh' ? '自定义' : 'Custom'}
</button>
</div>
<div className="gfp-content">
{activeTab === 'presets' && renderPresetsPanel()}
{activeTab === 'custom' && renderCustomEditor()}
</div>
</div>
);
};
export default GradientFillPanel;
@@ -0,0 +1,320 @@
/**
* 图片填充面板组件
* Image Fill Panel Component
*/
import React, { useState, useCallback, useRef } from 'react';
import classNames from 'classnames';
import { useI18n } from '../../i18n';
import { MessagePlugin } from 'tdesign-react';
import { Image, Upload, Grid, ChevronDown, ChevronUp } from 'lucide-react';
import type { ImageFillConfig, ImageFillMode } from '../../types/fill.types';
import { DEFAULT_IMAGE_FILL } from '../../types/fill.types';
import { MediaLibraryModal } from '../media-library/MediaLibraryModal';
import { AssetType, SelectionMode } from '../../types/asset.types';
import type { Asset } from '../../types/asset.types';
import './image-fill-panel.scss';
export interface ImageFillPanelProps {
value?: ImageFillConfig;
onChange?: (config: ImageFillConfig) => void;
/** 请求打开素材库的回调(用于将 Modal 渲染到 Popover 外部) */
onOpenMediaLibrary?: () => void;
/** 是否由外部控制素材库状态 */
externalMediaLibraryControl?: boolean;
}
const FILL_MODES: { value: ImageFillMode; labelZh: string; labelEn: string; icon: string }[] = [
{ value: 'stretch', labelZh: '拉伸', labelEn: 'Stretch', icon: '⬌' },
{ value: 'tile', labelZh: '平铺', labelEn: 'Tile', icon: '⊞' },
{ value: 'fit', labelZh: '适应', labelEn: 'Fit', icon: '⊡' },
];
export const ImageFillPanel: React.FC<ImageFillPanelProps> = ({
value,
onChange,
onOpenMediaLibrary,
externalMediaLibraryControl = false,
}) => {
const { language } = useI18n();
const [config, setConfig] = useState<ImageFillConfig>(value || DEFAULT_IMAGE_FILL);
const [showMediaLibrary, setShowMediaLibrary] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 打开素材库的处理函数
const handleOpenMediaLibrary = useCallback(() => {
if (externalMediaLibraryControl && onOpenMediaLibrary) {
// 使用外部控制时,调用外部回调
onOpenMediaLibrary();
} else {
// 内部控制
setShowMediaLibrary(true);
}
}, [externalMediaLibraryControl, onOpenMediaLibrary]);
// 更新配置
const updateConfig = useCallback(
(updates: Partial<ImageFillConfig>) => {
const newConfig = { ...config, ...updates };
setConfig(newConfig);
onChange?.(newConfig);
},
[config, onChange]
);
// 处理从素材库选择
const handleSelectFromLibrary = useCallback(
(asset: Asset) => {
updateConfig({ imageUrl: asset.url });
setShowMediaLibrary(false);
},
[updateConfig]
);
// 处理文件上传
const handleFileUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// 验证文件类型
if (!file.type.startsWith('image/')) {
MessagePlugin.warning(language === 'zh' ? '请选择图片文件' : 'Please select an image file');
return;
}
// 验证文件大小 (最大 10MB)
if (file.size > 10 * 1024 * 1024) {
MessagePlugin.warning(
language === 'zh' ? '图片大小不能超过 10MB' : 'Image size cannot exceed 10MB'
);
return;
}
// 转换为 base64
const reader = new FileReader();
reader.onload = (e) => {
const imageUrl = e.target?.result as string;
updateConfig({ imageUrl });
};
reader.readAsDataURL(file);
// 清空 input
event.target.value = '';
},
[language, updateConfig]
);
// 打开文件选择器
const handleUploadClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
// 渲染图片预览或占位符
const renderImagePreview = () => {
if (config.imageUrl) {
return (
<div className="ifp-image-preview">
<img src={config.imageUrl} alt="Fill preview" />
<button
className="ifp-image-remove"
onClick={() => updateConfig({ imageUrl: '' })}
title={language === 'zh' ? '移除图片' : 'Remove image'}
>
×
</button>
</div>
);
}
return (
<div className="ifp-image-placeholder">
<Image size={32} className="ifp-placeholder-icon" />
<span className="ifp-placeholder-text">
{language === 'zh' ? '选择图片' : 'Select Image'}
</span>
</div>
);
};
return (
<div className="image-fill-panel">
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileUpload}
/>
{/* 图片选择区域 */}
<div className="ifp-image-section">
<div className="ifp-image-container" onClick={() => !config.imageUrl && handleOpenMediaLibrary()}>
{renderImagePreview()}
</div>
<div className="ifp-image-actions">
<button className="ifp-action-btn ifp-action-btn--primary" onClick={handleOpenMediaLibrary}>
<Grid size={14} />
<span>{language === 'zh' ? '素材库' : 'Library'}</span>
</button>
<button className="ifp-action-btn" onClick={handleUploadClick}>
<Upload size={14} />
<span>{language === 'zh' ? '上传' : 'Upload'}</span>
</button>
</div>
</div>
{/* 只有选择了图片才显示后续选项 */}
{config.imageUrl && (
<>
{/* 平铺模式 */}
<div className="ifp-control-section">
<div className="ifp-section-title">
{language === 'zh' ? '平铺模式' : 'Fill Mode'}
</div>
<div className="ifp-mode-buttons">
{FILL_MODES.map((mode) => (
<button
key={mode.value}
className={classNames('ifp-mode-btn', { active: config.mode === mode.value })}
onClick={() => updateConfig({ mode: mode.value })}
title={language === 'zh' ? mode.labelZh : mode.labelEn}
>
<span className="ifp-mode-icon">{mode.icon}</span>
<span className="ifp-mode-label">
{language === 'zh' ? mode.labelZh : mode.labelEn}
</span>
</button>
))}
</div>
</div>
{/* 高级参数折叠面板 */}
<div className="ifp-advanced-section">
<button
className="ifp-advanced-toggle"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<span>{language === 'zh' ? '高级设置' : 'Advanced'}</span>
{showAdvanced ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{showAdvanced && (
<div className="ifp-advanced-content">
{/* 缩放 */}
<div className="ifp-control-row">
<label className="ifp-control-label">
{language === 'zh' ? '缩放' : 'Scale'}
</label>
<div className="ifp-control-input">
<input
type="range"
min={50}
max={200}
value={(config.scale ?? 1) * 100}
onChange={(e) => updateConfig({ scale: Number(e.target.value) / 100 })}
className="ifp-slider"
/>
<span className="ifp-control-value">
{Math.round((config.scale ?? 1) * 100)}%
</span>
</div>
</div>
{/* X 偏移 */}
<div className="ifp-control-row">
<label className="ifp-control-label">
{language === 'zh' ? 'X 偏移' : 'Offset X'}
</label>
<div className="ifp-control-input">
<input
type="range"
min={-100}
max={100}
value={(config.offsetX ?? 0) * 100}
onChange={(e) => updateConfig({ offsetX: Number(e.target.value) / 100 })}
className="ifp-slider"
/>
<span className="ifp-control-value">
{Math.round((config.offsetX ?? 0) * 100)}%
</span>
</div>
</div>
{/* Y 偏移 */}
<div className="ifp-control-row">
<label className="ifp-control-label">
{language === 'zh' ? 'Y 偏移' : 'Offset Y'}
</label>
<div className="ifp-control-input">
<input
type="range"
min={-100}
max={100}
value={(config.offsetY ?? 0) * 100}
onChange={(e) => updateConfig({ offsetY: Number(e.target.value) / 100 })}
className="ifp-slider"
/>
<span className="ifp-control-value">
{Math.round((config.offsetY ?? 0) * 100)}%
</span>
</div>
</div>
{/* 旋转 */}
<div className="ifp-control-row">
<label className="ifp-control-label">
{language === 'zh' ? '旋转' : 'Rotation'}
</label>
<div className="ifp-control-input">
<input
type="range"
min={0}
max={360}
value={config.rotation ?? 0}
onChange={(e) => updateConfig({ rotation: Number(e.target.value) })}
className="ifp-slider"
/>
<span className="ifp-control-value">{config.rotation ?? 0}°</span>
</div>
</div>
{/* 重置按钮 */}
<button
className="ifp-reset-btn"
onClick={() =>
updateConfig({
scale: 1,
offsetX: 0,
offsetY: 0,
rotation: 0,
})
}
>
{language === 'zh' ? '重置' : 'Reset'}
</button>
</div>
)}
</div>
</>
)}
{/* 素材库弹窗 - 仅在内部控制时渲染 */}
{!externalMediaLibraryControl && (
<MediaLibraryModal
isOpen={showMediaLibrary}
onClose={() => setShowMediaLibrary(false)}
mode={SelectionMode.SELECT}
filterType={AssetType.IMAGE}
onSelect={handleSelectFromLibrary}
selectButtonText={language === 'zh' ? '使用此图片' : 'Use this image'}
/>
)}
</div>
);
};
export default ImageFillPanel;
@@ -0,0 +1,109 @@
/**
* 填充面板样式
* Fill Panel Styles
*/
.fill-panel {
display: flex;
flex-direction: column;
width: 280px;
max-height: 500px;
background: var(--color-surface, #ffffff);
border-radius: 12px;
overflow: hidden;
}
// 头部
.fill-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.fill-panel__title {
font-size: 14px;
font-weight: 600;
color: var(--color-text, #111827);
}
.fill-panel__close {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--color-text-secondary, #6b7280);
font-size: 18px;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
&:hover {
background: var(--color-bg-secondary, #f3f4f6);
color: var(--color-text, #111827);
}
}
// 标签页
.fill-panel__tabs {
display: flex;
padding: 4px;
margin: 12px 16px;
background: var(--color-bg-secondary, #f3f4f6);
border-radius: 8px;
}
.fill-panel__tab {
flex: 1;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--color-text-secondary, #6b7280);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(.active) {
color: var(--color-text, #111827);
}
&.active {
background: #ffffff;
color: var(--color-text, #111827);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
}
// 内容区域
.fill-panel__content {
flex: 1;
overflow-y: auto;
padding: 0 16px 16px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border, #e5e7eb);
border-radius: 2px;
}
}
// 纯色面板
.fill-panel__solid {
.unified-color-picker {
padding: 0;
background: transparent;
}
}
@@ -0,0 +1,400 @@
/**
* 渐变填充面板样式
* Gradient Fill Panel Styles
*/
.gradient-fill-panel {
display: flex;
flex-direction: column;
width: 100%;
background: var(--color-surface, #ffffff);
}
// 标签页
.gfp-tabs {
display: none; // 在独立使用时显示,嵌入 FillPanel 时隐藏
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding: 0 4px;
}
.gfp-tab {
flex: 1;
padding: 8px 12px;
background: transparent;
border: none;
color: var(--color-text-secondary, #6b7280);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
&:hover {
color: var(--color-text, #111827);
}
&.active {
color: var(--color-primary, #3b82f6);
&::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--color-primary, #3b82f6);
border-radius: 1px;
}
}
}
.gfp-content {
flex: 1;
overflow-y: auto;
padding: 12px 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border, #e5e7eb);
border-radius: 2px;
}
}
// 预设面板
.gfp-presets-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.gfp-preset-category {
.gfp-category-title {
font-size: 11px;
color: var(--color-text-tertiary, #9ca3af);
margin-bottom: 8px;
padding-left: 2px;
}
}
.gfp-preset-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
}
.gfp-preset-item {
aspect-ratio: 1;
padding: 0;
background: transparent;
border: 2px solid transparent;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
overflow: hidden;
&:hover {
border-color: var(--color-primary, #3b82f6);
transform: scale(1.05);
}
.gfp-preset-preview {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
// 自定义编辑器
.gfp-custom-editor {
display: flex;
flex-direction: column;
gap: 16px;
}
.gfp-control-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.gfp-section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.gfp-section-title {
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
font-weight: 500;
}
.gfp-stop-actions {
display: flex;
gap: 4px;
}
.gfp-action-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
color: var(--color-text-secondary, #6b7280);
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: all 0.15s ease;
&:hover:not(:disabled) {
background: var(--color-bg-hover, #e5e7eb);
color: var(--color-text, #111827);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
// 类型按钮
.gfp-type-buttons {
display: flex;
gap: 8px;
}
.gfp-type-btn {
flex: 1;
padding: 8px 12px;
background: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 20px;
color: var(--color-text-secondary, #6b7280);
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--color-bg-hover, #e5e7eb);
color: var(--color-text, #111827);
}
&.active {
background: var(--color-text, #111827);
border-color: var(--color-text, #111827);
color: #ffffff;
}
}
// 渐变条和色标
.gfp-gradient-bar-container {
position: relative;
padding-bottom: 20px;
}
.gfp-gradient-bar {
height: 24px;
border-radius: 6px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.gfp-stops-track {
position: absolute;
top: 28px;
left: 0;
right: 0;
height: 16px;
}
.gfp-stop-handle {
position: absolute;
width: 16px;
height: 16px;
border: 3px solid #ffffff;
border-radius: 50%;
transform: translateX(-50%);
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(0, 0, 0, 0.1);
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateX(-50%) scale(1.1);
}
&.selected {
box-shadow: 0 0 0 2px var(--color-primary, #3b82f6), 0 2px 6px rgba(0, 0, 0, 0.3);
z-index: 1;
}
}
// 色标编辑器
.gfp-stop-editor {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
background: var(--color-bg-secondary, #f3f4f6);
border-radius: 8px;
}
.gfp-stop-color {
display: flex;
align-items: center;
gap: 8px;
.gfp-color-input {
width: 32px;
height: 32px;
padding: 0;
border: 2px solid var(--color-border, #e5e7eb);
border-radius: 50%;
cursor: pointer;
background: transparent;
overflow: hidden;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: none;
border-radius: 50%;
}
}
.gfp-color-value {
font-size: 11px;
color: var(--color-text-secondary, #6b7280);
font-family: 'SF Mono', Monaco, monospace;
}
}
.gfp-stop-position {
flex: 1;
.gfp-position-slider {
width: 100%;
height: 4px;
appearance: none;
background: var(--color-border, #e5e7eb);
border-radius: 2px;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
background: var(--color-primary, #3b82f6);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
}
// 角度控制
.gfp-angle-input-wrapper {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
}
.gfp-angle-input {
width: 40px;
padding: 0;
border: none;
background: transparent;
font-size: 12px;
font-family: 'SF Mono', Monaco, monospace;
color: var(--color-text, #111827);
text-align: center;
outline: none;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
.gfp-angle-slider {
width: 100%;
height: 4px;
appearance: none;
background: var(--color-border, #e5e7eb);
border-radius: 2px;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
background: var(--color-primary, #3b82f6);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
// 中心点控制
.gfp-center-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.gfp-center-control {
display: flex;
align-items: center;
gap: 8px;
label {
font-size: 11px;
color: var(--color-text-tertiary, #9ca3af);
width: 16px;
}
.gfp-center-slider {
flex: 1;
height: 4px;
appearance: none;
background: var(--color-border, #e5e7eb);
border-radius: 2px;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
background: var(--color-primary, #3b82f6);
border-radius: 50%;
cursor: pointer;
}
}
.gfp-center-value {
font-size: 11px;
color: var(--color-text-secondary, #6b7280);
font-family: 'SF Mono', Monaco, monospace;
min-width: 32px;
text-align: right;
}
}
@@ -0,0 +1,289 @@
/**
* 图片填充面板样式
* Image Fill Panel Styles
*/
.image-fill-panel {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
background: var(--color-surface, #ffffff);
}
// 图片选择区域
.ifp-image-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.ifp-image-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border: 2px dashed var(--color-border, #e5e7eb);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s ease;
&:hover {
border-color: var(--color-primary, #3b82f6);
}
}
.ifp-image-preview {
position: relative;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ifp-image-remove {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
border: none;
border-radius: 50%;
color: #ffffff;
font-size: 14px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
&:hover {
background: rgba(239, 68, 68, 0.9);
}
}
&:hover .ifp-image-remove {
opacity: 1;
}
}
.ifp-image-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 8px;
background: var(--color-bg-secondary, #f9fafb);
.ifp-placeholder-icon {
color: var(--color-text-tertiary, #9ca3af);
}
.ifp-placeholder-text {
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
}
}
// 图片操作按钮
.ifp-image-actions {
display: flex;
gap: 8px;
}
.ifp-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 12px;
background: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
color: var(--color-text-secondary, #6b7280);
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--color-bg-hover, #e5e7eb);
color: var(--color-text, #111827);
}
&--primary {
background: var(--color-primary, #3b82f6);
border-color: var(--color-primary, #3b82f6);
color: #ffffff;
&:hover {
background: var(--color-primary-dark, #2563eb);
border-color: var(--color-primary-dark, #2563eb);
color: #ffffff;
}
}
}
// 控制区域
.ifp-control-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.ifp-section-title {
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
font-weight: 500;
}
// 平铺模式按钮
.ifp-mode-buttons {
display: flex;
gap: 8px;
}
.ifp-mode-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
background: var(--color-bg-secondary, #f3f4f6);
border: 2px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--color-bg-hover, #e5e7eb);
}
&.active {
background: rgba(59, 130, 246, 0.1);
border-color: var(--color-primary, #3b82f6);
}
.ifp-mode-icon {
font-size: 18px;
color: var(--color-text-secondary, #6b7280);
}
.ifp-mode-label {
font-size: 11px;
color: var(--color-text-secondary, #6b7280);
}
&.active {
.ifp-mode-icon,
.ifp-mode-label {
color: var(--color-primary, #3b82f6);
}
}
}
// 高级设置
.ifp-advanced-section {
border-top: 1px solid var(--color-border, #e5e7eb);
padding-top: 12px;
}
.ifp-advanced-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 0;
background: transparent;
border: none;
color: var(--color-text-secondary, #6b7280);
font-size: 12px;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: var(--color-text, #111827);
}
}
.ifp-advanced-content {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 12px;
}
// 控制行
.ifp-control-row {
display: flex;
align-items: center;
gap: 12px;
}
.ifp-control-label {
flex-shrink: 0;
width: 50px;
font-size: 11px;
color: var(--color-text-tertiary, #9ca3af);
}
.ifp-control-input {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.ifp-slider {
flex: 1;
height: 4px;
appearance: none;
background: var(--color-border, #e5e7eb);
border-radius: 2px;
outline: none;
cursor: pointer;
&::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
background: var(--color-primary, #3b82f6);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
.ifp-control-value {
flex-shrink: 0;
width: 40px;
font-size: 11px;
color: var(--color-text-secondary, #6b7280);
font-family: 'SF Mono', Monaco, monospace;
text-align: right;
}
// 重置按钮
.ifp-reset-btn {
align-self: flex-end;
padding: 6px 12px;
background: var(--color-bg-secondary, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
color: var(--color-text-secondary, #6b7280);
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--color-bg-hover, #e5e7eb);
color: var(--color-text, #111827);
}
}
@@ -0,0 +1,13 @@
/**
* 填充面板组件导出
* Fill Panel Components Export
*/
export { FillPanel } from './FillPanel';
export type { FillPanelProps } from './FillPanel';
export { GradientFillPanel } from './GradientFillPanel';
export type { GradientFillPanelProps } from './GradientFillPanel';
export { ImageFillPanel } from './ImageFillPanel';
export type { ImageFillPanelProps } from './ImageFillPanel';
@@ -67,7 +67,20 @@ export function usePopover({
const click = useClick(context, {
enabled: controlledOpen == null,
});
const dismiss = useDismiss(context);
const dismiss = useDismiss(context, {
// 自定义外部点击判断,排除 TDesign Dialog 和其他弹出层
outsidePress: (event) => {
const target = event.target as HTMLElement;
// 检查点击目标是否在 TDesign Dialog 内部
// TDesign Dialog 使用 .t-dialog 类名
const isInTDesignDialog = target.closest('.t-dialog__ctx') ||
target.closest('.t-dialog') ||
target.closest('.t-popup') ||
target.closest('.t-drawer');
// 如果点击在 TDesign 弹出层内部,不关闭 Popover
return !isInTDesignDialog;
},
});
const role = useRole(context);
const interactions = useInteractions([click, dismiss, role]);
@@ -1,9 +1,8 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useMemo, lazy, Suspense } from 'react';
import { ToolButton } from '../../tool-button';
import classNames from 'classnames';
import { ATTACHED_ELEMENT_CLASS_NAME, PlaitBoard } from '@plait/core';
import { ATTACHED_ELEMENT_CLASS_NAME } from '@plait/core';
import { Island } from '../../island';
import { UnifiedColorPicker } from '../../unified-color-picker';
import {
hexAlphaToOpacity,
isFullyTransparent,
@@ -14,11 +13,29 @@ import { Popover, PopoverContent, PopoverTrigger } from '../../popover/popover';
import {
setFillColor,
setFillColorOpacity,
setGradientFill,
setImageFill,
setFillType,
} from '../../../transforms/property';
import { FillPanel } from '../../fill-panel';
import type {
FillConfig,
FillType,
GradientFillConfig,
ImageFillConfig,
} from '../../../types/fill.types';
import { isSolidFill, isFillConfig } from '../../../types/fill.types';
import { AssetType, SelectionMode } from '../../../types/asset.types';
import type { Asset } from '../../../types/asset.types';
import { useI18n } from '../../../i18n';
// 懒加载 MediaLibraryModal
const MediaLibraryModal = lazy(() => import('../../media-library').then(module => ({ default: module.MediaLibraryModal })));
export type PopupFillButtonProps = {
board: PlaitBoard;
currentColor: string | undefined;
/** 当前填充值(支持旧的 string 格式和新的 FillConfig 格式) */
currentColor: string | FillConfig | undefined;
title: string;
children?: React.ReactNode;
};
@@ -29,20 +46,121 @@ export const PopupFillButton: React.FC<PopupFillButtonProps> = ({
title,
children,
}) => {
const { language } = useI18n();
const [isFillPropertyOpen, setIsFillPropertyOpen] = useState(false);
const hexColor = currentColor && removeHexAlpha(currentColor);
const opacity = currentColor ? hexAlphaToOpacity(currentColor) : 100;
const container = PlaitBoard.getBoardContainer(board);
const [showMediaLibrary, setShowMediaLibrary] = useState(false);
// 计算显示的颜色和透明度
const { hexColor, opacity, displayColor } = useMemo(() => {
if (!currentColor) {
return { hexColor: undefined, opacity: 100, displayColor: undefined };
}
// 纯色字符串
if (isSolidFill(currentColor)) {
const hex = removeHexAlpha(currentColor);
const op = hexAlphaToOpacity(currentColor);
return { hexColor: hex, opacity: op, displayColor: currentColor };
}
// FillConfig 对象
if (isFillConfig(currentColor)) {
switch (currentColor.type) {
case 'solid':
if (currentColor.solid) {
const hex = removeHexAlpha(currentColor.solid.color);
const op = hexAlphaToOpacity(currentColor.solid.color);
return { hexColor: hex, opacity: op, displayColor: currentColor.solid.color };
}
break;
case 'gradient':
// 渐变显示第一个色标的颜色
if (currentColor.gradient?.stops[0]) {
return {
hexColor: currentColor.gradient.stops[0].color,
opacity: 100,
displayColor: currentColor.gradient.stops[0].color,
};
}
break;
case 'image':
// 图片填充显示特殊标识
return { hexColor: '#888888', opacity: 100, displayColor: '#888888' };
}
}
return { hexColor: undefined, opacity: 100, displayColor: undefined };
}, [currentColor]);
const icon =
!hexColor || isFullyTransparent(opacity) ? BackgroundColorIcon : undefined;
const handleColorChange = useCallback((color: string) => {
setFillColor(board, color);
}, [board]);
// 处理纯色变更
const handleSolidChange = useCallback(
(color: string) => {
setFillColor(board, color);
},
[board]
);
const handleOpacityChange = useCallback((opacity: number) => {
setFillColorOpacity(board, opacity);
}, [board]);
// 处理透明度变更
const handleOpacityChange = useCallback(
(opacity: number) => {
setFillColorOpacity(board, opacity);
},
[board]
);
// 处理渐变变更
const handleGradientChange = useCallback(
(config: GradientFillConfig) => {
setGradientFill(board, config);
},
[board]
);
// 处理图片填充变更
const handleImageChange = useCallback(
(config: ImageFillConfig) => {
setImageFill(board, config);
},
[board]
);
// 处理填充类型切换
const handleFillTypeChange = useCallback(
(type: FillType) => {
setFillType(board, type);
},
[board]
);
// 关闭面板
const handleClose = useCallback(() => {
setIsFillPropertyOpen(false);
}, []);
// 打开素材库(在 Popover 外部渲染)
const handleOpenMediaLibrary = useCallback(() => {
setShowMediaLibrary(true);
}, []);
// 处理素材库选择图片
const handleSelectFromLibrary = useCallback(
(asset: Asset) => {
// 设置图片填充
setImageFill(board, {
imageUrl: asset.url,
mode: 'stretch',
scale: 1,
offsetX: 0,
offsetY: 0,
rotation: 0,
});
setShowMediaLibrary(false);
},
[board]
);
return (
<Popover
@@ -69,18 +187,39 @@ export const PopupFillButton: React.FC<PopupFillButtonProps> = ({
{!icon && children}
</ToolButton>
</PopoverTrigger>
<PopoverContent container={container}>
{/* 不传递 container,让 FloatingPortal 渲染到 body,确保 AssetProvider 上下文可用 */}
<PopoverContent>
<Island
padding={4}
padding={0}
className={classNames(`${ATTACHED_ELEMENT_CLASS_NAME}`, 'fill-setting')}
>
<UnifiedColorPicker
<FillPanel
value={currentColor}
onChange={handleColorChange}
onSolidChange={handleSolidChange}
onOpacityChange={handleOpacityChange}
onGradientChange={handleGradientChange}
onImageChange={handleImageChange}
onFillTypeChange={handleFillTypeChange}
showClose={true}
onClose={handleClose}
onOpenMediaLibrary={handleOpenMediaLibrary}
/>
</Island>
</PopoverContent>
{/* 素材库弹窗 - 渲染在 Popover 外部,避免 Popover 关闭时被卸载 */}
{showMediaLibrary && (
<Suspense fallback={null}>
<MediaLibraryModal
isOpen={showMediaLibrary}
onClose={() => setShowMediaLibrary(false)}
mode={SelectionMode.SELECT}
filterType={AssetType.IMAGE}
onSelect={handleSelectFromLibrary}
selectButtonText={language === 'zh' ? '使用此图片' : 'Use this image'}
/>
</Suspense>
)}
</Popover>
);
};
@@ -58,6 +58,9 @@ import { useDrawnix, DialogType } from '../../../hooks/use-drawnix';
import { useI18n } from '../../../i18n';
import { ToolButton } from '../../tool-button';
import { useGlobalMousePosition } from '../../../hooks/use-global-mouse-position';
import type { FillConfig } from '../../../types/fill.types';
import { isSolidFill, isFillConfig } from '../../../types/fill.types';
import { gradientToCSS } from '../../../utils/fill-renderer';
import { isVideoElement } from '../../../plugins/with-video';
import { VideoFrameSelector } from '../../video-frame-selector/video-frame-selector';
import { insertVideoFrame } from '../../../utils/video-frame';
@@ -111,7 +114,7 @@ export const PopupToolbar = () => {
],
});
let state: {
fill: string | undefined;
fill: string | FillConfig | undefined;
strokeColor?: string;
hasFill?: boolean;
hasText?: boolean;
@@ -486,10 +489,23 @@ export const PopupToolbar = () => {
<label
className={classNames('fill-label', 'color-label', {
'color-white':
state.fill && isWhite(removeHexAlpha(state.fill)),
state.fill && isSolidFill(state.fill) && isWhite(removeHexAlpha(state.fill)),
'color-mixed': state.fill === undefined,
'color-gradient': state.fill && isFillConfig(state.fill) && state.fill.type === 'gradient',
'color-image': state.fill && isFillConfig(state.fill) && state.fill.type === 'image',
})}
style={{ backgroundColor: state.fill }}
style={{
// 统一使用 background 属性,避免 backgroundColor 和 background 冲突
background: state.fill && isSolidFill(state.fill)
? state.fill
: state.fill && isFillConfig(state.fill)
? state.fill.type === 'gradient' && state.fill.gradient
? gradientToCSS(state.fill.gradient)
: state.fill.type === 'solid' && state.fill.solid
? state.fill.solid.color
: undefined
: undefined,
}}
></label>
</PopupFillButton>
)}
+2
View File
@@ -76,6 +76,7 @@ import { withArrowLineAutoCompleteExtend } from './plugins/with-arrow-line-auto-
import { AutoCompleteShapePicker } from './components/auto-complete-shape-picker';
import { useAutoCompleteShapePicker } from './hooks/useAutoCompleteShapePicker';
import { withDefaultFill } from './plugins/with-default-fill';
import { withGradientFill } from './plugins/with-gradient-fill';
import { API_AUTH_ERROR_EVENT, ApiAuthErrorDetail } from './utils/api-auth-error-event';
import { MessagePlugin } from 'tdesign-react';
@@ -561,6 +562,7 @@ export const Drawnix: React.FC<DrawnixProps> = ({
withWorkZone, // 工作区元素 - 在画布上显示工作流进度
withArrowLineAutoCompleteExtend, // 自动完成形状选择 - hover 中点时选择下一个节点形状
withDefaultFill, // 默认填充 - 让新创建的图形有白色填充,方便双击编辑
withGradientFill, // 渐变填充 - 支持渐变和图片填充渲染
withTracking,
];
@@ -0,0 +1,272 @@
/**
* With Gradient Fill Plugin
*
* 为图形元素支持渐变和图片填充
* 通过在 afterChange 钩子中修改 SVG DOM 来实现
*/
import { PlaitBoard, PlaitElement, getRectangleByElements } from '@plait/core';
import { isFillConfig, FillConfig } from '../types/fill.types';
import {
parseFillValue,
createSVGDefs,
} from '../utils/fill-renderer';
import { isClosedElement } from '../utils/property';
// 存储已处理的元素和它们的填充定义 ID
const processedFillDefs = new WeakMap<PlaitElement, string>();
// MutationObserver 实例
let observer: MutationObserver | null = null;
// 存储 board 引用,用于 MutationObserver 回调
let boardRef: PlaitBoard | null = null;
/**
* 查找元素对应的 SVG 组元素
* 优先使用 Plait API,回退到 DOM 查询
*/
function findElementG(board: PlaitBoard, element: PlaitElement): SVGGElement | null {
// 方法 1: 使用 Plait 的 API 获取元素的 G 元素
try {
const g = PlaitElement.getElementG(element);
if (g) return g;
} catch {
// 忽略异常,尝试其他方法
}
// 方法 2: 通过 element.id 在 DOM 中查找
if (element?.id) {
const host = PlaitBoard.getElementHost(board);
if (host) {
const g = host.querySelector(`g[id="${element.id}"]`) as SVGGElement;
if (g) return g;
}
}
return null;
}
/**
* 获取元素的填充目标 SVG 元素
* 对于不同类型的元素,填充应用的目标可能不同
*/
function getFillTargetElement(elementG: SVGGElement): SVGElement | null {
// 优先查找 path 或 rect 或 ellipse 等形状元素
const fillTarget = elementG.querySelector('path, rect, ellipse, polygon, circle') as SVGElement;
return fillTarget;
}
/**
* 获取或创建 SVG defs 元素
*/
function getOrCreateDefs(svg: SVGSVGElement): SVGDefsElement {
let defs = svg.querySelector('defs');
if (!defs) {
defs = createSVGDefs();
svg.insertBefore(defs, svg.firstChild);
}
return defs;
}
/**
* 清理旧的填充定义
*/
function cleanupOldFillDef(svg: SVGSVGElement, element: PlaitElement): void {
const oldDefId = processedFillDefs.get(element);
if (oldDefId) {
const oldDef = svg.querySelector(`#${oldDefId}`);
if (oldDef) {
oldDef.remove();
}
}
}
/**
* 应用填充配置到元素
*/
function applyFillConfig(
board: PlaitBoard,
element: PlaitElement,
fillConfig: FillConfig
): void {
const elementG = findElementG(board, element);
if (!elementG) return;
const fillTarget = getFillTargetElement(elementG);
if (!fillTarget) return;
const svg = elementG.closest('svg');
if (!svg) return;
// 获取元素尺寸
const rect = getRectangleByElements(board, [element], false);
const width = rect?.width || 100;
const height = rect?.height || 100;
// 解析填充值
const parsed = parseFillValue(fillConfig, element.id, width, height);
// 清理旧的定义(每次都清理,确保渐变内容更新)
cleanupOldFillDef(svg, element);
// 同时清理已存在的同 ID 定义(可能由之前的渲染遗留)
if (parsed.defElement) {
const existingDef = svg.querySelector(`#${parsed.defElement.id}`);
if (existingDef) {
existingDef.remove();
}
}
// 设置填充属性
fillTarget.setAttribute('fill', parsed.fillValue);
// 如果有定义元素,添加到 defs
if (parsed.defElement) {
const defs = getOrCreateDefs(svg);
defs.appendChild(parsed.defElement);
processedFillDefs.set(element, parsed.defElement.id);
}
}
/**
* 根据元素 ID 查找 board.children 中的元素
*/
function findElementById(board: PlaitBoard, id: string): PlaitElement | null {
return board.children.find((el) => el.id === id) || null;
}
/**
* 处理 DOM 变化,修复被 Plait 重新渲染的元素
*/
function handleDOMMutation(mutations: MutationRecord[]): void {
if (!boardRef) return;
const elementsToFix = new Set<PlaitElement>();
mutations.forEach((mutation) => {
// 监听属性变化
if (mutation.type === 'attributes' && mutation.attributeName === 'fill') {
const target = mutation.target as SVGElement;
const gElement = target.closest('g[id]') as SVGGElement;
if (gElement) {
const elementId = gElement.getAttribute('id');
if (elementId) {
const element = findElementById(boardRef!, elementId);
// 使用统一的 isClosedElement 判断
if (element && element.fill && isFillConfig(element.fill) && isClosedElement(boardRef!, element)) {
elementsToFix.add(element);
}
}
}
}
// 监听子节点变化(元素被重新渲染)
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node instanceof SVGGElement && node.hasAttribute('id')) {
const elementId = node.getAttribute('id');
if (elementId) {
const element = findElementById(boardRef!, elementId);
// 使用统一的 isClosedElement 判断
if (element && element.fill && isFillConfig(element.fill) && isClosedElement(boardRef!, element)) {
elementsToFix.add(element);
}
}
}
});
}
});
// 延迟修复,避免与 Plait 的渲染冲突
if (elementsToFix.size > 0) {
requestAnimationFrame(() => {
elementsToFix.forEach((element) => {
if (element.fill && isFillConfig(element.fill)) {
applyFillConfig(boardRef!, element, element.fill);
}
});
});
}
}
/**
* 设置 MutationObserver
*/
function setupMutationObserver(board: PlaitBoard): void {
if (observer) return;
const host = PlaitBoard.getElementHost(board);
if (!host) return;
boardRef = board;
observer = new MutationObserver(handleDOMMutation);
observer.observe(host, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['fill'],
});
}
/**
* 处理所有元素的填充渲染
* @param board - Plait Board 实例
* @param immediate - 是否立即同步处理(不使用 requestAnimationFrame)
*/
function processAllFillRendering(board: PlaitBoard, immediate = false): void {
board.children.forEach((element) => {
const fill = element.fill;
// 只处理 FillConfig 类型的填充
if (fill && isFillConfig(fill)) {
// 使用统一的 isClosedElement 判断,支持所有闭合图形类型
// 包括:DrawElement、Freehand、PenPath、CustomGeometry 等
if (isClosedElement(board, element)) {
if (immediate) {
// 同步处理,用于初始加载
applyFillConfig(board, element, fill);
} else {
// 异步处理,用于变更后的更新
requestAnimationFrame(() => {
applyFillConfig(board, element, fill);
});
}
}
}
});
}
// 标记是否为首次渲染(用于优化初始加载)
let isFirstRender = true;
/**
* 插件:支持渐变和图片填充
*/
export const withGradientFill = (board: PlaitBoard): PlaitBoard => {
const { afterChange } = board;
board.afterChange = () => {
// 先调用原始的 afterChange
afterChange();
// 设置 MutationObserver(只需一次)
setupMutationObserver(board);
// 处理填充渲染
// 首次渲染时使用同步模式,后续使用异步模式
if (isFirstRender) {
isFirstRender = false;
// 首次渲染:先尝试同步处理,然后再异步补充处理(确保 DOM 完全准备好)
processAllFillRendering(board, true);
requestAnimationFrame(() => {
processAllFillRendering(board, true);
});
} else {
processAllFillRendering(board);
}
};
return board;
};
+153 -3
View File
@@ -23,6 +23,30 @@ import {
import { TextTransforms, FontSizes, setSelection } from '@plait/text-plugins';
import { getTextEditors } from '@plait/common';
import { Editor, Transforms as SlateTransforms } from 'slate';
import type {
FillConfig,
FillType,
GradientFillConfig,
ImageFillConfig,
} from '../types/fill.types';
import { isFillConfig } from '../types/fill.types';
/**
* 从填充值中提取颜色字符串
* 支持字符串和 FillConfig 对象
*/
const extractColorFromFill = (fill: string | FillConfig | null | undefined): string | null => {
if (!fill) return null;
if (typeof fill === 'string') return fill;
if (isFillConfig(fill)) {
if (fill.type === 'solid' && fill.solid) {
return fill.solid.color;
}
// 渐变和图片填充不返回颜色
return null;
}
return null;
};
export const setFillColorOpacity = (board: PlaitBoard, fillOpacity: number) => {
PropertyTransforms.setFillColor(board, null, {
@@ -31,7 +55,9 @@ export const setFillColorOpacity = (board: PlaitBoard, fillOpacity: number) => {
if (!isClosedElement(board, element)) {
return;
}
const currentFill = getCurrentFill(board, element);
// 直接从 element.fill 获取,可能是 FillConfig
const rawFill = element.fill;
const currentFill = extractColorFromFill(rawFill as string | FillConfig) || getCurrentFill(board, element);
if (!isValidColor(currentFill)) {
return;
}
@@ -51,8 +77,10 @@ export const setFillColor = (board: PlaitBoard, fillColor: string) => {
if (!isClosedElement(board, element)) {
return;
}
const currentFill = getCurrentFill(board, element);
const currentOpacity = hexAlphaToOpacity(currentFill);
// 直接从 element.fill 获取,可能是 FillConfig
const rawFill = element.fill;
const currentFillStr = extractColorFromFill(rawFill as string | FillConfig) || getCurrentFill(board, element);
const currentOpacity = typeof currentFillStr === 'string' ? hexAlphaToOpacity(currentFillStr) : undefined;
if (isNoColor(fillColor)) {
Transforms.setNode(board, { fill: 'none' }, path);
} else {
@@ -407,3 +435,125 @@ export const getTextAlign = (board: PlaitBoard): 'left' | 'center' | 'right' =>
}
return 'left';
};
// ============ 填充类型相关 Transform ============
/**
* 设置渐变填充
*/
export const setGradientFill = (board: PlaitBoard, gradientConfig: GradientFillConfig) => {
PropertyTransforms.setFillColor(board, null, {
getMemorizeKey,
callback: (element: PlaitElement, path: Path) => {
if (!isClosedElement(board, element)) {
return;
}
const fillConfig: FillConfig = {
type: 'gradient',
gradient: gradientConfig,
};
Transforms.setNode(board, { fill: fillConfig }, path);
},
});
};
/**
* 设置图片填充
*/
export const setImageFill = (board: PlaitBoard, imageConfig: ImageFillConfig) => {
PropertyTransforms.setFillColor(board, null, {
getMemorizeKey,
callback: (element: PlaitElement, path: Path) => {
if (!isClosedElement(board, element)) {
return;
}
const fillConfig: FillConfig = {
type: 'image',
image: imageConfig,
};
Transforms.setNode(board, { fill: fillConfig }, path);
},
});
};
/**
* 设置填充类型(切换纯色/渐变/图片)
* 当切换类型时,保留当前类型的配置
*/
export const setFillType = (board: PlaitBoard, fillType: FillType) => {
PropertyTransforms.setFillColor(board, null, {
getMemorizeKey,
callback: (element: PlaitElement, path: Path) => {
if (!isClosedElement(board, element)) {
return;
}
const currentFill = element.fill;
// 如果当前是 FillConfig 且类型相同,不做任何操作
if (typeof currentFill === 'object' && currentFill?.type === fillType) {
return;
}
// 根据目标类型创建新的配置
let newFillConfig: FillConfig;
switch (fillType) {
case 'solid':
// 切换到纯色:如果当前是 FillConfig,尝试获取纯色配置
let solidColor = '#FFFFFF';
if (typeof currentFill === 'string') {
solidColor = currentFill;
} else if (typeof currentFill === 'object' && currentFill?.solid) {
solidColor = currentFill.solid.color;
}
newFillConfig = {
type: 'solid',
solid: { color: solidColor },
};
break;
case 'gradient':
newFillConfig = {
type: 'gradient',
gradient: {
type: 'linear',
angle: 90,
stops: [
{ offset: 0, color: '#FFFFFF' },
{ offset: 1, color: '#000000' },
],
},
};
// 如果当前是 FillConfig 且有渐变配置,保留它
if (typeof currentFill === 'object' && currentFill?.gradient) {
newFillConfig.gradient = currentFill.gradient;
}
break;
case 'image':
newFillConfig = {
type: 'image',
image: {
imageUrl: '',
mode: 'stretch',
scale: 1,
offsetX: 0,
offsetY: 0,
rotation: 0,
},
};
// 如果当前是 FillConfig 且有图片配置,保留它
if (typeof currentFill === 'object' && currentFill?.image) {
newFillConfig.image = currentFill.image;
}
break;
default:
return;
}
Transforms.setNode(board, { fill: newFillConfig }, path);
},
});
};
+340
View File
@@ -0,0 +1,340 @@
/**
* 填充类型定义
* Fill Type Definitions
*/
// ============ 填充类型枚举 ============
/** 填充类型 */
export type FillType = 'solid' | 'gradient' | 'image';
// ============ 纯色填充 ============
/** 纯色填充配置 */
export interface SolidFillConfig {
/** 颜色值 (HEX 格式,可含透明度) */
color: string;
}
// ============ 渐变填充 ============
/** 渐变类型 */
export type GradientFillType = 'linear' | 'radial';
/** 渐变色标 */
export interface GradientFillStop {
/** 位置 (0-1) */
offset: number;
/** 颜色值 (HEX 格式) */
color: string;
/** 透明度 (0-1),可选,默认从 color 的 alpha 通道获取 */
opacity?: number;
}
/** 线性渐变配置 */
export interface LinearGradientConfig {
type: 'linear';
/** 渐变角度 (0-360),0 度为从左到右 */
angle: number;
/** 色标列表 */
stops: GradientFillStop[];
}
/** 径向渐变配置 */
export interface RadialGradientConfig {
type: 'radial';
/** 中心点 X 位置 (0-1),相对于元素宽度 */
centerX: number;
/** 中心点 Y 位置 (0-1),相对于元素高度 */
centerY: number;
/** 色标列表 */
stops: GradientFillStop[];
}
/** 渐变填充配置 */
export type GradientFillConfig = LinearGradientConfig | RadialGradientConfig;
// ============ 图片填充 ============
/** 图片平铺模式 */
export type ImageFillMode = 'stretch' | 'tile' | 'fit';
/** 图片填充配置 */
export interface ImageFillConfig {
/** 图片 URL(可以是 base64 或网络 URL) */
imageUrl: string;
/** 平铺模式 */
mode: ImageFillMode;
/** 缩放比例 (0.5-2.0),默认 1 */
scale?: number;
/** X 轴偏移 (-1 到 1),相对于元素宽度,默认 0 */
offsetX?: number;
/** Y 轴偏移 (-1 到 1),相对于元素高度,默认 0 */
offsetY?: number;
/** 旋转角度 (0-360),默认 0 */
rotation?: number;
}
// ============ 统一填充配置 ============
/** 统一填充配置 */
export interface FillConfig {
/** 填充类型 */
type: FillType;
/** 纯色配置 (type='solid' 时使用) */
solid?: SolidFillConfig;
/** 渐变配置 (type='gradient' 时使用) */
gradient?: GradientFillConfig;
/** 图片配置 (type='image' 时使用) */
image?: ImageFillConfig;
}
// ============ 辅助类型和常量 ============
/** 默认纯色填充配置 */
export const DEFAULT_SOLID_FILL: SolidFillConfig = {
color: '#FFFFFF',
};
/** 默认线性渐变填充配置 */
export const DEFAULT_LINEAR_GRADIENT: LinearGradientConfig = {
type: 'linear',
angle: 90,
stops: [
{ offset: 0, color: '#FFFFFF' },
{ offset: 1, color: '#000000' },
],
};
/** 默认径向渐变填充配置 */
export const DEFAULT_RADIAL_GRADIENT: RadialGradientConfig = {
type: 'radial',
centerX: 0.5,
centerY: 0.5,
stops: [
{ offset: 0, color: '#FFFFFF' },
{ offset: 1, color: '#000000' },
],
};
/** 默认图片填充配置 */
export const DEFAULT_IMAGE_FILL: ImageFillConfig = {
imageUrl: '',
mode: 'stretch',
scale: 1,
offsetX: 0,
offsetY: 0,
rotation: 0,
};
// ============ 渐变预设 ============
/** 渐变预设分类 */
export type GradientPresetCategory = 'basic' | 'colorful' | 'sunset' | 'nature' | 'metal';
/** 渐变预设 */
export interface GradientFillPreset {
id: string;
name: string;
nameZh: string;
category: GradientPresetCategory;
config: GradientFillConfig;
}
/** 预设渐变列表 */
export const GRADIENT_FILL_PRESETS: GradientFillPreset[] = [
// Basic
{
id: 'gray-scale',
name: 'Gray Scale',
nameZh: '灰度',
category: 'basic',
config: {
type: 'linear',
angle: 90,
stops: [
{ offset: 0, color: '#FFFFFF' },
{ offset: 1, color: '#666666' },
],
},
},
// Colorful
{
id: 'ocean-blue',
name: 'Ocean Blue',
nameZh: '海洋蓝',
category: 'colorful',
config: {
type: 'linear',
angle: 135,
stops: [
{ offset: 0, color: '#667eea' },
{ offset: 1, color: '#764ba2' },
],
},
},
{
id: 'fresh-green',
name: 'Fresh Green',
nameZh: '清新绿',
category: 'colorful',
config: {
type: 'linear',
angle: 135,
stops: [
{ offset: 0, color: '#11998e' },
{ offset: 1, color: '#38ef7d' },
],
},
},
{
id: 'pink-purple',
name: 'Pink Purple',
nameZh: '粉紫',
category: 'colorful',
config: {
type: 'linear',
angle: 135,
stops: [
{ offset: 0, color: '#ee9ca7' },
{ offset: 1, color: '#ffdde1' },
],
},
},
// Sunset
{
id: 'sunset-orange',
name: 'Sunset Orange',
nameZh: '日落橙',
category: 'sunset',
config: {
type: 'linear',
angle: 45,
stops: [
{ offset: 0, color: '#ff6a00' },
{ offset: 1, color: '#ee0979' },
],
},
},
{
id: 'warm-flame',
name: 'Warm Flame',
nameZh: '暖焰',
category: 'sunset',
config: {
type: 'linear',
angle: 45,
stops: [
{ offset: 0, color: '#ff9a9e' },
{ offset: 0.5, color: '#fecfef' },
{ offset: 1, color: '#fecfef' },
],
},
},
// Nature
{
id: 'sky-blue',
name: 'Sky Blue',
nameZh: '天空蓝',
category: 'nature',
config: {
type: 'radial',
centerX: 0.5,
centerY: 0.3,
stops: [
{ offset: 0, color: '#a1c4fd' },
{ offset: 1, color: '#c2e9fb' },
],
},
},
{
id: 'forest-green',
name: 'Forest Green',
nameZh: '森林绿',
category: 'nature',
config: {
type: 'linear',
angle: 180,
stops: [
{ offset: 0, color: '#134e5e' },
{ offset: 1, color: '#71b280' },
],
},
},
// Metal
{
id: 'silver',
name: 'Silver',
nameZh: '银色',
category: 'metal',
config: {
type: 'linear',
angle: 135,
stops: [
{ offset: 0, color: '#bdc3c7' },
{ offset: 0.5, color: '#ecf0f1' },
{ offset: 1, color: '#bdc3c7' },
],
},
},
{
id: 'gold',
name: 'Gold',
nameZh: '金色',
category: 'metal',
config: {
type: 'linear',
angle: 135,
stops: [
{ offset: 0, color: '#f5af19' },
{ offset: 0.5, color: '#f7dc6f' },
{ offset: 1, color: '#f5af19' },
],
},
},
];
// ============ 工具函数类型 ============
/**
* 判断是否为纯色填充(兼容旧的 string 格式)
*/
export function isSolidFill(fill: string | FillConfig | undefined): fill is string {
return typeof fill === 'string';
}
/**
* 判断是否为 FillConfig 对象
*/
export function isFillConfig(fill: string | FillConfig | undefined): fill is FillConfig {
return typeof fill === 'object' && fill !== null && 'type' in fill;
}
/**
* 将旧格式的 string fill 转换为 FillConfig
*/
export function stringToFillConfig(fill: string): FillConfig {
return {
type: 'solid',
solid: { color: fill },
};
}
/**
* 将 FillConfig 转换为可用于渲染的字符串或 SVG 定义 ID
* 返回 null 表示需要使用 SVG defs 定义
*/
export function fillConfigToRenderValue(fill: FillConfig): string | null {
if (fill.type === 'solid' && fill.solid) {
return fill.solid.color;
}
// 渐变和图片填充需要使用 SVG defs,返回 null
return null;
}
/**
* 生成唯一的 SVG 定义 ID
*/
export function generateFillDefId(elementId: string, fillType: FillType): string {
return `fill-${fillType}-${elementId}`;
}
+380
View File
@@ -0,0 +1,380 @@
/**
* 填充渲染工具函数
* Fill Renderer Utilities
*
* 用于在 SVG 中渲染渐变和图片填充
*/
import type {
FillConfig,
GradientFillConfig,
ImageFillConfig,
LinearGradientConfig,
RadialGradientConfig,
} from '../types/fill.types';
import { isSolidFill, isFillConfig } from '../types/fill.types';
/**
* 生成唯一的 fill 定义 ID
*/
export function generateFillDefId(elementId: string, type: 'gradient' | 'pattern'): string {
return `fill-${type}-${elementId}`;
}
/**
* 创建 SVG defs 元素
*/
export function createSVGDefs(): SVGDefsElement {
return document.createElementNS('http://www.w3.org/2000/svg', 'defs');
}
/**
* 创建线性渐变定义
*/
export function createLinearGradientDef(
config: LinearGradientConfig,
id: string
): SVGLinearGradientElement {
const gradient = document.createElementNS(
'http://www.w3.org/2000/svg',
'linearGradient'
);
gradient.setAttribute('id', id);
// 将角度转换为 x1, y1, x2, y2
// 0 度 = 从左到右,90 度 = 从上到下
const angleRad = ((config.angle - 90) * Math.PI) / 180;
const x1 = 50 - Math.cos(angleRad) * 50;
const y1 = 50 - Math.sin(angleRad) * 50;
const x2 = 50 + Math.cos(angleRad) * 50;
const y2 = 50 + Math.sin(angleRad) * 50;
gradient.setAttribute('x1', `${x1}%`);
gradient.setAttribute('y1', `${y1}%`);
gradient.setAttribute('x2', `${x2}%`);
gradient.setAttribute('y2', `${y2}%`);
// 添加色标
config.stops.forEach((stop) => {
const stopEl = document.createElementNS(
'http://www.w3.org/2000/svg',
'stop'
);
stopEl.setAttribute('offset', `${stop.offset * 100}%`);
stopEl.setAttribute('stop-color', stop.color);
if (stop.opacity !== undefined && stop.opacity < 1) {
stopEl.setAttribute('stop-opacity', String(stop.opacity));
}
gradient.appendChild(stopEl);
});
return gradient;
}
/**
* 创建径向渐变定义
*/
export function createRadialGradientDef(
config: RadialGradientConfig,
id: string
): SVGRadialGradientElement {
const gradient = document.createElementNS(
'http://www.w3.org/2000/svg',
'radialGradient'
);
gradient.setAttribute('id', id);
gradient.setAttribute('cx', `${config.centerX * 100}%`);
gradient.setAttribute('cy', `${config.centerY * 100}%`);
gradient.setAttribute('r', '50%');
gradient.setAttribute('fx', `${config.centerX * 100}%`);
gradient.setAttribute('fy', `${config.centerY * 100}%`);
// 添加色标
config.stops.forEach((stop) => {
const stopEl = document.createElementNS(
'http://www.w3.org/2000/svg',
'stop'
);
stopEl.setAttribute('offset', `${stop.offset * 100}%`);
stopEl.setAttribute('stop-color', stop.color);
if (stop.opacity !== undefined && stop.opacity < 1) {
stopEl.setAttribute('stop-opacity', String(stop.opacity));
}
gradient.appendChild(stopEl);
});
return gradient;
}
/**
* 创建渐变定义
*/
export function createGradientDef(
config: GradientFillConfig,
id: string
): SVGLinearGradientElement | SVGRadialGradientElement {
if (config.type === 'linear') {
return createLinearGradientDef(config, id);
} else {
return createRadialGradientDef(config, id);
}
}
/**
* 创建图片填充 pattern 定义
*/
export function createImagePatternDef(
config: ImageFillConfig,
id: string,
elementWidth: number,
elementHeight: number
): SVGPatternElement {
const pattern = document.createElementNS(
'http://www.w3.org/2000/svg',
'pattern'
);
pattern.setAttribute('id', id);
pattern.setAttribute('patternUnits', 'userSpaceOnUse');
const scale = config.scale ?? 1;
const rotation = config.rotation ?? 0;
const offsetX = (config.offsetX ?? 0) * elementWidth;
const offsetY = (config.offsetY ?? 0) * elementHeight;
// 创建 image 元素
const image = document.createElementNS(
'http://www.w3.org/2000/svg',
'image'
);
image.setAttribute('href', config.imageUrl);
image.setAttribute('preserveAspectRatio', 'none');
// 根据模式设置 pattern 和 image 属性
switch (config.mode) {
case 'stretch':
// 拉伸模式:pattern 覆盖整个元素
pattern.setAttribute('width', String(elementWidth));
pattern.setAttribute('height', String(elementHeight));
pattern.setAttribute('x', String(offsetX));
pattern.setAttribute('y', String(offsetY));
image.setAttribute('width', String(elementWidth * scale));
image.setAttribute('height', String(elementHeight * scale));
break;
case 'tile':
// 平铺模式:使用原始图片尺寸(这里假设 100x100 作为基础)
const tileSize = 100 * scale;
pattern.setAttribute('width', String(tileSize));
pattern.setAttribute('height', String(tileSize));
pattern.setAttribute('x', String(offsetX));
pattern.setAttribute('y', String(offsetY));
image.setAttribute('width', String(tileSize));
image.setAttribute('height', String(tileSize));
break;
case 'fit':
// 适应模式:保持比例
pattern.setAttribute('width', String(elementWidth));
pattern.setAttribute('height', String(elementHeight));
pattern.setAttribute('x', String(offsetX));
pattern.setAttribute('y', String(offsetY));
image.setAttribute('width', String(elementWidth * scale));
image.setAttribute('height', String(elementHeight * scale));
image.setAttribute('preserveAspectRatio', 'xMidYMid meet');
break;
}
// 应用旋转变换
if (rotation !== 0) {
const cx = elementWidth / 2;
const cy = elementHeight / 2;
pattern.setAttribute(
'patternTransform',
`rotate(${rotation} ${cx} ${cy})`
);
}
pattern.appendChild(image);
return pattern;
}
/**
* 解析填充值,返回可用于 SVG fill 属性的值
* 如果需要 defs,返回 { defElement, fillValue }
* 如果是纯色,返回 { fillValue }
*/
export interface ParsedFill {
fillValue: string;
defElement?: SVGElement;
}
export function parseFillValue(
fill: string | FillConfig | undefined,
elementId: string,
elementWidth: number,
elementHeight: number
): ParsedFill {
// 无填充
if (!fill || fill === 'none') {
return { fillValue: 'none' };
}
// 纯色字符串
if (isSolidFill(fill)) {
return { fillValue: fill };
}
// FillConfig 对象
if (isFillConfig(fill)) {
switch (fill.type) {
case 'solid':
return { fillValue: fill.solid?.color || 'none' };
case 'gradient':
if (fill.gradient) {
const gradientId = generateFillDefId(elementId, 'gradient');
const defElement = createGradientDef(fill.gradient, gradientId);
return {
fillValue: `url(#${gradientId})`,
defElement,
};
}
return { fillValue: 'none' };
case 'image':
if (fill.image && fill.image.imageUrl) {
const patternId = generateFillDefId(elementId, 'pattern');
const defElement = createImagePatternDef(
fill.image,
patternId,
elementWidth,
elementHeight
);
return {
fillValue: `url(#${patternId})`,
defElement,
};
}
return { fillValue: 'none' };
default:
return { fillValue: 'none' };
}
}
return { fillValue: 'none' };
}
/**
* 为 SVG 元素应用填充
* 自动处理 defs 的添加
*/
export function applyFillToSVGElement(
svgElement: SVGElement,
fill: string | FillConfig | undefined,
elementId: string,
elementWidth: number,
elementHeight: number
): void {
const parsed = parseFillValue(fill, elementId, elementWidth, elementHeight);
// 设置 fill 属性
svgElement.setAttribute('fill', parsed.fillValue);
// 如果有 def 元素,添加到 SVG 的 defs 中
if (parsed.defElement) {
// 查找或创建 defs
const svg = svgElement.closest('svg');
if (svg) {
let defs = svg.querySelector('defs');
if (!defs) {
defs = createSVGDefs();
svg.insertBefore(defs, svg.firstChild);
}
// 移除已存在的同 id 定义
const existingDef = defs.querySelector(`#${parsed.defElement.id}`);
if (existingDef) {
existingDef.remove();
}
defs.appendChild(parsed.defElement);
}
}
}
/**
* 生成渐变的 CSS 字符串(用于预览)
*/
export function gradientToCSS(config: GradientFillConfig): string {
const stopsStr = config.stops
.map((stop) => {
const opacity = stop.opacity !== undefined ? stop.opacity : 1;
if (opacity < 1) {
// 转换为 rgba
const hex = stop.color.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity}) ${Math.round(stop.offset * 100)}%`;
}
return `${stop.color} ${Math.round(stop.offset * 100)}%`;
})
.join(', ');
if (config.type === 'linear') {
return `linear-gradient(${config.angle}deg, ${stopsStr})`;
} else {
const cx = Math.round(config.centerX * 100);
const cy = Math.round(config.centerY * 100);
return `radial-gradient(circle at ${cx}% ${cy}%, ${stopsStr})`;
}
}
/**
* 生成图片填充的 CSS 字符串(用于预览)
*/
export function imageFillToCSS(config: ImageFillConfig): string {
if (!config.imageUrl) return 'none';
const scale = config.scale ?? 1;
const offsetX = (config.offsetX ?? 0) * 100;
const offsetY = (config.offsetY ?? 0) * 100;
const rotation = config.rotation ?? 0;
let backgroundSize = '';
let backgroundRepeat = '';
switch (config.mode) {
case 'stretch':
backgroundSize = `${scale * 100}% ${scale * 100}%`;
backgroundRepeat = 'no-repeat';
break;
case 'tile':
backgroundSize = `${scale * 100}px ${scale * 100}px`;
backgroundRepeat = 'repeat';
break;
case 'fit':
backgroundSize = 'contain';
backgroundRepeat = 'no-repeat';
break;
}
const styles: string[] = [
`background-image: url(${config.imageUrl})`,
`background-size: ${backgroundSize}`,
`background-repeat: ${backgroundRepeat}`,
`background-position: ${50 + offsetX}% ${50 + offsetY}%`,
];
if (rotation !== 0) {
// CSS 不支持直接旋转背景,需要使用伪元素或其他技术
// 这里仅做标注
styles.push(`/* rotation: ${rotation}deg */`);
}
return styles.join('; ');
}