Files
TrueGrowth/docs/MEDIA_LIBRARY_INSERTION_LESSONS.md

9.9 KiB
Raw Blame History

素材库插入画布经验总结

这份文档沉淀 2026-04-22 这轮“素材库点击插入无响应”的排查与修复经验,重点不是复盘某一个按钮,而是明确一条规则:

  • “打开素材库浏览”
  • “打开素材库并选择后插入画布”

这两个动作在产品语义和代码语义上都不能混用。

一、问题现象

用户在画布页通过左侧工具栏或快捷工具栏打开素材库后,点击“插入”没有插入到画布,也没有明显反馈。

表面看像是“插入逻辑失效”,但真实原因是两层问题叠加:

  • 工具栏入口想打开的是 SELECT 模式素材库
  • 实际打开的是全局默认 BROWSE 模式素材库
  • 即使走到 onSelect,素材库内部也没有等待异步插入完成,失败和卡顿都被伪装成“没反应”

二、根因

1. 全局素材库默认是 BROWSE,但画布入口需要 SELECT

画布页工具栏和快捷工具栏并不总是打开自身维护的 MediaLibraryModal,当父层传入 onOpenMediaLibrary 时,会改为打开 drawnix.tsx 里统一管理的全局素材库。

而全局素材库原来只接收:

  • isOpen
  • onClose

没有接收:

  • mode
  • filterType
  • onSelect
  • selectButtonText

结果就是:

  • 入口想要“插入画布”
  • 实际打开成了“浏览素材”
  • 用户看见的是素材库开了,但插入语义丢了

经验:

  • “统一弹窗管理”不能只统一开关状态
  • 还必须统一携带“打开语义”
  • 否则很容易出现入口 A 和入口 B 打开的是同一个弹窗壳,但行为完全不一致

2. 异步选择动作不能先关窗再说

原来的 MediaLibraryModal 在双击素材或点击“使用”按钮时是这样执行的:

  • onSelect(asset)
  • onClose()

这里最大的问题是 onSelect 允许异步,但弹窗不等待结果。

后果:

  • 插入慢时,用户看到弹窗先消失,但画布没变化
  • 插入失败时,错误反馈晚于关窗,用户会误以为“没点上”
  • 如果中途 pending很容易被误判成点击失效

经验:

  • 只要动作会改动画布、网络、缓存、解码资源,就应视为异步事务
  • 弹窗不能在事务开始后立刻关闭
  • 必须等待完成,再关闭或给失败反馈

三、这次修法

1. 给全局素材库增加“打开配置”

这次把全局素材库状态从单纯的 boolean 扩成了“开关 + 配置”:

  • mode
  • filterType
  • onSelect
  • selectButtonText

这样不同入口可以明确表达自己的意图:

  • 缓存清理入口:BROWSE
  • 画布插入入口:SELECT + onSelect

经验:

  • 全局弹窗状态不要只存 visible
  • 还要存“这个弹窗是为谁、以什么模式打开的”

2. 工具栏入口显式传入 SELECT

画布左侧工具栏和快捷工具栏现在打开素材库时,会显式传:

  • mode: SelectionMode.SELECT
  • onSelect: handleInsertAsset
  • selectButtonText: '插入'

这样不会再依赖全局默认值。

经验:

  • 入口的行为语义,必须在入口处声明
  • 不能靠下游组件默认值“猜”

3. 素材库等待插入完成后再关闭

MediaLibraryModal 里的双击和“使用”按钮,现在改成:

  • 设置 isSelecting
  • await onSelect(asset)
  • 成功后再 onClose()

同时:

  • “插入”按钮显示 loading
  • loading 时禁止重复点击
  • 卸载后通过 isMountedRef 防止无意义的状态回写

经验:

  • 交互上要把“正在插入”显式呈现出来
  • 这不仅是体验问题,也是排障问题
  • 用户可见的 loading本质上是运行时可观测性的一部分

四、这次沉淀出的规则

规则 1弹窗统一管理时必须保留入口语义

不要只抽象:

  • openXxxModal()

要抽象成:

  • openXxxModal(config)

至少要能带:

  • 模式
  • 回调
  • 文案
  • 筛选条件

规则 2BROWSESELECT 是两种产品态,不是一个小参数

BROWSE 表示:

  • 查看
  • 管理
  • 下载
  • 删除

SELECT 表示:

  • 选中后回传
  • 触发上游业务动作
  • 按上游场景展示按钮文案

经验:

  • 如果一个组件同时承载两种态,就必须在类型和状态上明确区分
  • 不能让调用方靠“有没有按钮”去推断当前模式

规则 3任何“选择后执行动作”的弹窗都要把异步状态做完整

至少包含:

  • loading
  • 防重复提交
  • 成功后关闭
  • 失败时保留现场

不要做成:

  • 点一下就关
  • 剩下靠日志和运气

规则 4统一弹窗很方便但默认值很危险

这次问题本质上就是:

  • 统一弹窗没错
  • 但把行为寄托在默认值上,导致入口语义丢失

经验:

  • 越是“被多个入口复用”的弹窗,越不能依赖默认行为
  • 默认值只能兜底,不能承载主流程

五、建议后续继续保持

  • 新增全局弹窗时,优先设计 open(config) 而不是 setVisible(true)
  • 只要弹窗里的主按钮会触发异步副作用,就必须带 loading
  • 当一个入口的目标是“插入到画布”,要从入口到弹窗都显式传递这层语义,不要中途丢失
  • 如果未来再拆工具栏或迁移弹窗管理层,优先回归测试“入口模式是否正确透传”

六、涉及文件

  • packages/drawnix/src/drawnix.tsx
  • packages/drawnix/src/components/startup/DrawnixDeferredFeatures.tsx
  • packages/drawnix/src/components/toolbar/creation-toolbar.tsx
  • packages/drawnix/src/components/toolbar/quick-creation-toolbar/quick-creation-toolbar.tsx
  • packages/drawnix/src/components/media-library/MediaLibraryModal.tsx
  • packages/drawnix/src/components/media-library/MediaLibraryInspector.tsx
  • packages/drawnix/src/components/toolbar/toolbar.types.ts
  • packages/drawnix/src/types/asset.types.ts

七、一句话结论

素材库“能打开”不等于“能插入”。
对画布入口来说,真正重要的是:入口语义要带到弹窗里,异步插入要被用户看见。🎯

八、画布多文件拖拽插入经验

更新日期2026-04-27

1. 问题现象

用户希望一次拖多个图片/视频进入画布。第一轮修复后,多文件可以插入,但当原画布内容很多、画布很大时,拖入完成后视口会偏移,新增内容不在当前可见区域。

这类问题表面是“插入位置不对”,本质是两个坐标系统同时变化:

  • Drop 事件发生时,鼠标位置来自屏幕/DOM 坐标。
  • Plait 插入元素后,画布 viewBox 可能因为内容边界扩大而重算。
  • 如果只在插入前计算一次 toViewBoxPoint,插入后 DOM scroll 和 viewport origination 可能不再对应,用户就会被带到别的位置。

2. 设计原则

  1. 批量拖拽不是把 files[0] 改成循环这么简单。

    • 图片、视频、音频要走各自已有插入链路。
    • 单张图片拖到 mind node 时,应保留“替换节点图片”的旧语义。
    • 多文件拖拽时,不应让多个文件抢同一个节点,应统一按坐标插入画布。
  2. 大文件批量处理要顺序执行。

    • 不要对多个视频同时 Promise.all 存储、解码或读元数据。
    • 图片加载、视频缓存、音频封面读取都可能触发大内存占用。
    • 高并发文件处理场景下,宁可略慢,也要避免内存峰值和交换分区压力。
  3. 本地视频类型要同时看 MIME 和扩展名。

    • macOS/浏览器可能把 .mov 标成 video/quicktime
    • 有些拖入来源可能不给 file.type
    • 类型兜底应集中在 data/blob.ts,不要让各入口各写一份扩展名判断。
  4. 批量插入后要锚定拖放点。

    • 记录 drop 时鼠标下的画布坐标 point
    • 同时记录鼠标在 board 容器内的屏幕偏移 activePoint
    • 插入完成并等待 Plait 完成一轮/两轮渲染后,用 BoardTransforms.updateViewport 恢复:
const origination: [number, number] = [
  point[0] - activePoint[0] / zoom,
  point[1] - activePoint[1] / zoom,
];
BoardTransforms.updateViewport(board, origination, zoom);

这样无论 viewBox 怎么重算,用户鼠标落下的画布点都会回到原来的屏幕位置,新增内容仍在可见区域。

3. 实现要点

  • with-image.tsx 的 drop 入口应先把 FileList 过滤成结构化的 DroppedMediaFile[],而不是在循环里散落判断。
  • 批量坐标使用固定网格偏移,避免所有文件堆在同一点。
  • 视频文件先进入 assetStorageService,再复用 insertVideoFromUrl,不要新增一套视频元素结构。
  • 当浏览器没有提供正确 MIME 时,可通过 file.slice(0, file.size, resolvedMimeType) 生成带类型的 Blob保证 Cache Storage 元数据和 Response header 正确。
  • 图片插入函数可增加 skipScroll 参数,批量拖拽由上层统一控制视口,避免每张图自己滚动。
  • 视口恢复应使用 updateViewport 一步设置 origination + zoom,不要拆成滚动 DOM 或 updateZoom + moveToCenter

4. 验证建议

  1. 精确单测:
pnpm --dir packages/drawnix exec vitest run src/data/blob.test.ts
  1. 类型检查:
pnpm nx run drawnix:typecheck
  1. 手动回归:
  • 大画布、已有大量元素、滚动到远离原点的位置。
  • 一次拖入 2 张以上图片,新增内容应出现在 drop 点附近且可见。
  • 一次拖入图片 + .mov / .mp4 视频,视频应进入画布并保留可播放语义。
  • 单张图片拖到 mind node 上,仍应替换该节点图片。

5. 涉及文件

  • packages/drawnix/src/plugins/with-image.tsx
  • packages/drawnix/src/data/blob.ts
  • packages/drawnix/src/data/image.ts
  • packages/drawnix/src/data/video.ts
  • packages/drawnix/src/services/asset-storage-service.ts
  • packages/drawnix/src/constants/ASSET_CONSTANTS.ts

6. 一句话结论

画布文件拖拽要同时处理“文件批量语义”和“视口锚定语义”。 能插入多个文件只是第一步,真正稳定的体验是:插入完成后,用户仍然停在自己放手的位置。📍