304 lines
9.9 KiB
Markdown
304 lines
9.9 KiB
Markdown
# 素材库插入画布经验总结
|
||
|
||
这份文档沉淀 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)`
|
||
|
||
至少要能带:
|
||
|
||
- 模式
|
||
- 回调
|
||
- 文案
|
||
- 筛选条件
|
||
|
||
### 规则 2:`BROWSE` 和 `SELECT` 是两种产品态,不是一个小参数
|
||
|
||
`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` 恢复:
|
||
|
||
```typescript
|
||
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. 精确单测:
|
||
|
||
```bash
|
||
pnpm --dir packages/drawnix exec vitest run src/data/blob.test.ts
|
||
```
|
||
|
||
2. 类型检查:
|
||
|
||
```bash
|
||
pnpm nx run drawnix:typecheck
|
||
```
|
||
|
||
3. 手动回归:
|
||
|
||
- 大画布、已有大量元素、滚动到远离原点的位置。
|
||
- 一次拖入 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. 一句话结论
|
||
|
||
画布文件拖拽要同时处理“文件批量语义”和“视口锚定语义”。
|
||
能插入多个文件只是第一步,真正稳定的体验是:插入完成后,用户仍然停在自己放手的位置。📍
|