From 206af04b671f318d337c027583b8d7a204cc55fc Mon Sep 17 00:00:00 2001 From: liquidliang Date: Fri, 16 Jan 2026 18:20:56 +0800 Subject: [PATCH] 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. --- .../fill-gradient-image-support_57515a39.md | 245 +++++++++++ .../src/components/fill-panel/FillPanel.tsx | 197 +++++++++ .../fill-panel/GradientFillPanel.tsx | 386 +++++++++++++++++ .../components/fill-panel/ImageFillPanel.tsx | 320 ++++++++++++++ .../src/components/fill-panel/fill-panel.scss | 109 +++++ .../fill-panel/gradient-fill-panel.scss | 400 ++++++++++++++++++ .../fill-panel/image-fill-panel.scss | 289 +++++++++++++ .../src/components/fill-panel/index.ts | 13 + .../src/components/popover/popover.tsx | 15 +- .../toolbar/popup-toolbar/fill-button.tsx | 173 +++++++- .../toolbar/popup-toolbar/popup-toolbar.tsx | 22 +- packages/drawnix/src/drawnix.tsx | 2 + .../drawnix/src/plugins/with-gradient-fill.ts | 272 ++++++++++++ packages/drawnix/src/transforms/property.ts | 156 ++++++- packages/drawnix/src/types/fill.types.ts | 340 +++++++++++++++ packages/drawnix/src/utils/fill-renderer.ts | 380 +++++++++++++++++ 16 files changed, 3295 insertions(+), 24 deletions(-) create mode 100644 .codebuddy/plans/fill-gradient-image-support_57515a39.md create mode 100644 packages/drawnix/src/components/fill-panel/FillPanel.tsx create mode 100644 packages/drawnix/src/components/fill-panel/GradientFillPanel.tsx create mode 100644 packages/drawnix/src/components/fill-panel/ImageFillPanel.tsx create mode 100644 packages/drawnix/src/components/fill-panel/fill-panel.scss create mode 100644 packages/drawnix/src/components/fill-panel/gradient-fill-panel.scss create mode 100644 packages/drawnix/src/components/fill-panel/image-fill-panel.scss create mode 100644 packages/drawnix/src/components/fill-panel/index.ts create mode 100644 packages/drawnix/src/plugins/with-gradient-fill.ts create mode 100644 packages/drawnix/src/types/fill.types.ts create mode 100644 packages/drawnix/src/utils/fill-renderer.ts diff --git a/.codebuddy/plans/fill-gradient-image-support_57515a39.md b/.codebuddy/plans/fill-gradient-image-support_57515a39.md new file mode 100644 index 00000000..d9690f77 --- /dev/null +++ b/.codebuddy/plans/fill-gradient-image-support_57515a39.md @@ -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: 了解现有填充功能的代码组织、数据结构和渲染方式,确保新功能与现有代码无缝集成 \ No newline at end of file diff --git a/packages/drawnix/src/components/fill-panel/FillPanel.tsx b/packages/drawnix/src/components/fill-panel/FillPanel.tsx new file mode 100644 index 00000000..3215b236 --- /dev/null +++ b/packages/drawnix/src/components/fill-panel/FillPanel.tsx @@ -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 = ({ + 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(currentConfig.type); + + // 同步外部值变化到标签页状态 + useEffect(() => { + setActiveTab(currentConfig.type); + }, [currentConfig.type]); + + // 内部状态 - 保存每种类型的配置 + const [gradientConfig, setGradientConfig] = useState( + currentConfig.gradient || DEFAULT_LINEAR_GRADIENT + ); + const [imageConfig, setImageConfig] = useState( + 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 ( +
+ {/* 头部 */} +
+ + {language === 'zh' ? '填充' : 'Fill'} + + {showClose && ( + + )} +
+ + {/* 标签页 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* 内容区域 */} +
+ {activeTab === 'solid' && ( +
+ +
+ )} + + {activeTab === 'gradient' && ( + + )} + + {activeTab === 'image' && ( + + )} +
+
+ ); +}; + +export default FillPanel; diff --git a/packages/drawnix/src/components/fill-panel/GradientFillPanel.tsx b/packages/drawnix/src/components/fill-panel/GradientFillPanel.tsx new file mode 100644 index 00000000..2a1b490b --- /dev/null +++ b/packages/drawnix/src/components/fill-panel/GradientFillPanel.tsx @@ -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 = ({ + value, + onChange, +}) => { + const { language } = useI18n(); + const [activeTab, setActiveTab] = useState('presets'); + const [gradient, setGradient] = useState( + 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) => { + 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) => { + 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 = () => ( +
+ {presetCategories.map(({ key, label }) => { + const presets = GRADIENT_FILL_PRESETS.filter((p) => p.category === key); + if (presets.length === 0) return null; + + return ( +
+
{label}
+
+ {presets.map((preset) => ( + + ))} +
+
+ ); + })} +
+ ); + + // 渲染自定义编辑器 + const renderCustomEditor = () => ( +
+ {/* 渐变类型 */} +
+
+ + +
+
+ + {/* 渐变预览和色标 */} +
+
+ + {language === 'zh' ? '颜色' : 'Colors'} + +
+ + +
+
+ + {/* 渐变条与色标 */} +
+
+
+ {gradient.stops.map((stop, index) => ( +
+
+ + {/* 选中色标的编辑 */} + {gradient.stops[selectedStopIndex] && ( +
+
+ updateStop(selectedStopIndex, { color: e.target.value })} + className="gfp-color-input" + /> + + {gradient.stops[selectedStopIndex].color.toUpperCase()} + +
+
+ + updateStop(selectedStopIndex, { offset: Number(e.target.value) / 100 }) + } + className="gfp-position-slider" + /> +
+
+ )} +
+ + {/* 角度控制 (仅线性渐变) */} + {gradient.type === 'linear' && ( +
+
+ + {language === 'zh' ? '旋转' : 'Rotation'} + +
+ updateGradient({ angle: Number(e.target.value) })} + className="gfp-angle-input" + /> +
+
+ updateGradient({ angle: Number(e.target.value) })} + className="gfp-angle-slider" + /> +
+ )} + + {/* 中心点控制 (仅径向渐变) */} + {gradient.type === 'radial' && ( +
+
+ {language === 'zh' ? '中心点' : 'Center'} +
+
+
+ + + updateGradient({ centerX: Number(e.target.value) / 100 }) + } + className="gfp-center-slider" + /> + + {Math.round((gradient as RadialGradientConfig).centerX * 100)}% + +
+
+ + + updateGradient({ centerY: Number(e.target.value) / 100 }) + } + className="gfp-center-slider" + /> + + {Math.round((gradient as RadialGradientConfig).centerY * 100)}% + +
+
+
+ )} +
+ ); + + return ( +
+
+ + +
+
+ {activeTab === 'presets' && renderPresetsPanel()} + {activeTab === 'custom' && renderCustomEditor()} +
+
+ ); +}; + +export default GradientFillPanel; diff --git a/packages/drawnix/src/components/fill-panel/ImageFillPanel.tsx b/packages/drawnix/src/components/fill-panel/ImageFillPanel.tsx new file mode 100644 index 00000000..230dfc95 --- /dev/null +++ b/packages/drawnix/src/components/fill-panel/ImageFillPanel.tsx @@ -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 = ({ + value, + onChange, + onOpenMediaLibrary, + externalMediaLibraryControl = false, +}) => { + const { language } = useI18n(); + const [config, setConfig] = useState(value || DEFAULT_IMAGE_FILL); + const [showMediaLibrary, setShowMediaLibrary] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const fileInputRef = useRef(null); + + // 打开素材库的处理函数 + const handleOpenMediaLibrary = useCallback(() => { + if (externalMediaLibraryControl && onOpenMediaLibrary) { + // 使用外部控制时,调用外部回调 + onOpenMediaLibrary(); + } else { + // 内部控制 + setShowMediaLibrary(true); + } + }, [externalMediaLibraryControl, onOpenMediaLibrary]); + + // 更新配置 + const updateConfig = useCallback( + (updates: Partial) => { + 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) => { + 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 ( +
+ Fill preview + +
+ ); + } + + return ( +
+ + + {language === 'zh' ? '选择图片' : 'Select Image'} + +
+ ); + }; + + return ( +
+ {/* 隐藏的文件输入 */} + + + {/* 图片选择区域 */} +
+
!config.imageUrl && handleOpenMediaLibrary()}> + {renderImagePreview()} +
+ +
+ + +
+
+ + {/* 只有选择了图片才显示后续选项 */} + {config.imageUrl && ( + <> + {/* 平铺模式 */} +
+
+ {language === 'zh' ? '平铺模式' : 'Fill Mode'} +
+
+ {FILL_MODES.map((mode) => ( + + ))} +
+
+ + {/* 高级参数折叠面板 */} +
+ + + {showAdvanced && ( +
+ {/* 缩放 */} +
+ +
+ updateConfig({ scale: Number(e.target.value) / 100 })} + className="ifp-slider" + /> + + {Math.round((config.scale ?? 1) * 100)}% + +
+
+ + {/* X 偏移 */} +
+ +
+ updateConfig({ offsetX: Number(e.target.value) / 100 })} + className="ifp-slider" + /> + + {Math.round((config.offsetX ?? 0) * 100)}% + +
+
+ + {/* Y 偏移 */} +
+ +
+ updateConfig({ offsetY: Number(e.target.value) / 100 })} + className="ifp-slider" + /> + + {Math.round((config.offsetY ?? 0) * 100)}% + +
+
+ + {/* 旋转 */} +
+ +
+ updateConfig({ rotation: Number(e.target.value) })} + className="ifp-slider" + /> + {config.rotation ?? 0}° +
+
+ + {/* 重置按钮 */} + +
+ )} +
+ + )} + + {/* 素材库弹窗 - 仅在内部控制时渲染 */} + {!externalMediaLibraryControl && ( + setShowMediaLibrary(false)} + mode={SelectionMode.SELECT} + filterType={AssetType.IMAGE} + onSelect={handleSelectFromLibrary} + selectButtonText={language === 'zh' ? '使用此图片' : 'Use this image'} + /> + )} +
+ ); +}; + +export default ImageFillPanel; diff --git a/packages/drawnix/src/components/fill-panel/fill-panel.scss b/packages/drawnix/src/components/fill-panel/fill-panel.scss new file mode 100644 index 00000000..b88f69a0 --- /dev/null +++ b/packages/drawnix/src/components/fill-panel/fill-panel.scss @@ -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; + } +} diff --git a/packages/drawnix/src/components/fill-panel/gradient-fill-panel.scss b/packages/drawnix/src/components/fill-panel/gradient-fill-panel.scss new file mode 100644 index 00000000..faac4b3c --- /dev/null +++ b/packages/drawnix/src/components/fill-panel/gradient-fill-panel.scss @@ -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; + } +} diff --git a/packages/drawnix/src/components/fill-panel/image-fill-panel.scss b/packages/drawnix/src/components/fill-panel/image-fill-panel.scss new file mode 100644 index 00000000..a013e3da --- /dev/null +++ b/packages/drawnix/src/components/fill-panel/image-fill-panel.scss @@ -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); + } +} diff --git a/packages/drawnix/src/components/fill-panel/index.ts b/packages/drawnix/src/components/fill-panel/index.ts new file mode 100644 index 00000000..8e1fb6ec --- /dev/null +++ b/packages/drawnix/src/components/fill-panel/index.ts @@ -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'; diff --git a/packages/drawnix/src/components/popover/popover.tsx b/packages/drawnix/src/components/popover/popover.tsx index cf808a93..83d27f83 100644 --- a/packages/drawnix/src/components/popover/popover.tsx +++ b/packages/drawnix/src/components/popover/popover.tsx @@ -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]); diff --git a/packages/drawnix/src/components/toolbar/popup-toolbar/fill-button.tsx b/packages/drawnix/src/components/toolbar/popup-toolbar/fill-button.tsx index 893c5037..1003ecaf 100644 --- a/packages/drawnix/src/components/toolbar/popup-toolbar/fill-button.tsx +++ b/packages/drawnix/src/components/toolbar/popup-toolbar/fill-button.tsx @@ -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 = ({ 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 ( = ({ {!icon && children} - + {/* 不传递 container,让 FloatingPortal 渲染到 body,确保 AssetProvider 上下文可用 */} + - + + {/* 素材库弹窗 - 渲染在 Popover 外部,避免 Popover 关闭时被卸载 */} + {showMediaLibrary && ( + + setShowMediaLibrary(false)} + mode={SelectionMode.SELECT} + filterType={AssetType.IMAGE} + onSelect={handleSelectFromLibrary} + selectButtonText={language === 'zh' ? '使用此图片' : 'Use this image'} + /> + + )} ); }; diff --git a/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx b/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx index ff7b15d4..796a0a10 100644 --- a/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx +++ b/packages/drawnix/src/components/toolbar/popup-toolbar/popup-toolbar.tsx @@ -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 = () => { )} diff --git a/packages/drawnix/src/drawnix.tsx b/packages/drawnix/src/drawnix.tsx index 5dbe2f1d..b40ba89c 100644 --- a/packages/drawnix/src/drawnix.tsx +++ b/packages/drawnix/src/drawnix.tsx @@ -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 = ({ withWorkZone, // 工作区元素 - 在画布上显示工作流进度 withArrowLineAutoCompleteExtend, // 自动完成形状选择 - hover 中点时选择下一个节点形状 withDefaultFill, // 默认填充 - 让新创建的图形有白色填充,方便双击编辑 + withGradientFill, // 渐变填充 - 支持渐变和图片填充渲染 withTracking, ]; diff --git a/packages/drawnix/src/plugins/with-gradient-fill.ts b/packages/drawnix/src/plugins/with-gradient-fill.ts new file mode 100644 index 00000000..03223f1d --- /dev/null +++ b/packages/drawnix/src/plugins/with-gradient-fill.ts @@ -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(); + +// 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(); + + 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; +}; diff --git a/packages/drawnix/src/transforms/property.ts b/packages/drawnix/src/transforms/property.ts index c966bac9..f3885486 100644 --- a/packages/drawnix/src/transforms/property.ts +++ b/packages/drawnix/src/transforms/property.ts @@ -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); + }, + }); +}; diff --git a/packages/drawnix/src/types/fill.types.ts b/packages/drawnix/src/types/fill.types.ts new file mode 100644 index 00000000..1c01434c --- /dev/null +++ b/packages/drawnix/src/types/fill.types.ts @@ -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}`; +} diff --git a/packages/drawnix/src/utils/fill-renderer.ts b/packages/drawnix/src/utils/fill-renderer.ts new file mode 100644 index 00000000..9d4343c2 --- /dev/null +++ b/packages/drawnix/src/utils/fill-renderer.ts @@ -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('; '); +}