Files
TrueGrowth/docs/CANVAS_BATCH_INSERTION_FIX_LESSONS.md

13 KiB
Raw Blame History

批量插入图片叠加问题修复经验总结

更新日期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

/**
 * 计算图片合理的显示尺寸(与 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

修改前

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 更新元素
    });
}

修改后

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

修改前

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;
  });

修改后

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

新增配置

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 使用统一的尺寸计算逻辑;添加 horizontalGapverticalGap 参数 素材库批量插入

六、经验总结

关键设计原则

1. 统一尺寸计算逻辑

问题:不同阶段使用不同的尺寸计算逻辑导致不一致 解决方案:提取共享的尺寸计算函数,所有阶段使用同一逻辑

2. 避免硬编码限制

问题:之前的 2048px 限制与布局预计算使用的 600px 不一致 解决方案:使用统一的 MEDIA_MAX_SIZE 常量600px

3. 保持异步更新能力

问题:锁定尺寸虽然解决叠加,但破坏了图片自适应能力 解决方案:保持 lockReferenceDimensions=false,但确保尺寸一致

4. 分离布局和渲染

概念:布局是"规划",渲染是"执行" 实践

  • 布局阶段预计算所有位置
  • 渲染阶段按计划插入
  • 异步更新只改变尺寸,不改变位置

调试技巧

1. 添加日志追踪

calculateImageDisplayDimensions 中添加日志:

console.log('[尺寸计算]', {
  natural: { width: naturalWidth, height: naturalHeight },
  calculated: { width, height },
  ratio: width / height
});

2. 对比预计算和实际尺寸

precalculateGridLayout 调用前后记录:

console.log('[布局预计算]', {
  inputSizes: itemSizes,
  positions: gridLayout.positions
});

3. 追踪异步更新

updateImageSizeAfterLoad 中记录:

console.log('[异步尺寸更新]', {
  url: imageUrl,
  natural: { width: naturalWidth, height: naturalHeight },
  newSize: { width: newWidth, height: newHeight },
  expectedSize: referenceDimensions
});

架构改进建议

1. 统一尺寸计算入口

建议在未来重构中,将所有图片尺寸计算统一到一个函数:

// 统一的图片尺寸计算
export function calculateImageDimensions(
  source: { width: number; height: number } | ImageElement,
  options?: {
    maxSize?: number;
    useOriginalSize?: boolean;
    referenceDimensions?: { width: number; height: number };
  }
): { width: number; height: number }

2. 尺寸元数据传递

建议在图片元素中存储尺寸计算的元数据:

interface ImageElement {
  // ... 现有属性
  dimensionMeta?: {
    calculatedAt: 'layout' | 'load' | 'update';
    algorithm: 'calculateImageDisplayDimensions' | 'buildImage' | 'legacy';
  };
}

3. 布局验证机制

建议添加布局验证,在开发模式下检测尺寸不一致:

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 列