drawui-core 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ # 图片上传通道 · 集成指南
2
+
3
+ > 面向接入方(队友 / 业务方)。用于把 drawui 的「图片」按钮、粘贴、拖入文件统一接到自家上传服务(OSS / 自建后端 / 其它对象存储)。
4
+
5
+ ## 〇、这份文档在哪
6
+
7
+ | 场景 | 路径 |
8
+ | --- | --- |
9
+ | 本仓库(真源,改动以此为准) | `packages/core/IMAGE_UPLOAD.md` |
10
+ | 本仓库(同步副本) | `packages/react/IMAGE_UPLOAD.md` |
11
+ | 安装依赖后 | `node_modules/drawui-core/IMAGE_UPLOAD.md` / `node_modules/drawui-react/IMAGE_UPLOAD.md` |
12
+ | 在线 | <https://github.com/weishell/drawui/blob/main/packages/core/IMAGE_UPLOAD.md> |
13
+
14
+ > 两个 npm 包均已把该文档列入 `files`,`README.md` 里的链接使用的是包内相对路径,安装后同样点得开。
15
+
16
+ ## 一、能力定位
17
+
18
+ - **drawui 不内置任何对象存储 SDK**(不依赖 `ali-oss`、不预置 STS、不暴露任何 accessKey)。
19
+ - SDK 只负责「选文件 → 拿到一个 URL → 在画布上渲染图片」,至于 URL 从哪儿来,完全由接入方决定。
20
+ - 提供 **两条通道**,二选一或并存:
21
+
22
+ | 通道 | 形式 | 适用 |
23
+ | --- | --- | --- |
24
+ | **A. `ImageUploadConfig`** | 配 `{ url, method, fieldName, headers, fields, responseUrlPath, timeoutMs, maxSizeBytes }` | 队友有自己的中转接口(前端 → 队友后端 → OSS) |
25
+ | **B. `ImageUploader` 函数** | `(file: File) => Promise<string>`,函数内自行拿到 STS、签名直传 OSS | 前端直传 OSS / 自定义分片 / 自定义压缩 |
26
+
27
+ **优先级 B > A**(函数优先于配置)。
28
+
29
+ ## 二、硬性约定(已默认开启,接入方不需要写)
30
+
31
+ | 项 | 默认值 | 覆盖方式 |
32
+ | --- | --- | --- |
33
+ | **单张图片体积上限** | **30 MB**(超过直接拒绝,不会发出请求) | `ImageUploadConfig.maxSizeBytes` 自定义;写 `ImageUploader` 函数时需自己检查 |
34
+ | **上传超时** | **60 秒**(之前是 30 秒,已调大) | `ImageUploadConfig.timeoutMs` 自定义 |
35
+ | **失败重试** | 不重试,抛错后由调用方提示用户 | 自定义 `ImageUploader` 时自己实现 |
36
+ | **请求并发控制** | 不限制,多文件并发上传 | 自定义 `ImageUploader` 时自己实现 |
37
+ | **跨域** | 远端图默认 `crossOrigin='anonymous'` | `setImageCrossOrigin(null)` 关闭(注意会污染画布导致 PNG 导出失败) |
38
+
39
+ > 体积超限走错误码 `image.tooLarge:`(前端 toast 已自动用 `image.tooLarge` 文案显示)。
40
+
41
+ ## 三、怎么调(按场景)
42
+
43
+ ### 场景 1:队友有自己的上传中转接口(最常见)
44
+
45
+ ```tsx
46
+ import { CanvasBoard } from 'drawui-react'
47
+
48
+ <CanvasBoard
49
+ imageUpload={{
50
+ url: 'https://api.team.com/upload',
51
+ method: 'POST',
52
+ fieldName: 'file', // 后端接收字段名
53
+ headers: { Authorization: `Bearer ${token}` },
54
+ // responseUrlPath: 'data.url', // 响应 JSON 里 url 的路径;不填自动探测
55
+ // timeoutMs: 60000, // 默认 60s
56
+ // maxSizeBytes: 30 * 1024 * 1024, // 默认 30 MB
57
+ }}
58
+ />
59
+ ```
60
+
61
+ 后端返回任一形式即可(自动探测顺序:`url` → `data.url` → `data` → `data.src` → `location` → `path` → `src` → `href` → `fileUrl` → `imageUrl` → `data.fileUrl`):
62
+
63
+ ```jsonc
64
+ // 推荐:直接给 url
65
+ { "url": "https://cdn.example.com/foo.png" }
66
+
67
+ // 或者套了一层
68
+ { "code": 0, "data": { "url": "https://cdn.example.com/foo.png" } }
69
+ ```
70
+
71
+ 如果自动探测拿不准,**强烈建议显式配 `responseUrlPath: 'data.url'`**,避免 JSON 里其它字段(如 `userUrl`)被误识别。
72
+
73
+ ### 场景 2:前端直传 OSS(拿 STS 后 PutObject)
74
+
75
+ **必须用 `ImageUploader` 函数,不要用 `ImageUploadConfig.headers` 试图塞 OSS 签名 —— 浏览器会先发 CORS 预检,而 OSS 默认不回 `x-oss-*` 自定义头的预检响应。**
76
+
77
+ ```tsx
78
+ import OSS from 'ali-oss'
79
+ import { CanvasBoard } from 'drawui-react'
80
+
81
+ async function uploadToOSS(file: File): Promise<string> {
82
+ // 1. 跟你们后端拿 STS 临时凭证
83
+ const sts = await fetch('/api/oss/sts', { credentials: 'include' }).then(r => r.json())
84
+ // 期望字段:accessKeyId / accessKeySecret / securityToken / region / bucket / host / dir
85
+ const { accessKeyId, accessKeySecret, securityToken, region, bucket, host, dir } = sts
86
+
87
+ // 2. 客户端压缩一下,避免撞 60s 超时
88
+ const compressed = await compressIfNeeded(file, 30 * 1024 * 1024)
89
+
90
+ // 3. 直传 OSS
91
+ const oss = new OSS({
92
+ accessKeyId, accessKeySecret, stsToken: securityToken,
93
+ bucket, region, secure: true,
94
+ })
95
+ const filename = `${dir}/${Date.now()}_${compressed.name}`
96
+ await oss.put(filename, compressed, {
97
+ headers: { 'x-oss-object-acl': 'public-read' },
98
+ })
99
+
100
+ // 4. 必须返回公网可访问的 URL
101
+ return `${host}/${filename}`
102
+ }
103
+
104
+ <CanvasBoard imageUpload={uploadToOSS} />
105
+ ```
106
+
107
+ > STS 接口必须保证返回的 `host` + 文件路径能在浏览器里直接 GET 到,并且对应 OSS 桶回 **正确的 CORS 头**。
108
+
109
+ ### 场景 3:暂未接入上传服务
110
+
111
+ 不传 `imageUpload` 也能用 —— SDK 会走 `URL.createObjectURL(file)` 本地兜底,图片以 **`temporary: true`** 标记。**但**:刷新页面图片即丢失。SDK 会自动弹「已插入临时图片(未配置上传接口,刷新后会丢失)」toast 提示用户。
112
+
113
+ 接入方在保存数据前可调用 `editor.hasTemporaryImages()` 检测并提示用户:
114
+
115
+ ```ts
116
+ if (editor.hasTemporaryImages()) {
117
+ // 让用户先上传完再保存,或者保存时提醒刷新会丢
118
+ }
119
+ ```
120
+
121
+ ## 四、调用链速览(接入方排查用)
122
+
123
+ ```
124
+ Toolbar「图片」按钮 ─► onInsertImage(files)
125
+ │
126
+ ▼
127
+ CanvasBoard (imageUpload?: ImageUploadOption)
128
+ │
129
+ ▼
130
+ editor.insertImageFiles(files, at)
131
+ │
132
+ ▼
133
+ Editor.insertImageFile(file, at) ← packages/core/src/editor/Editor.ts
134
+ │
135
+ ┌─────────┴─────────┐
136
+ 配了 imageUploadOption 没配(默认)
137
+ │ │
138
+ ▼ ▼
139
+ runUploader(option, file) URL.createObjectURL(file)
140
+ ─► 拿远端 URL ─► blob:xxx(temporary:true)
141
+ │ │
142
+ └─────────┬───────────┘
143
+ ▼
144
+ loadImage(src) → ImageShape
145
+ ▼
146
+ src 入画布(不是 base64)
147
+ ```
148
+
149
+ 三条触发路径都走同一通道:
150
+
151
+ | 入口 | 代码位置(按符号定位,行号会漂移) |
152
+ | --- | --- |
153
+ | 工具栏「图片」按钮 | `Toolbar.tsx` → `handleImagePicked`(隐藏 `<input type="file">` 的 `onChange`) |
154
+ | 拖拽图片到画布 | `CanvasBoard.tsx` → `onDrop` → `editor.insertImageFiles(dropped, at)` |
155
+ | 粘贴图片 | `CanvasBoard.tsx` → 粘贴处理 → `editor.insertImageFiles(files)`(失败经 `imageError` UI 通道回流) |
156
+
157
+ ## 五、接 OSS 时最容易踩的 10 个坑
158
+
159
+ | # | 现象 | 根因 / 处理 |
160
+ | --- | --- | --- |
161
+ | 1 | 上传完图裂了 | OSS 桶没回 CORS 头。检查桶的「跨域设置」:允许源 `*`(或具体域名)、允许方法 `GET/HEAD/PUT/POST`、允许头 `*` |
162
+ | 2 | `ImageUploadConfig.headers` 加 `Authorization` 后浏览器报错 | 浏览器会先发 CORS 预检,队友接口没回 `Access-Control-Allow-Headers`。要么后端放开,要么改走 `ImageUploader` 函数自己 fetch |
163
+ | 3 | 60 秒超时 | 大图直传。压缩后再上传,或调大 `timeoutMs: 180000` |
164
+ | 4 | 一次选 20 张图全失败 | 并发数太高被 OSS 限流。自定义 `ImageUploader` 内做并发限流(如 p-limit) |
165
+ | 5 | 上传报错后用户重试不了 | SDK 不内置重试,在 `ImageUploader` 内自己加 |
166
+ | 6 | 刷新后图全没了 | 拿到的是 `blob:` 本地地址或返回的 URL 不公网可访问。`editor.hasTemporaryImages()` 帮你在保存前检测 |
167
+ | 7 | 客户端工具菜单「另存为图片」导出 PNG 失败 | OSS 没配 CORS 头,canvas 被污染。用 `setImageCrossOrigin(null)` 可暂解,但会失去 PNG/SVG 导出能力 |
168
+ | 8 | 自动探测 URL 拿错了字段 | 返回 JSON 里恰好有非图片含义的 `url` 字段。**显式配 `responseUrlPath`** |
169
+ | 9 | 拖拽多个文件只插入一张 | `CanvasBoard.tsx` 的 `onDrop` 已过滤非图片。检查 `file.type` 是否正确 |
170
+ | 10 | 大于 30 MB 静默失败 | 不静默,会抛 `image.tooLarge:`,前端 toast 用 `image.tooLarge` 文案显式提示用户。要调大用 `maxSizeBytes` |
171
+
172
+ ## 六、自检 checklist(上线前过一遍)
173
+
174
+ - [ ] OSS 桶已配 CORS
175
+ - [ ] STS 接口走自家鉴权(不依赖前端 cookie)
176
+ - [ ] 返回的图片 URL 公网可访问,浏览器 GET 200
177
+ - [ ] 上传 > 30 MB 的图被拒绝(toast 提示用户)
178
+ - [ ] 上传 > 60s 的请求会超时(确认是自己调的 60s 还是后端更快超时)
179
+ - [ ] 上传完 → 刷新页面 → 图片还在(验证不是 `blob:`)
180
+ - [ ] 保存文档前调用 `editor.hasTemporaryImages()` 提示用户
181
+ - [ ] `image.tooLarge` 文案在你们支持的语言里都翻译到位(默认带 7 种语言,扩展见 `packages/core/src/i18n.ts`)
182
+
183
+ ## 七、相关导出(来自 `drawui-core`)
184
+
185
+ ```ts
186
+ import {
187
+ Editor,
188
+ DEFAULT_IMAGE_MAX_SIZE_BYTES, // 30 * 1024 * 1024
189
+ type ImageUploadOption, // 配置 | 函数,二选一
190
+ type ImageUploadConfig, // url / method / headers / responseUrlPath / timeoutMs / maxSizeBytes ...
191
+ type ImageUploader, // (file: File) => Promise<string>
192
+ isUploaderFunction,
193
+ parseUploadResponse, // 解析后端响应文本 → URL(业务方自实现上传函数时可复用)
194
+ } from 'drawui-core'
195
+ ```
196
+
197
+ 核心文件:
198
+
199
+ - `packages/core/src/imageUpload.ts`:上传通道定义 + fetch 实现
200
+ - `packages/core/src/editor/Editor.ts`:`insertImageFile` / `insertImageFiles`(带 30 MB 闸门)
201
+ - `packages/core/src/imageStore.ts`:图片加载、缓存、跨域处理
202
+ - `packages/react/src/components/CanvasBoard.tsx`:`imageUpload` prop + toast 文案
203
+ - `packages/core/src/i18n.ts`:多语言文案(含新增的 `image.tooLarge`)
package/README.md CHANGED
@@ -63,6 +63,31 @@ interface EditorOptions {
63
63
 
64
64
  ## 更新日志
65
65
 
66
+ ### v0.5.2
67
+
68
+ > 主题:**思维导图:节点升降层级 + 拖拽换层级**
69
+
70
+ - ⬆️ **节点「升层级」**:新增 `canPromoteMindMapNode(shapeId, nodeId, levels)` / `promoteMindMapNode(...)`,以及树工具 `promoteMindMapNodeInTree(root, nodeId, levels)` —— 把节点挂到它第 `levels + 1` 代祖先之下、紧挨旧祖先之后,**子树整体跟随上移**;门槛 `depth ≥ levels + 1`(根节点与一级子节点不可升)。
71
+ - 🖱️ **拖拽「换层级」**:新增 `moveMindMapNodeToParent(shapeId, nodeId, targetParentId, direction, insertIndex)` 与树工具 `moveMindMapNodeInTree(...)` —— 把节点移到任意目标父节点下,并按同方向兄弟组的索引落位。落点规则:拖到**另一个节点框内部** → 成为它的**子节点**;拖到**节点框外侧 48px 内** → 成为它的**同方向兄弟**;落在同一父级的兄弟之间 → 只重排序(原行为)。目标落在被拖节点自身子树内会被拒绝,子树不会被拆坏。
72
+ - 🟦 **落点提示升级**:`drawMindMapReorderIndicator` 支持 `childParentId` —— 给「落点框将来挂靠的父节点」描一圈虚线环,并画一条与真实父子连线同款(水平 `midX` / 垂直 `midY` 三次贝塞尔)的虚线接到占位框;目标还没有子节点时,占位框落在它生长方向的外侧。
73
+ - 🧷 **结构变化不再「整图平移」**:`layoutMindMap` 会把包围盒中心对齐到导图中心,因此树一变宽/变高,所有节点都会整体平移(换层级时整张图会「跳」一下)。现在结构变化后会按**根节点**在旧/新布局中的坐标差反向补偿导图中心,让根节点在屏幕上不动;覆盖升层级 / 拖拽换层级 / 增删节点 / 重排序 / 折叠 / 文本改动等全部重算布局的路径。
74
+ - 🫥 **拖拽阶段的 UI 事件**:新增 `UiEvent { kind: 'drag'; active: boolean }`,在移动 / 缩放 / 旋转 / 拖连线端点 / 拖线段 / 拉新连线期间为 `true`(手型平移不算);React 层据此在拖拽时隐藏工具栏与悬浮面板。
75
+ - 🌐 新增 4 条文案键:`mindmap.level` / `mindmap.promote1` / `mindmap.promote2` / `mindmap.promoteDisabled`(7 种语言)。
76
+ - 新增导出:`promoteMindMapNodeInTree`、`moveMindMapNodeInTree`。
77
+
78
+ ### v0.5.1
79
+
80
+ > 主题:**思维导图四方向布局与交互重做**
81
+
82
+ - 🧭 **`MindMapNode` 新增 `direction` 字段**(`'right' | 'left' | 'up' | 'down'`):根节点无方向,子节点继承父方向;`layoutMindMap` 按四方向布局,根节点把子节点按方向分组各自生长。
83
+ - 🧭 **根节点四方向加号**:`handlePosOf` 对根遍历 `MINDMAP_DIRECTIONS` 生成四个 child 加号;非根节点 = 自身方向 child 加号 + 垂直方向 sibling 加号。
84
+ - 🔧 **连线按子节点方向绘制**:`drawMindMapConnections` 与导出 `mindMapSvg` 改用 `to.direction` 取四方向边缘 + 同构贝塞尔曲线,修复此前根节点的 left/up/down 子节点连线从右侧斜穿的 bug。
85
+ - ⚖️ **根节点一级子节点内边对齐**:布局改用孩子自身尺寸定位(`childSize`),一级孩子朝根内边严格对齐,子树尺寸不同也不会错开。
86
+ - 🔒 **拖拽 = 同方向重排序**(非自由拖):`getMindMapNodeSiblings` + `reorderMindMapNodeInTree`,跨方向互不交换;`calcMindMapReorderTarget` 排除被拖节点自身,需拖过邻节点中心才换位。
87
+ - 🟦 **重排落点预览**:`drawMindMapReorderIndicator` 改为与被拖节点等大的虚线圆角占位框。
88
+ - ✋ **点加号不进编辑态**:`addMindMapNodeFromHandle` 不再调 `beginMindMapEdit`,新节点仅成为活动节点 `mindMapSelectedNode`。
89
+ - 新增导出:`MINDMAP_DIRECTIONS`、`effectiveDirection`、`isHorizontalDirection`、`getMindMapNodeSiblings`、`reorderMindMapNodeInTree`、`MindMapDirection`(类型)。
90
+
66
91
  ### v0.5.0
67
92
 
68
93
  > 主题:**图片上传通道**(OSS / 自建后端友好)
@@ -249,6 +274,31 @@ interface EditorOptions {
249
274
 
250
275
  ## Changelog
251
276
 
277
+ ### v0.5.2
278
+
279
+ > Theme: **Mind map: promote/demote a node's level + drag to re-parent**
280
+
281
+ - ⬆️ **Promote a node's level**: new `canPromoteMindMapNode(shapeId, nodeId, levels)` / `promoteMindMapNode(...)`, plus the tree helper `promoteMindMapNodeInTree(root, nodeId, levels)` — re-parents a node under its `levels + 1`-th ancestor, right after its former ancestor, **moving the whole subtree with it**. Gate: `depth ≥ levels + 1` (the root and its direct children cannot be promoted).
282
+ - 🖱️ **Drag to re-parent**: new `moveMindMapNodeToParent(shapeId, nodeId, targetParentId, direction, insertIndex)` and the tree helper `moveMindMapNodeInTree(...)`. Drop rules: drop **inside** another node's box → become its **child**; drop **within 48 px outside** a node's box → become its **same-direction sibling**; drop between siblings of the same parent → plain reorder (unchanged). Dropping onto the dragged node's own subtree is rejected; subtrees are never broken apart.
283
+ - 🟦 **Better drop hints**: `drawMindMapReorderIndicator` now accepts `childParentId` — it outlines the node the placeholder will hang under with a dashed ring and draws a dashed bézier (the same `midX`/`midY` cubic used by real parent-child links) from that node to the placeholder. When the target has no children yet, the placeholder sits just outside it along the growth direction.
284
+ - 🧷 **Structural edits no longer shift the whole map**: `layoutMindMap` centers the bounding box on the board's center, so any change in tree width/height used to translate every node (the map visibly "jumped" when re-parenting). Structural edits now compensate the board center by the root's old/new position so **the root stays put on screen** — covering promote, drag-re-parent, add/delete node, reorder, collapse and text edits.
285
+ - 🫥 **UI event for drag phases**: new `UiEvent { kind: 'drag'; active: boolean }`, `true` while translating / resizing / rotating / dragging a connector endpoint or segment / pulling a new connector (hand panning excluded). The React layer uses it to hide toolbars and floating panels while dragging.
286
+ - 🌐 Four new message keys: `mindmap.level` / `mindmap.promote1` / `mindmap.promote2` / `mindmap.promoteDisabled` (7 languages).
287
+ - New exports: `promoteMindMapNodeInTree`, `moveMindMapNodeInTree`.
288
+
289
+ ### v0.5.1
290
+
291
+ > Theme: **Mind map four-direction layout & interaction rework**
292
+
293
+ - 🧭 **`MindMapNode` gains a `direction` field** (`'right' | 'left' | 'up' | 'down'`): the root has no direction; children inherit it from their parent. `layoutMindMap` groups the root's children by direction and grows each group independently.
294
+ - 🧭 **Root gets four-direction "+" handles**: `handlePosOf` walks `MINDMAP_DIRECTIONS` for the root; non-root nodes get one child handle (own direction) plus one sibling handle (perpendicular direction).
295
+ - 🔧 **Connections drawn by child direction**: `drawMindMapConnections` and the exported `mindMapSvg` now use `to.direction` for the four-direction edges with isomorphic bézier curves — fixes the previous bug where a root's `left`/`up`/`down` children were wired out from the right edge.
296
+ - ⚖️ **Root's first-level children align on their inner edge**: layout positions children by their own size, so the inner edge facing the root is strictly aligned even when subtrees differ in size.
297
+ - 🔒 **Drag = same-direction reordering** (no free drag): `getMindMapNodeSiblings` + `reorderMindMapNodeInTree`; cross-direction groups never swap. `calcMindMapReorderTarget` excludes the dragged node itself, so a swap only happens once the pointer passes a neighbour's center.
298
+ - 🟦 **Reorder drop preview**: `drawMindMapReorderIndicator` now draws a dashed rounded placeholder the same size as the dragged node.
299
+ - ✋ **Clicking "+" no longer enters text-edit**: `addMindMapNodeFromHandle` no longer calls `beginMindMapEdit`; the new node only becomes the active node (`mindMapSelectedNode`).
300
+ - New exports: `MINDMAP_DIRECTIONS`, `effectiveDirection`, `isHorizontalDirection`, `getMindMapNodeSiblings`, `reorderMindMapNodeInTree`, `MindMapDirection` (type).
301
+
252
302
  ### v0.5.0
253
303
 
254
304
  > Theme: **Image upload channel** (OSS / custom backend friendly)