@mx-sose-front/mx-sose-graph 1.1.4 → 1.1.6

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.
package/src/utils/drag.ts CHANGED
@@ -1,4 +1,4 @@
1
- // utils/drag.ts
1
+ // utils/drag.ts
2
2
  import type { Shape } from '../types'
3
3
  import { getBounds, getDiagramRect, clampPointToRect } from './geom'
4
4
  import { useGraphStore } from '../store/graphStore'
@@ -31,9 +31,11 @@ export const buildDragSnapshot = (
31
31
  shapes: Shape[],
32
32
  ids: string[],
33
33
  ) => {
34
+ const graphStore = useGraphStore();
35
+ const byId = graphStore.shapeMap;
34
36
  const dragBase: Record<string, Rect> = {}
35
37
  ids.forEach(id => {
36
- const s = shapes.find(x => x.id === id); if (!s) return
38
+ const s = byId.get(id); if (!s) return
37
39
  const b = getBounds(s)
38
40
  dragBase[id] = { x: b.x, y: b.y, width: b.width, height: b.height }
39
41
  })
@@ -61,7 +63,8 @@ export const stepSingleDrag = (
61
63
  constrainEdges: { left?: boolean; top?: boolean; right?: boolean; bottom?: boolean },
62
64
  hitPointer?: { x: number; y: number },
63
65
  ) => {
64
- const s = shapes.find(x => x.id === id); if (!s) return { ghost: {}, hover: null }
66
+ const graphStore = useGraphStore();
67
+ const s = graphStore.shapeMap.get(id); if (!s) return { ghost: {}, hover: null }
65
68
  const base = dragBase[id]; if (!base) return { ghost: {}, hover: null }
66
69
  const container = getDiagramRect(shapes)
67
70
 
@@ -101,6 +104,8 @@ export const stepGroupDrag = (
101
104
  dragAnchor: { x: number; y: number },
102
105
  groupBaseBox: Rect,
103
106
  ) => {
107
+ const graphStore = useGraphStore();
108
+ const byId = graphStore.shapeMap;
104
109
  const diagramRect = getDiagramRect(shapes)
105
110
  const dxRaw = pointer.x - dragAnchor.x
106
111
  const dyRaw = pointer.y - dragAnchor.y
@@ -120,14 +125,14 @@ export const stepGroupDrag = (
120
125
  // 构建整组 ghost
121
126
  const ghost: Record<string, Rect> = {}
122
127
  for (const id of draggingIds) {
123
- const s = shapes.find(x => x.id === id); if (!s) continue
128
+ const s = byId.get(id); if (!s) continue
124
129
  const b = getBounds(s)
125
130
  ghost[id] = { x: b.x + dx, y: b.y + dy, width: b.width, height: b.height }
126
131
  }
127
132
 
128
133
  // —— hover 统一改为“指针命中 topmost 容器”
129
134
  // 用主拖动项获取 diagramId(也可以存你当前图的 id)
130
- const priShape = shapes.find(x => x.id === primaryId)
135
+ const priShape = byId.get(primaryId)
131
136
  const diagramId = priShape?.diagramId || ''
132
137
 
133
138
  const hoverShape = pickContainerByPointerTopmost(
@@ -155,9 +160,22 @@ export const commitDrag = (
155
160
  updateShape: (id: string, updates: Partial<Shape>) => void,
156
161
  baseMap?: Record<string, Rect>
157
162
  ) => {
163
+ const graphStore = useGraphStore();
164
+ const parentChildMap = graphStore.parentChildMap;
158
165
  const changedIds = new Set<string>()
159
166
 
160
- // 仅把“参与拖拽的节点”的 ghost 写回;不处理父子关系,也不扩父或夹回
167
+ // 递归收集所有后代 ID
168
+ const collectAllDescendants = (parentId: string): string[] => {
169
+ const result: string[] = []
170
+ const children = parentChildMap.get(parentId) || []
171
+ for (const childId of children) {
172
+ result.push(childId)
173
+ result.push(...collectAllDescendants(childId))
174
+ }
175
+ return result
176
+ }
177
+
178
+ // 仅把"参与拖拽的节点"的 ghost 写回;不处理父子关系,也不扩父或夹回
161
179
  for (const id of draggingIds) {
162
180
  const s = shapes.find(x => x.id === id)
163
181
  const r = ghost[id] // 参与拖拽的 id 都应当在 ghost 里
@@ -172,12 +190,34 @@ export const commitDrag = (
172
190
  }
173
191
 
174
192
  const pb = (baseMap?.[id] ?? (s.bounds as Rect))
175
- const same =
176
- pb.x === nb.x && pb.y === nb.y && pb.width === nb.width && pb.height === nb.height
193
+ const dx = nb.x - pb.x
194
+ const dy = nb.y - pb.y
195
+ const same = dx === 0 && dy === 0 && nb.width === pb.width && nb.height === pb.height
177
196
 
178
197
  if (!same) {
179
198
  updateShape(id, { bounds: nb })
180
199
  changedIds.add(id)
200
+
201
+ // 性能优化:同步移动所有后代元素(只在父元素位置变化时)
202
+ if (dx !== 0 || dy !== 0) {
203
+ const descendants = collectAllDescendants(id)
204
+ for (const descId of descendants) {
205
+ // 跳过已经在 draggingIds 中的元素(避免重复处理)
206
+ if (draggingIds.includes(descId)) continue
207
+ const descShape = shapes.find(x => x.id === descId)
208
+ if (!descShape?.bounds) continue
209
+ const descBounds = descShape.bounds as Rect
210
+ updateShape(descId, {
211
+ bounds: {
212
+ x: Math.round(descBounds.x + dx),
213
+ y: Math.round(descBounds.y + dy),
214
+ width: descBounds.width,
215
+ height: descBounds.height,
216
+ }
217
+ })
218
+ changedIds.add(descId)
219
+ }
220
+ }
181
221
  }
182
222
  }
183
223
 
@@ -77,13 +77,14 @@ export async function applyReparentAndClone(options: {
77
77
  const graphStore = useGraphStore()
78
78
  let ownerPayload: OwnerPayload | null = null
79
79
  const clonedIds: string[] = []
80
- // 只在嵌套/脱离时刷新
81
- const parentsToRefresh = new Set<string>();
82
- const normPid = (pid: any): string | null => {
83
- const v = pid ?? null;
84
- return v === "" ? null : v;
85
- };
86
- for (const id of changedIds) {
80
+ // 只保留顶层变更:父也在 changedIds 中的,认为是后代(跳过克隆)
81
+ const changedSet = new Set(changedIds)
82
+ const topLevelChangedIds = changedIds.filter(id => {
83
+ const beforePid = prevParentById[id] ?? null
84
+ const afterPid = shapes.find(s => s.id === id)?.parenShapeId ?? null
85
+ return !(beforePid && changedSet.has(beforePid)) && !(afterPid && changedSet.has(afterPid))
86
+ })
87
+ for (const id of topLevelChangedIds) {
87
88
  const after = shapes.find(s => s.id === id)
88
89
  if (!after) continue
89
90
 
@@ -132,7 +133,6 @@ export async function applyReparentAndClone(options: {
132
133
  parenShapeId: prevParentById[id] || '',
133
134
  bounds: baseBounds ? { ...baseBounds } : dropBounds,
134
135
  })
135
-
136
136
  // 放进画布
137
137
  addShape(clonedShape)
138
138
  pendingNestedIds.value.push(newId)
@@ -144,7 +144,7 @@ export async function applyReparentAndClone(options: {
144
144
  })
145
145
 
146
146
  const cloneInStore = shapes.find(s => s.id === newId) || clonedShape
147
- const shapeDataForAdd: any = _.cloneDeep(cloneInStore)
147
+ const shapeDataForAdd: any = _.cloneDeep(cloneInStore)
148
148
  const payloadData: any = {
149
149
  coordinate: {
150
150
  clientX: dropBounds.x,
@@ -322,6 +322,7 @@ export const createOnNestDoneCallback = (options: {
322
322
  if (!expanded) return
323
323
  const afterParent = JSON.stringify(parent.bounds ?? {})
324
324
  if (afterParent == beforeParent) return
325
+ return { id: parent.id, bounds: afterParent }
325
326
  // 只更新当前嵌套的父图元(parent)
326
327
  // eventBus.emit('shape-size-update', [
327
328
  // { id: parent.id, bounds: afterParent },
@@ -31,11 +31,13 @@ const KEYBOARD_MOVE_DEBOUNCE_DELAY = 1000; // 1000ms 防抖延迟
31
31
  */
32
32
  const collectAffectedShapeIds = (graphStore: any, movedIds: Set<string>): Set<string> => {
33
33
  const affectedIds = new Set<string>();
34
+ const shapeMap = graphStore.shapeMap;
34
35
 
35
36
  movedIds.forEach(id => {
36
37
  affectedIds.add(id);
37
38
 
38
- const shape = graphStore.shapes.find((s: any) => s.id === id);
39
+ // 使用 shapeMap O(1) 查找
40
+ const shape = shapeMap.get(id);
39
41
  if (!shape) return;
40
42
 
41
43
  // 添加父元素
@@ -58,7 +60,8 @@ const collectAffectedShapeIds = (graphStore: any, movedIds: Set<string>): Set<st
58
60
  let guard = 0;
59
61
  while (currentParentId && guard++ < 100) {
60
62
  affectedIds.add(currentParentId);
61
- const parent = graphStore.shapes.find((s: any) => s.id === currentParentId);
63
+ // 使用 shapeMap O(1) 查找
64
+ const parent = shapeMap.get(currentParentId);
62
65
  currentParentId = parent?.parenShapeId;
63
66
  }
64
67
  });
@@ -74,10 +77,11 @@ const emitKeyboardMoveEnd = (graphStore: any) => {
74
77
 
75
78
  // 收集所有受影响的图元
76
79
  const affectedIds = collectAffectedShapeIds(graphStore, keyboardMovingShapeIds);
80
+ const shapeMap = graphStore.shapeMap;
77
81
 
78
- // 组装 payloads(深拷贝)
82
+ // 组装 payloads(深拷贝) - 使用 shapeMap O(1) 查找
79
83
  const payloads = Array.from(affectedIds)
80
- .map(id => graphStore.shapes.find((s: any) => s.id === id))
84
+ .map(id => shapeMap.get(id))
81
85
  .filter(Boolean)
82
86
  .map(s => _.cloneDeep(s));
83
87
 
@@ -286,8 +290,14 @@ export const createKeyboardHandler = (config: KeyboardConfig): KeyboardEventHand
286
290
  e.preventDefault();
287
291
  const step = 1; // 移动距离
288
292
 
289
- // 获取画布信息(diagram类型的shape
290
- const canvas = graphStore.shapes.find(shape => shape.shapeType === 'diagram');
293
+ // 获取画布信息(diagram类型的shape)- 遍历一次找 diagram
294
+ let canvas: any = null;
295
+ for (const shape of graphStore.shapes) {
296
+ if (shape.shapeType === 'diagram') {
297
+ canvas = shape;
298
+ break;
299
+ }
300
+ }
291
301
  const canvasX = canvas?.bounds?.x || 0;
292
302
  const canvasY = canvas?.bounds?.y || 0;
293
303
  const canvasWidth = canvas?.bounds?.width || 300; // 最小画布宽度
@@ -299,10 +309,13 @@ export const createKeyboardHandler = (config: KeyboardConfig): KeyboardEventHand
299
309
  const canvasMaxX = canvasWidth + canvasX;
300
310
  const canvasMaxY = canvasHeight + canvasY;
301
311
 
302
- // 获取所有选中的图元
303
- const selectedShapes = graphStore.selectedIds
304
- .map(id => graphStore.shapes.find(s => s.id === id))
305
- .filter((shape): shape is NonNullable<typeof shape> => shape != null);
312
+ // 获取所有选中的图元 - 使用 shapeMap O(1) 查找
313
+ const shapeMap = graphStore.shapeMap;
314
+ const selectedShapes: any[] = [];
315
+ for (const id of graphStore.selectedIds) {
316
+ const shape = shapeMap.get(id);
317
+ if (shape) selectedShapes.push(shape);
318
+ }
306
319
 
307
320
  // ==================== 防抖机制:记录初始状态 ====================
308
321
  // 在第一次移动时记录初始bounds
@@ -362,7 +375,8 @@ export const createKeyboardHandler = (config: KeyboardConfig): KeyboardEventHand
362
375
  // 查找并计算所有子图元的新位置
363
376
  const childIds = graphStore.parentChildMap.get(shape.id) || [];
364
377
  childIds.forEach(childId => {
365
- const child = graphStore.shapes.find(s => s.id === childId);
378
+ // 使用 shapeMap O(1) 查找
379
+ const child = shapeMap.get(childId);
366
380
  if (child && child.bounds) {
367
381
  // 如果子图元也在选中列表中,跳过它
368
382
  // 因为它会在遍历 selectedShapes 时被单独处理
@@ -4,6 +4,15 @@
4
4
  export type ShapeId = string | number
5
5
  export type ShapeOp = "add" | "update" | "delete" | "upsert" | "replace"
6
6
 
7
+ /**
8
+ * 变更详情,用于增量更新外部索引
9
+ */
10
+ export interface ShapeChangeDetail<Shape> {
11
+ type: 'add' | 'update' | 'delete'
12
+ shape: Shape
13
+ oldShape?: Shape // update 时提供旧值,用于检测父节点变更
14
+ }
15
+
7
16
  /**
8
17
  * 创建一个图元操作器(每个操作器维护自己的 indexMap)
9
18
  * 推荐在每个 store 实例里创建一次并复用(不要每次调用都 new)
@@ -11,19 +20,47 @@ export type ShapeOp = "add" | "update" | "delete" | "upsert" | "replace"
11
20
  export function createShapeOperator<Shape extends { id: ShapeId }>() {
12
21
  const indexMap = new Map<ShapeId, number>()
13
22
  /**
14
- * 重建索引
15
- */
16
- const resetIndex = (list: Shape[]) => {
23
+ * 重建索引 + 去重(保留最后出现的同 id)
24
+ * 说明:
25
+ * - 从尾到头遍历,遇到重复 id 就删掉前面的(更早的那条)
26
+ * - 最终 list 中保证每个 id 只剩 1 条
27
+ */
28
+ const rebuildIndexAndDedupe = (list: Shape[]) => {
29
+ const seen = new Set<ShapeId>()
30
+
31
+ // 先去重:保留最后一条
32
+ for (let i = list.length - 1; i >= 0; i--) {
33
+ const id = list[i]?.id
34
+ if (id == null) continue
35
+ if (seen.has(id)) {
36
+ list.splice(i, 1) // 删除更早的重复项
37
+ } else {
38
+ seen.add(id)
39
+ }
40
+ }
41
+
42
+ // 再重建索引
17
43
  indexMap.clear()
18
44
  for (let i = 0; i < list.length; i++) {
19
45
  indexMap.set(list[i].id, i)
20
46
  }
21
47
  }
48
+ /**
49
+ * 确保索引可用:
50
+ * - 不仅检查 size,还校验 indexMap 指向的元素是否真的匹配 id
51
+ * - 一旦发现错位/重复,直接 rebuild
52
+ */
22
53
  const ensureIndex = (list: Shape[]) => {
23
- if (indexMap.size === list.length) return
24
- indexMap.clear()
25
- for (let i = 0; i < list.length; i++) {
26
- indexMap.set(list[i].id, i)
54
+ if (indexMap.size !== list.length) {
55
+ rebuildIndexAndDedupe(list)
56
+ return
57
+ }
58
+ // 校验映射是否仍然正确(防止 sort/reorder 导致错位)
59
+ for (const [id, idx] of indexMap) {
60
+ if (list[idx]?.id !== id) {
61
+ rebuildIndexAndDedupe(list)
62
+ return
63
+ }
27
64
  }
28
65
  }
29
66
 
@@ -41,13 +78,15 @@ export function createShapeOperator<Shape extends { id: ShapeId }>() {
41
78
  return ids
42
79
  }
43
80
 
44
- /** O(1) 删除:swap last + pop(会改变数组顺序) */
45
- const removeByIdFast = (list: Shape[], id: ShapeId) => {
81
+ /** O(1) 删除:swap last + pop(会改变数组顺序),返回被删除的元素 */
82
+ const removeByIdFast = (list: Shape[], id: ShapeId): Shape | null => {
46
83
  const idx = indexMap.get(id)
47
- if (idx == null) return false
84
+ if (idx == null) return null
48
85
 
49
86
  const lastIdx = list.length - 1
50
- if (lastIdx < 0) return false
87
+ if (lastIdx < 0) return null
88
+
89
+ const removed = list[idx]
51
90
 
52
91
  if (idx !== lastIdx) {
53
92
  const moved = list[lastIdx]
@@ -57,52 +96,57 @@ export function createShapeOperator<Shape extends { id: ShapeId }>() {
57
96
 
58
97
  list.pop()
59
98
  indexMap.delete(id)
60
- return true
99
+ return removed
61
100
  }
62
101
 
63
102
  /** upsert:有则整条替换,无则 push 新增 */
64
- const upsertOne = (list: Shape[], incoming: Shape) => {
103
+ const upsertOne = (list: Shape[], incoming: Shape): { changed: boolean; isAdd: boolean; oldShape?: Shape } => {
65
104
  const id = incoming?.id
66
- if (id == null) return false
105
+ if (id == null) return { changed: false, isAdd: false }
67
106
 
68
107
  const idx = indexMap.get(id)
69
108
  if (idx == null) {
70
109
  list.push(incoming)
71
110
  indexMap.set(id, list.length - 1)
111
+ return { changed: true, isAdd: true }
72
112
  } else {
113
+ const oldShape = list[idx]
73
114
  list[idx] = incoming
115
+ return { changed: true, isAdd: false, oldShape }
74
116
  }
75
- return true
76
117
  }
77
118
 
78
119
  /** update:仅存在才替换 */
79
- const updateOne = (list: Shape[], incoming: Shape) => {
120
+ const updateOne = (list: Shape[], incoming: Shape): { changed: boolean; oldShape?: Shape } => {
80
121
  const id = incoming?.id
81
- if (id == null) return false
122
+ if (id == null) return { changed: false }
82
123
 
83
124
  const idx = indexMap.get(id)
84
- if (idx == null) return false
125
+ if (idx == null) return { changed: false }
85
126
 
127
+ const oldShape = list[idx]
86
128
  list[idx] = incoming
87
- return true
129
+ return { changed: true, oldShape }
88
130
  }
89
131
 
90
132
  /**
91
133
  * add:不存在才新增
92
- * - 已存在时选择“替换”保证最终一致
134
+ * - 已存在时选择"替换"保证最终一致
93
135
  */
94
- const addOne = (list: Shape[], incoming: Shape) => {
136
+ const addOne = (list: Shape[], incoming: Shape): { changed: boolean; isAdd: boolean; oldShape?: Shape } => {
95
137
  const id = incoming?.id
96
- if (id == null) return false
138
+ if (id == null) return { changed: false, isAdd: false }
97
139
 
98
140
  const idx = indexMap.get(id)
99
141
  if (idx == null) {
100
142
  list.push(incoming)
101
143
  indexMap.set(id, list.length - 1)
144
+ return { changed: true, isAdd: true }
102
145
  } else {
146
+ const oldShape = list[idx]
103
147
  list[idx] = incoming
148
+ return { changed: true, isAdd: false, oldShape }
104
149
  }
105
- return true
106
150
  }
107
151
 
108
152
  /**
@@ -112,8 +156,9 @@ export function createShapeOperator<Shape extends { id: ShapeId }>() {
112
156
  list: Shape[]
113
157
  payload: Shape[] | ShapeId[]
114
158
  op: ShapeOp
115
- }) => {
159
+ }): { changedShapes: Shape[]; changeDetails: ShapeChangeDetail<Shape>[] } => {
116
160
  const { list, payload, op } = args
161
+ const changeDetails: ShapeChangeDetail<Shape>[] = []
117
162
 
118
163
  // ==================== replace:全量覆盖(就地覆盖数组内容) ====================
119
164
  if (op === "replace") {
@@ -130,8 +175,8 @@ export function createShapeOperator<Shape extends { id: ShapeId }>() {
130
175
  for (let i = 0; i < list.length; i++) {
131
176
  indexMap.set(list[i].id, i)
132
177
  }
133
- // replace 场景:可以认为所有都是“变更”,直接返回整批(方便 autoExpand)
134
- return { changedShapes: incomingShapes }
178
+ // replace 场景:返回空的 changeDetails,调用方应该全量重建索引
179
+ return { changedShapes: incomingShapes, changeDetails: [] }
135
180
  }
136
181
  // 轻量保证索引可用
137
182
  ensureIndex(list)
@@ -141,9 +186,12 @@ export function createShapeOperator<Shape extends { id: ShapeId }>() {
141
186
  if (op === "delete") {
142
187
  const ids = extractIds(payload as any)
143
188
  for (const id of ids) {
144
- removeByIdFast(list, id)
189
+ const removed = removeByIdFast(list, id)
190
+ if (removed) {
191
+ changeDetails.push({ type: 'delete', shape: removed })
192
+ }
145
193
  }
146
- return { changedShapes }
194
+ return { changedShapes, changeDetails }
147
195
  }
148
196
 
149
197
  const incomingShapes = payload as Shape[]
@@ -151,15 +199,39 @@ export function createShapeOperator<Shape extends { id: ShapeId }>() {
151
199
  if (!incoming) continue
152
200
 
153
201
  if (op === "upsert") {
154
- if (upsertOne(list, incoming)) changedShapes.push(incoming)
202
+ const result = upsertOne(list, incoming)
203
+ if (result.changed) {
204
+ changedShapes.push(incoming)
205
+ changeDetails.push({
206
+ type: result.isAdd ? 'add' : 'update',
207
+ shape: incoming,
208
+ oldShape: result.oldShape
209
+ })
210
+ }
155
211
  } else if (op === "update") {
156
- if (updateOne(list, incoming)) changedShapes.push(incoming)
212
+ const result = updateOne(list, incoming)
213
+ if (result.changed) {
214
+ changedShapes.push(incoming)
215
+ changeDetails.push({
216
+ type: 'update',
217
+ shape: incoming,
218
+ oldShape: result.oldShape
219
+ })
220
+ }
157
221
  } else if (op === "add") {
158
- if (addOne(list, incoming)) changedShapes.push(incoming)
222
+ const result = addOne(list, incoming)
223
+ if (result.changed) {
224
+ changedShapes.push(incoming)
225
+ changeDetails.push({
226
+ type: result.isAdd ? 'add' : 'update',
227
+ shape: incoming,
228
+ oldShape: result.oldShape
229
+ })
230
+ }
159
231
  }
160
232
  }
161
233
 
162
- return { changedShapes }
234
+ return { changedShapes, changeDetails }
163
235
  }
164
236
 
165
237
  return {
@@ -360,7 +360,8 @@ const updateShapes = (shapes: Shape[]) => {
360
360
  })
361
361
  clearEdgeStyleCache() // 批量更新时清空 edge 样式缓存,避免旧数据残留
362
362
  // graphStore.updateShapes(shapes)
363
- graphStore.shapes=[]
363
+ // 使用 clearAll 清空数据和索引,然后用 replace 重新加载
364
+ graphStore.clearAll()
364
365
  graphStore.updateShapes(shapes, 'replace');
365
366
  // 在图形批量更新后(通常是从后端加载数据),初始化所有连线的端点
366
367
  // 确保连线不会横跨图元,而是从合适的位置连接
@@ -388,7 +389,8 @@ function sweepShapesWithInert() {
388
389
  let removed = 0
389
390
  for (let i = arr.length - 1; i >= 0; i--) {
390
391
  if ('inert' in arr[i]) {
391
- arr.splice(i, 1) // 直接删,避免 removeShape 的事件/副作用导致回填
392
+ // 使用 removeShape 确保索引同步
393
+ graphStore.removeShape(arr[i].id)
392
394
  removed++
393
395
  }
394
396
  }