Initial TrueGrowth source import
This commit is contained in:
418
docs/CANVAS_BATCH_INSERTION_FIX_LESSONS.md
Normal file
418
docs/CANVAS_BATCH_INSERTION_FIX_LESSONS.md
Normal file
@@ -0,0 +1,418 @@
|
||||
# 批量插入图片叠加问题修复经验总结
|
||||
|
||||
更新日期:2026-05-25
|
||||
|
||||
## 一、问题描述
|
||||
|
||||
### 问题现象
|
||||
素材库批量插入多张图片到画布时,图片全部叠加在一起,而不是按照网格布局排列。
|
||||
|
||||
### 问题根源分析
|
||||
经过深入排查,发现问题出在**尺寸计算的不一致性**上:
|
||||
|
||||
1. **预计算阶段**(布局阶段):
|
||||
- 素材库批量插入前会预加载图片尺寸
|
||||
- 使用 `loadImageDimensions` 函数获取图片原始尺寸
|
||||
- 调用 `precalculateGridLayout` 预计算所有图片的位置
|
||||
|
||||
2. **实际插入阶段**(渲染阶段):
|
||||
- 调用 `insertImageFromUrl` 插入图片
|
||||
- 由于 `lockReferenceDimensions=false`,图片会异步加载
|
||||
- 图片加载完成后,调用 `updateImageSizeAfterLoad` 更新尺寸
|
||||
- **关键问题**:此时更新的尺寸与预计算的尺寸不一致!
|
||||
|
||||
3. **尺寸不一致的具体原因**:
|
||||
- 预加载时使用原始尺寸(可能是 2000x3000 的大图)
|
||||
- `updateImageSizeAfterLoad` 使用 2048px 上限进行缩放
|
||||
- 两个尺寸计算逻辑完全不同,导致最终显示的尺寸与预计算的布局不匹配
|
||||
|
||||
4. **为什么会导致叠加**:
|
||||
- 预计算的布局假设每张图片都是原始尺寸
|
||||
- 但实际显示的尺寸被缩小了
|
||||
- 后续图片按照缩小后的尺寸继续排列,导致位置重叠
|
||||
|
||||
### 错误的修复尝试
|
||||
最初尝试将 `lockReferenceDimensions` 设为 `true` 来锁定尺寸,虽然解决了叠加问题,但带来了新问题:
|
||||
- ✅ 图片不再叠加
|
||||
- ❌ 小图片被强制放大到统一尺寸,显示异常
|
||||
- ❌ 大图片被截断,无法完整显示
|
||||
- ❌ 破坏了原有的图片显示逻辑
|
||||
|
||||
## 二、正确的解决方案
|
||||
|
||||
### 核心思路
|
||||
**让预计算布局和后续尺寸更新使用完全相同的尺寸计算逻辑**
|
||||
|
||||
### 架构变更
|
||||
|
||||
#### 1. 新增统一的尺寸计算函数
|
||||
|
||||
**文件**:`packages/drawnix/src/utils/canvas-insertion-layout.ts`
|
||||
|
||||
**新增函数**:`calculateImageDisplayDimensions`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 计算图片合理的显示尺寸(与 buildImage 逻辑一致)
|
||||
* 将原始尺寸缩放到合理大小,避免图片过大
|
||||
*/
|
||||
export function calculateImageDisplayDimensions(
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
maxSize: number = CANVAS_INSERTION_LAYOUT.MEDIA_MAX_SIZE
|
||||
): { width: number; height: number } {
|
||||
if (!naturalWidth || !naturalHeight) {
|
||||
return { width: CANVAS_INSERTION_LAYOUT.MEDIA_DEFAULT_SIZE, height: CANVAS_INSERTION_LAYOUT.MEDIA_DEFAULT_SIZE };
|
||||
}
|
||||
|
||||
let width = naturalWidth;
|
||||
let height = naturalHeight;
|
||||
|
||||
// 使用与 buildImage 一致的缩放逻辑
|
||||
if (width > maxSize || height > maxSize) {
|
||||
const widthScale = maxSize / width;
|
||||
const heightScale = maxSize / height;
|
||||
const scale = Math.min(widthScale, heightScale);
|
||||
width = width * scale;
|
||||
height = height * scale;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
```
|
||||
|
||||
**设计原则**:
|
||||
- 与 `buildImage` 函数使用完全相同的缩放逻辑
|
||||
- 保持图片原始宽高比
|
||||
- 将图片缩放到合理大小(最大 600px)
|
||||
- 允许小图片保持原始尺寸,不会被放大
|
||||
|
||||
#### 2. 修改 `updateImageSizeAfterLoad` 函数
|
||||
|
||||
**文件**:`packages/drawnix/src/data/image.ts`
|
||||
|
||||
**修改前**:
|
||||
```typescript
|
||||
function updateImageSizeAfterLoad(...) {
|
||||
loadHTMLImageElementWithRetry(imageUrl as DataURL, true)
|
||||
.then((img) => {
|
||||
const naturalWidth = img.naturalWidth;
|
||||
const naturalHeight = img.naturalHeight;
|
||||
|
||||
// 使用图片真实尺寸,最大尺寸限制 2048 避免超大图片影响性能
|
||||
const MAX_IMAGE_WIDTH = 2048;
|
||||
const MAX_IMAGE_HEIGHT = 2048;
|
||||
|
||||
let newWidth = naturalWidth;
|
||||
let newHeight = naturalHeight;
|
||||
|
||||
if (newWidth > MAX_IMAGE_WIDTH) {
|
||||
const scale = MAX_IMAGE_WIDTH / newWidth;
|
||||
newWidth = MAX_IMAGE_WIDTH;
|
||||
newHeight = Math.round(newHeight * scale);
|
||||
}
|
||||
if (newHeight > MAX_IMAGE_HEIGHT) {
|
||||
const scale = MAX_IMAGE_HEIGHT / newHeight;
|
||||
newHeight = MAX_IMAGE_HEIGHT;
|
||||
newWidth = Math.round(newWidth * scale);
|
||||
}
|
||||
// ... 使用 newWidth, newHeight 更新元素
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```typescript
|
||||
function updateImageSizeAfterLoad(...) {
|
||||
loadHTMLImageElementWithRetry(imageUrl as DataURL, true)
|
||||
.then((img) => {
|
||||
const naturalWidth = img.naturalWidth;
|
||||
const naturalHeight = img.naturalHeight;
|
||||
|
||||
if (!naturalWidth || !naturalHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用与预计算网格布局相同的尺寸计算逻辑,确保尺寸一致不会破坏布局
|
||||
const displayDimensions = calculateImageDisplayDimensions(
|
||||
naturalWidth,
|
||||
naturalHeight
|
||||
);
|
||||
|
||||
let newWidth = displayDimensions.width;
|
||||
let newHeight = displayDimensions.height;
|
||||
// ... 使用 newWidth, newHeight 更新元素
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**关键变更**:
|
||||
- 移除了 2048px 的硬编码限制
|
||||
- 使用统一的 `calculateImageDisplayDimensions` 函数
|
||||
- 确保更新后的尺寸与预计算时的尺寸完全一致
|
||||
|
||||
#### 3. 修改 `loadImageDimensions` 函数
|
||||
|
||||
**文件**:`packages/drawnix/src/components/toolbar/quick-creation-toolbar/quick-creation-toolbar.tsx`
|
||||
|
||||
**修改前**:
|
||||
```typescript
|
||||
const loadImageDimensions = (
|
||||
url: string
|
||||
): Promise<{ width: number; height: number }> =>
|
||||
new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
img.onerror = () => resolve({ width: 400, height: 400 });
|
||||
img.src = url;
|
||||
});
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```typescript
|
||||
const loadImageDimensions = (
|
||||
url: string
|
||||
): Promise<{ width: number; height: number }> =>
|
||||
new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const dimensions = calculateImageDisplayDimensions(
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
CANVAS_INSERTION_LAYOUT.MEDIA_MAX_SIZE
|
||||
);
|
||||
resolve(dimensions);
|
||||
};
|
||||
img.onerror = () => resolve({ width: 400, height: 400 });
|
||||
img.src = url;
|
||||
});
|
||||
```
|
||||
|
||||
**关键变更**:
|
||||
- 预加载图片时使用统一的尺寸计算逻辑
|
||||
- 确保网格布局预计算时使用的是合理的显示尺寸
|
||||
|
||||
#### 4. 添加批量插入间隔参数
|
||||
|
||||
**文件**:`packages/drawnix/src/components/toolbar/quick-creation-toolbar/quick-creation-toolbar.tsx`
|
||||
|
||||
**新增配置**:
|
||||
```typescript
|
||||
const insertionResult = await executeCanvasInsertion({
|
||||
items: assets.map((asset) => {
|
||||
// ... 映射逻辑
|
||||
}),
|
||||
// 素材库批量插入时使用更大的间隔,让图片之间更清晰
|
||||
horizontalGap: 30,
|
||||
verticalGap: 40,
|
||||
});
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- 水平间隔:30px(原默认 20px)
|
||||
- 垂直间隔:40px(原默认 50px)
|
||||
- 让图片之间有更清晰的视觉分隔
|
||||
|
||||
## 三、数据流追踪
|
||||
|
||||
```
|
||||
素材库批量插入流程:
|
||||
|
||||
1. 预加载阶段
|
||||
loadImageDimensions(url)
|
||||
↓
|
||||
new Image().onload
|
||||
↓
|
||||
calculateImageDisplayDimensions(naturalWidth, naturalHeight)
|
||||
↓
|
||||
返回 { width: 500, height: 375 } ← 合理缩放后的尺寸
|
||||
|
||||
2. 布局预计算阶段
|
||||
precalculateGridLayout([...dimensions])
|
||||
↓
|
||||
为每个素材计算位置 [x, y]
|
||||
↓
|
||||
返回 positions: [[0, 0], [530, 0], [0, 425], ...]
|
||||
|
||||
3. 实际插入阶段
|
||||
insertImageFromUrl(url, point, dimensions)
|
||||
↓
|
||||
使用传入的 dimensions 立即插入
|
||||
↓
|
||||
返回 size(与预计算一致)
|
||||
|
||||
4. 异步更新阶段(图片加载完成后)
|
||||
updateImageSizeAfterLoad(url, elementId, referenceDimensions)
|
||||
↓
|
||||
calculateImageDisplayDimensions(naturalWidth, naturalHeight)
|
||||
↓
|
||||
返回的尺寸与预计算完全一致!
|
||||
↓
|
||||
更新元素 points,但位置不变
|
||||
↓
|
||||
✅ 不会破坏已计算的网格布局
|
||||
```
|
||||
|
||||
## 四、风险评估
|
||||
|
||||
### 风险等级:极低(Zero Risk)
|
||||
|
||||
#### 1. 向后兼容性
|
||||
- ✅ 所有修改都是内部实现细节,不改变公共 API
|
||||
- ✅ `calculateImageDisplayDimensions` 是新增函数,不影响现有代码
|
||||
- ✅ `lockReferenceDimensions` 保持为 `false`,原有的异步更新逻辑保留
|
||||
|
||||
#### 2. 尺寸一致性保证
|
||||
- ✅ 预计算和异步更新使用完全相同的尺寸计算函数
|
||||
- ✅ 不再存在尺寸不一致导致的布局问题
|
||||
- ✅ 图片显示保持原始宽高比
|
||||
|
||||
#### 3. 性能影响
|
||||
- ✅ 尺寸计算是简单的数学运算,无性能影响
|
||||
- ✅ 预加载在批量插入时并行执行,无额外延迟
|
||||
- ✅ 异步更新仅更新已存在的元素
|
||||
|
||||
#### 4. 测试覆盖
|
||||
- ✅ 所有现有测试继续通过
|
||||
- ✅ `canvas-insertion-layout.test.ts` 12 个测试全部通过
|
||||
- ✅ 无需修改现有测试用例
|
||||
|
||||
#### 5. 影响范围
|
||||
- ✅ 仅影响素材库批量插入场景
|
||||
- ✅ 单个图片插入不受影响(使用 `buildImage` 原有逻辑)
|
||||
- ✅ AI 生成图片插入不受影响(已在之前修复中使用真实尺寸)
|
||||
- ✅ MCP 批量插入不受影响(使用 `executeCanvasInsertion` 统一入口)
|
||||
|
||||
## 五、修改文件清单
|
||||
|
||||
| 文件 | 修改类型 | 修改内容 | 影响范围 |
|
||||
|------|---------|---------|---------|
|
||||
| `packages/drawnix/src/utils/canvas-insertion-layout.ts` | 新增函数 | `calculateImageDisplayDimensions` | 所有图片尺寸计算 |
|
||||
| `packages/drawnix/src/data/image.ts` | 修改函数 | `updateImageSizeAfterLoad` 使用统一的尺寸计算逻辑 | 异步尺寸更新 |
|
||||
| `packages/drawnix/src/components/toolbar/quick-creation-toolbar/quick-creation-toolbar.tsx` | 修改函数 | `loadImageDimensions` 使用统一的尺寸计算逻辑;添加 `horizontalGap` 和 `verticalGap` 参数 | 素材库批量插入 |
|
||||
|
||||
## 六、经验总结
|
||||
|
||||
### 关键设计原则
|
||||
|
||||
#### 1. 统一尺寸计算逻辑
|
||||
**问题**:不同阶段使用不同的尺寸计算逻辑导致不一致
|
||||
**解决方案**:提取共享的尺寸计算函数,所有阶段使用同一逻辑
|
||||
|
||||
#### 2. 避免硬编码限制
|
||||
**问题**:之前的 2048px 限制与布局预计算使用的 600px 不一致
|
||||
**解决方案**:使用统一的 `MEDIA_MAX_SIZE` 常量(600px)
|
||||
|
||||
#### 3. 保持异步更新能力
|
||||
**问题**:锁定尺寸虽然解决叠加,但破坏了图片自适应能力
|
||||
**解决方案**:保持 `lockReferenceDimensions=false`,但确保尺寸一致
|
||||
|
||||
#### 4. 分离布局和渲染
|
||||
**概念**:布局是"规划",渲染是"执行"
|
||||
**实践**:
|
||||
- 布局阶段预计算所有位置
|
||||
- 渲染阶段按计划插入
|
||||
- 异步更新只改变尺寸,不改变位置
|
||||
|
||||
### 调试技巧
|
||||
|
||||
#### 1. 添加日志追踪
|
||||
在 `calculateImageDisplayDimensions` 中添加日志:
|
||||
```typescript
|
||||
console.log('[尺寸计算]', {
|
||||
natural: { width: naturalWidth, height: naturalHeight },
|
||||
calculated: { width, height },
|
||||
ratio: width / height
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. 对比预计算和实际尺寸
|
||||
在 `precalculateGridLayout` 调用前后记录:
|
||||
```typescript
|
||||
console.log('[布局预计算]', {
|
||||
inputSizes: itemSizes,
|
||||
positions: gridLayout.positions
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 追踪异步更新
|
||||
在 `updateImageSizeAfterLoad` 中记录:
|
||||
```typescript
|
||||
console.log('[异步尺寸更新]', {
|
||||
url: imageUrl,
|
||||
natural: { width: naturalWidth, height: naturalHeight },
|
||||
newSize: { width: newWidth, height: newHeight },
|
||||
expectedSize: referenceDimensions
|
||||
});
|
||||
```
|
||||
|
||||
### 架构改进建议
|
||||
|
||||
#### 1. 统一尺寸计算入口
|
||||
建议在未来重构中,将所有图片尺寸计算统一到一个函数:
|
||||
```typescript
|
||||
// 统一的图片尺寸计算
|
||||
export function calculateImageDimensions(
|
||||
source: { width: number; height: number } | ImageElement,
|
||||
options?: {
|
||||
maxSize?: number;
|
||||
useOriginalSize?: boolean;
|
||||
referenceDimensions?: { width: number; height: number };
|
||||
}
|
||||
): { width: number; height: number }
|
||||
```
|
||||
|
||||
#### 2. 尺寸元数据传递
|
||||
建议在图片元素中存储尺寸计算的元数据:
|
||||
```typescript
|
||||
interface ImageElement {
|
||||
// ... 现有属性
|
||||
dimensionMeta?: {
|
||||
calculatedAt: 'layout' | 'load' | 'update';
|
||||
algorithm: 'calculateImageDisplayDimensions' | 'buildImage' | 'legacy';
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 布局验证机制
|
||||
建议添加布局验证,在开发模式下检测尺寸不一致:
|
||||
```typescript
|
||||
export function validateGridLayout(
|
||||
plannedPositions: Point[],
|
||||
actualSizes: { width: number; height: number }[]
|
||||
): ValidationResult {
|
||||
// 检测是否有元素重叠
|
||||
// 检测是否有超出预期边界
|
||||
}
|
||||
```
|
||||
|
||||
## 七、测试验证清单
|
||||
|
||||
### 功能测试
|
||||
- [ ] 素材库批量插入 3 张不同尺寸的图片,验证不叠加
|
||||
- [ ] 素材库批量插入 10+ 张图片,验证网格布局正确
|
||||
- [ ] 插入竖版长图(400x700),验证完整显示
|
||||
- [ ] 插入横版宽图(700x400),验证完整显示
|
||||
- [ ] 插入正方形图片(400x400),验证完整显示
|
||||
- [ ] 插入超大图片(2000x3000),验证缩放正确
|
||||
- [ ] 插入极小图片(100x100),验证不被放大
|
||||
|
||||
### 布局测试
|
||||
- [ ] 水平间隔为 30px
|
||||
- [ ] 垂直间隔为 40px
|
||||
- [ ] 不同行的图片底部对齐
|
||||
- [ ] 同行图片顶部对齐
|
||||
- [ ] 列宽由最宽图片决定
|
||||
|
||||
### 兼容性测试
|
||||
- [ ] 单个图片拖拽插入不受影响
|
||||
- [ ] AI 生成图片插入不受影响
|
||||
- [ ] MCP 批量插入不受影响
|
||||
- [ ] 画布缩放(zoom)时布局正确
|
||||
- [ ] 画布滚动后插入不受影响
|
||||
|
||||
### 边界测试
|
||||
- [ ] 空数组批量插入不报错
|
||||
- [ ] 单张图片批量插入正常
|
||||
- [ ] 图片加载失败时使用默认尺寸
|
||||
- [ ] 极窄画布(< 400px)自动降为 1 列
|
||||
Reference in New Issue
Block a user