@vvfx/sdk 0.2.7 → 0.2.9

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.
Files changed (108) hide show
  1. package/dist/config.js +231 -0
  2. package/dist/exporter/config.js +18 -0
  3. package/dist/exporter/const.js +71 -0
  4. package/dist/exporter/export-media.js +878 -0
  5. package/dist/exporter/exporter.js +121 -0
  6. package/dist/exporter/index.js +1 -0
  7. package/dist/exporter/types.js +1 -0
  8. package/dist/exporter/utils.js +190 -0
  9. package/dist/exporter/wav-audio.js +50 -0
  10. package/dist/gesture-handler/gizmo/adsorption-gizmo.js +227 -0
  11. package/dist/gesture-handler/gizmo/control-gizmo.js +149 -0
  12. package/dist/gesture-handler/gizmo/gizmo.js +17 -0
  13. package/dist/gesture-handler/gizmo/icon-gizmo.js +189 -0
  14. package/dist/gesture-handler/gizmo/index.js +6 -0
  15. package/dist/gesture-handler/gizmo/item-create-gizmo.js +256 -0
  16. package/dist/gesture-handler/gizmo/loading-gizmo.js +320 -0
  17. package/dist/gesture-handler/gizmo/mask-gizmo.js +648 -0
  18. package/dist/gesture-handler/gizmo/picture-cut-gizmo.js +550 -0
  19. package/dist/gesture-handler/gizmo/picture-expand-gizmo.js +491 -0
  20. package/dist/gesture-handler/gizmo/preference-gizmo.js +148 -0
  21. package/dist/gesture-handler/gizmo/selector-gizmo.d.ts +1 -1
  22. package/dist/gesture-handler/gizmo/selector-gizmo.js +468 -0
  23. package/dist/gesture-handler/gizmo/sprite-text-edit-gizmo.js +247 -0
  24. package/dist/gesture-handler/gizmo/text-gizmo.js +1033 -0
  25. package/dist/gesture-handler/gizmo/transform-gizmo.js +1199 -0
  26. package/dist/gesture-handler/gizmo/type.js +18 -0
  27. package/dist/gesture-handler/index.js +3 -0
  28. package/dist/gesture-handler/src/gesture-handler.js +770 -0
  29. package/dist/gesture-handler/src/icons.js +24 -0
  30. package/dist/gesture-handler/utils.js +40 -0
  31. package/dist/html-overlay/anchor-navigation-runtime.js +27 -0
  32. package/dist/html-overlay/auto-height-runtime.js +132 -0
  33. package/dist/html-overlay/document-runtime.js +71 -0
  34. package/dist/html-overlay/document-transform.js +96 -0
  35. package/dist/html-overlay/dom-order.js +8 -0
  36. package/dist/html-overlay/host-message-runtime.js +24 -0
  37. package/dist/html-overlay/html-capture-runtime.js +282 -0
  38. package/dist/html-overlay/html-viewport-style.js +10 -0
  39. package/dist/html-overlay/index.js +1 -0
  40. package/dist/html-overlay/manager.js +1663 -0
  41. package/dist/html-overlay/mime-utils.js +21 -0
  42. package/dist/html-overlay/overlay-transform.js +57 -0
  43. package/dist/html-overlay/path-utils.js +35 -0
  44. package/dist/html-overlay/shell-runtime.js +12 -0
  45. package/dist/index.cjs +1 -7
  46. package/dist/index.cjs.map +1 -1
  47. package/dist/index.js +13 -75317
  48. package/dist/index.mjs +75311 -0
  49. package/dist/index.mjs.map +1 -0
  50. package/dist/layer-order.js +8 -0
  51. package/dist/math/box2.js +396 -0
  52. package/dist/math/circle.js +131 -0
  53. package/dist/math/euler.js +35 -0
  54. package/dist/math/index.js +13 -0
  55. package/dist/math/line2.js +153 -0
  56. package/dist/math/line3.js +36 -0
  57. package/dist/math/matrix4.js +207 -0
  58. package/dist/math/plane.js +52 -0
  59. package/dist/math/quaternion.js +3 -0
  60. package/dist/math/ray-caster.js +25 -0
  61. package/dist/math/ray.js +47 -0
  62. package/dist/math/type.js +1 -0
  63. package/dist/math/utils.js +77 -0
  64. package/dist/math/vector2.js +70 -0
  65. package/dist/math/vector3.js +60 -0
  66. package/dist/screen-shot/index.js +1 -0
  67. package/dist/screen-shot/screen-shot.js +113 -0
  68. package/dist/sdk-item/base-item.js +160 -0
  69. package/dist/sdk-item/card-item.js +131 -0
  70. package/dist/sdk-item/effects-item.js +144 -0
  71. package/dist/sdk-item/frame-item.js +217 -0
  72. package/dist/sdk-item/generator-item.js +188 -0
  73. package/dist/sdk-item/group-item.js +116 -0
  74. package/dist/sdk-item/index.js +39 -0
  75. package/dist/sdk-item/sprite-item.js +147 -0
  76. package/dist/sdk-item/text-item.js +223 -0
  77. package/dist/sdk-item/types.d.ts +1 -11
  78. package/dist/sdk-item/types.js +15 -0
  79. package/dist/sdk-item/video-item.js +174 -0
  80. package/dist/sdk.js +1903 -0
  81. package/dist/service/UndoRedo.js +79 -0
  82. package/dist/shared/index.d.ts +1 -0
  83. package/dist/shared/index.js +2 -0
  84. package/dist/shared/player.d.ts +1 -1
  85. package/dist/shared/player.js +9 -0
  86. package/dist/shared/spec.d.ts +1 -0
  87. package/dist/shared/spec.js +1 -0
  88. package/dist/size-adapte/index.js +1 -0
  89. package/dist/size-adapte/size-adapt.js +395 -0
  90. package/dist/types.d.ts +5 -1
  91. package/dist/types.js +17 -0
  92. package/dist/utils/background-manager.js +189 -0
  93. package/dist/utils/common-utils.js +183 -0
  94. package/dist/utils/index.js +8 -0
  95. package/dist/utils/interaction-utils.js +205 -0
  96. package/dist/utils/json-data-utils.js +1529 -0
  97. package/dist/utils/layout-utils.js +1217 -0
  98. package/dist/utils/page-data-utils.js +5397 -0
  99. package/dist/utils/player-data-utils.js +48 -0
  100. package/dist/utils/types.js +8 -0
  101. package/dist/wireframe/common/box.js +18 -0
  102. package/dist/wireframe/common/dashed-line.js +21 -0
  103. package/dist/wireframe/common/line.js +17 -0
  104. package/dist/wireframe/common/pixi-ext.js +1 -0
  105. package/dist/wireframe/common/pixi.js +31 -0
  106. package/dist/wireframe/index.js +4 -0
  107. package/package.json +10 -3
  108. package/dist/index.js.map +0 -1
@@ -0,0 +1,1217 @@
1
+ import { Box2 } from '../math/box2';
2
+ import { Line2, Vector2 } from '../math';
3
+ // ============================================
4
+ // 工具函数
5
+ // ============================================
6
+ /**
7
+ * @description 将元素按行分组
8
+ * @param items 元素列表
9
+ * @returns 按行号分组的 Map
10
+ */
11
+ function groupItemsByRow(items) {
12
+ const groups = new Map();
13
+ for (const item of items) {
14
+ const row = item.row ?? 0;
15
+ const group = groups.get(row);
16
+ if (group) {
17
+ group.push(item);
18
+ }
19
+ else {
20
+ groups.set(row, [item]);
21
+ }
22
+ }
23
+ return groups;
24
+ }
25
+ /**
26
+ * @description 将元素映射为带尺寸的列表
27
+ * @param items 元素列表
28
+ * @returns 带尺寸的元素列表
29
+ */
30
+ function mapItemsWithSize(items) {
31
+ return items.map(item => {
32
+ const size = item.box.getSize();
33
+ return {
34
+ ...item,
35
+ width: size.x,
36
+ height: size.y,
37
+ };
38
+ });
39
+ }
40
+ /**
41
+ * @description 自动排布函数
42
+ * 根据元素信息中的行列数参数进行排布:
43
+ * - 如果元素有 row/column,则按指定行列排布
44
+ * - 如果元素没有 row/column,则使用 Shelf 算法自动生成行列数,再排布
45
+ *
46
+ * @param canvasBox 画布范围(Box2),min为左上角,max为右下角
47
+ * @param items 元素信息列表(包含 ID、Box2、可选的 row/column)
48
+ * @param options 可选配置(padding, gap)
49
+ * @returns 各元素的位置信息和整体尺寸
50
+ */
51
+ export function autoLayout(items, options = {}) {
52
+ if (items.length === 0) {
53
+ const padding = options.padding ?? 20;
54
+ return { items: [], size: [padding * 2, padding * 2] };
55
+ }
56
+ const padding = options.padding ?? 20;
57
+ const gap = options.gap ?? 5;
58
+ const itemsWithSize = mapItemsWithSize(items);
59
+ // 判断是否有任意元素有行列信息
60
+ const hasAnyLayoutInfo = itemsWithSize.some(item => item.row !== undefined);
61
+ let layoutResult;
62
+ if (hasAnyLayoutInfo) {
63
+ // 有布局信息,使用混合排布
64
+ layoutResult = calculateMixedLayout(itemsWithSize, padding, gap);
65
+ }
66
+ else {
67
+ // 没有布局信息,使用 Shelf 算法
68
+ layoutResult = calculateShelfLayout(itemsWithSize, padding, gap, options.config);
69
+ }
70
+ // 转换为输出格式
71
+ const result = layoutResult.items.map(item => {
72
+ return {
73
+ id: item.id,
74
+ position: [item.x, item.y],
75
+ row: item.row,
76
+ column: item.column,
77
+ };
78
+ });
79
+ return {
80
+ items: result,
81
+ size: layoutResult.size,
82
+ };
83
+ }
84
+ /**
85
+ * @description Shelf 算法计算自动布局
86
+ * 为单个元素判断是否有 row 信息:
87
+ * - 有 row 的元素:使用指定的 row,在该行内使用 Shelf 算法排列
88
+ * - 无 row 的元素:通过 Shelf 算法自动计算 row 和 column
89
+ *
90
+ * 支持 AutoLayoutConfig:
91
+ * - maxRowWidth: 单行最大宽度,超过后自动换行
92
+ * - maxRowCount: 最大行数限制
93
+ *
94
+ * @param items 元素列表(包含可选的 row)
95
+ * @param padding 边距
96
+ * @param gap 元素间隔
97
+ * @param config 自动布局配置(可选)
98
+ * @returns 计算后的位置、行列号和整体尺寸
99
+ */
100
+ function calculateShelfLayout(items, padding, gap, config) {
101
+ if (items.length === 0) {
102
+ return { items: [], size: [padding * 2, padding * 2] };
103
+ }
104
+ // 按 row 分组(无 row 的分配到 row 0)
105
+ const rowGroups = groupItemsByRow(items);
106
+ // 按行号排序
107
+ const sortedRows = Array.from(rowGroups.keys()).sort((a, b) => a - b);
108
+ const placedItems = [];
109
+ let currentY = padding;
110
+ let maxX = padding;
111
+ // 处理每一行
112
+ for (const row of sortedRows) {
113
+ const rowItems = rowGroups.get(row);
114
+ if (!rowItems) {
115
+ continue;
116
+ }
117
+ let currentX = padding;
118
+ let currentShelfHeight = 0;
119
+ let column = 0;
120
+ let currentRowCount = 1;
121
+ for (const item of rowItems) {
122
+ const { x: width, y: height } = item.box.getSize();
123
+ // 检查是否超出最大行宽
124
+ const wouldExceedMaxWidth = config?.maxRowWidth !== undefined &&
125
+ currentX + width + padding > config.maxRowWidth;
126
+ // 检查是否需要换行(同一行号内的自动换行)
127
+ let needNewShelf = wouldExceedMaxWidth && currentX > padding;
128
+ // 检查是否达到最大行数限制
129
+ if (needNewShelf && config?.maxRowCount !== undefined) {
130
+ if (currentRowCount >= config.maxRowCount) {
131
+ needNewShelf = false;
132
+ }
133
+ }
134
+ if (needNewShelf) {
135
+ // 当前行号内开新 shelf
136
+ currentY += currentShelfHeight + gap;
137
+ currentX = padding;
138
+ currentShelfHeight = height;
139
+ column = 0;
140
+ currentRowCount++;
141
+ }
142
+ else {
143
+ // 同一行内,更新 shelf 高度
144
+ currentShelfHeight = Math.max(currentShelfHeight, height);
145
+ }
146
+ // 放置当前元素(中心点坐标)
147
+ placedItems.push({
148
+ id: item.id,
149
+ x: currentX + width / 2,
150
+ y: currentY + height / 2,
151
+ row,
152
+ column,
153
+ });
154
+ // 更新最大 X
155
+ maxX = Math.max(maxX, currentX + width + padding);
156
+ // 移动到下一个位置
157
+ currentX += width + gap;
158
+ column++;
159
+ }
160
+ // 完成当前行号的所有元素,移动到下一行
161
+ if (rowItems.length > 0) {
162
+ currentY += currentShelfHeight + gap;
163
+ }
164
+ }
165
+ // 计算画板大小
166
+ const maxY = Math.max(currentY - gap + padding, padding * 2);
167
+ return {
168
+ items: placedItems,
169
+ size: [Math.max(maxX, padding * 2), maxY],
170
+ };
171
+ }
172
+ /**
173
+ * @description 混合排布算法
174
+ * 有 row/column 的元素按指定位置排布
175
+ * 无 row/column 的元素自动填充空位
176
+ *
177
+ * @param items 元素列表(包含宽高和可选的行列信息)
178
+ * @param padding 边距
179
+ * @param gap 元素间隔
180
+ * @returns 计算后的位置、行列号和整体尺寸
181
+ */
182
+ function calculateMixedLayout(items, padding, gap) {
183
+ if (items.length === 0) {
184
+ return { items: [], size: [padding * 2, padding * 2] };
185
+ }
186
+ // 按 row 分组(无 row 的分配到 row 0)
187
+ const rowGroups = groupItemsByRow(items);
188
+ // 按行号排序
189
+ const sortedRows = Array.from(rowGroups.keys()).sort((a, b) => a - b);
190
+ const placedItems = [];
191
+ let currentY = padding;
192
+ let maxX = padding;
193
+ // 处理每一行
194
+ for (const row of sortedRows) {
195
+ const rowItems = rowGroups.get(row);
196
+ if (!rowItems) {
197
+ continue;
198
+ }
199
+ // 分离有 column 和无 column 的元素
200
+ const withColumn = rowItems.filter(i => i.column !== undefined);
201
+ const withoutColumn = rowItems.filter(i => i.column === undefined);
202
+ // 计算行高
203
+ const rowHeight = Math.max(...rowItems.map(i => i.box.getSize().y), 0);
204
+ // 为所有元素分配列号
205
+ const columnAssignments = new Map();
206
+ const columnWidths = new Map(); // 记录每列宽度
207
+ // 1. 先处理有 column 的元素(优先)
208
+ const sortedWithColumn = [...withColumn].sort((a, b) => (a.column ?? 0) - (b.column ?? 0));
209
+ for (const item of sortedWithColumn) {
210
+ let targetColumn = item.column ?? 0;
211
+ // 如果目标列被占用,顺延到下一个可用列
212
+ while (columnAssignments.has(targetColumn)) {
213
+ targetColumn++;
214
+ }
215
+ columnAssignments.set(targetColumn, item);
216
+ columnWidths.set(targetColumn, item.box.getSize().x);
217
+ }
218
+ // 2. 为无 column 的元素填充空位
219
+ let nextColumn = 0;
220
+ for (const item of withoutColumn) {
221
+ while (columnAssignments.has(nextColumn)) {
222
+ nextColumn++;
223
+ }
224
+ columnAssignments.set(nextColumn, item);
225
+ columnWidths.set(nextColumn, item.box.getSize().x);
226
+ nextColumn++;
227
+ }
228
+ // 3. 按列号顺序计算位置(优化:使用累积宽度计算)
229
+ const allColumns = Array.from(columnAssignments.keys()).sort((a, b) => a - b);
230
+ let currentX = padding;
231
+ for (const column of allColumns) {
232
+ const item = columnAssignments.get(column);
233
+ const width = columnWidths.get(column);
234
+ if (!item || width === undefined) {
235
+ continue;
236
+ }
237
+ placedItems.push({
238
+ id: item.id,
239
+ x: currentX + width / 2,
240
+ y: currentY + item.box.getSize().y / 2,
241
+ row,
242
+ column,
243
+ });
244
+ maxX = Math.max(maxX, currentX + width + padding);
245
+ currentX += width + gap;
246
+ }
247
+ // 移动到下一行
248
+ currentY += rowHeight + gap;
249
+ }
250
+ // 计算画板大小
251
+ const maxY = Math.max(currentY - gap + padding, padding * 2);
252
+ return {
253
+ items: placedItems,
254
+ size: [Math.max(maxX, padding * 2), maxY],
255
+ };
256
+ }
257
+ /**
258
+ * @description 计算元素到目标位置的位移
259
+ * @param currentPosition 当前位置 [x, y]
260
+ * @param targetPosition 目标位置 [x, y]
261
+ * @returns 位移向量 [dx, dy]
262
+ */
263
+ export function calculateDisplacement(currentPosition, targetPosition) {
264
+ return [
265
+ targetPosition[0] - currentPosition[0],
266
+ targetPosition[1] - currentPosition[1],
267
+ ];
268
+ }
269
+ /**
270
+ * @description 为 autoLayout 结果计算位移信息
271
+ * @param items 元素列表(包含当前位置)
272
+ * @param layoutResult autoLayout 的返回结果
273
+ * @returns 每个元素的位移信息
274
+ */
275
+ export function calculateLayoutDisplacements(items, layoutResult) {
276
+ return layoutResult.items.map(layoutItem => {
277
+ const currentItem = items.find(i => i.id === layoutItem.id);
278
+ const currentPosition = currentItem?.currentPosition ?? [0, 0];
279
+ return {
280
+ id: layoutItem.id,
281
+ displacement: calculateDisplacement(currentPosition, layoutItem.position),
282
+ targetPosition: layoutItem.position,
283
+ };
284
+ });
285
+ }
286
+ // ============================================
287
+ // 自动布局拖拽相关函数
288
+ // ============================================
289
+ /**
290
+ * @description 计算自动布局指示线位置
291
+ * @param position 插入位置信息
292
+ * @param getViewBoxById 获取元素包围盒的函数
293
+ * @returns 主指示线
294
+ */
295
+ export function calculateAutoLayoutIndicatorLine(position, getViewBoxById) {
296
+ const LINE_EXTENSION = 10;
297
+ const MOUSE_GAP = 5;
298
+ const ROW_LINE_EXTENSION = 20;
299
+ // 如果是换行操作,绘制水平行级指示线
300
+ if (position.rowChange?.isRowChange && position.rowChange.rowBounds) {
301
+ const bounds = position.rowChange.rowBounds;
302
+ const isMovingUp = position.rowChange.toRow < position.rowChange.fromRow;
303
+ // 向上移动(到更小的行号):线在当前行上方
304
+ // 向下移动(到更大的行号):线在当前行下方
305
+ const lineY = isMovingUp ? bounds.minY : bounds.maxY;
306
+ return new Line2(new Vector2(bounds.minX - ROW_LINE_EXTENSION, lineY), new Vector2(bounds.maxX + ROW_LINE_EXTENSION, lineY));
307
+ }
308
+ // 同行内移动,绘制列级垂直指示线
309
+ if (position.siblingId) {
310
+ const siblingBox = getViewBoxById(position.siblingId);
311
+ if (position.direction === 'before') {
312
+ return new Line2(new Vector2(siblingBox.min.x - MOUSE_GAP, siblingBox.min.y - LINE_EXTENSION), new Vector2(siblingBox.min.x - MOUSE_GAP, siblingBox.max.y + LINE_EXTENSION));
313
+ }
314
+ else {
315
+ return new Line2(new Vector2(siblingBox.max.x + MOUSE_GAP, siblingBox.min.y - LINE_EXTENSION), new Vector2(siblingBox.max.x + MOUSE_GAP, siblingBox.max.y + LINE_EXTENSION));
316
+ }
317
+ }
318
+ return new Line2();
319
+ }
320
+ /**
321
+ * @description 基于鼠标位置计算布局位置(row 和 column)
322
+ * @param mousePoint 鼠标位置
323
+ * @param allItems 所有子元素
324
+ * @param getViewBoxById 获取元素包围盒的函数
325
+ * @param getLayoutInfo 获取元素布局信息的函数
326
+ * @param excludeItemId 需要排除的元素ID(被拖拽的元素)
327
+ * @returns 布局位置信息 { row: number, column: number }
328
+ */
329
+ export function calculateAutoLayoutPositionByMouse(mousePoint, allItems, getViewBoxById, getLayoutInfo, excludeItemId) {
330
+ // 排除被拖拽的元素
331
+ const siblings = allItems.filter(item => item.id !== excludeItemId);
332
+ if (siblings.length === 0) {
333
+ return { row: 0, column: 0 };
334
+ }
335
+ const siblingsWithInfo = siblings
336
+ .map(sibling => {
337
+ const box = getViewBoxById(sibling.id);
338
+ return {
339
+ id: sibling.id,
340
+ box,
341
+ center: box.getCenter(),
342
+ layoutInfo: getLayoutInfo(sibling),
343
+ };
344
+ })
345
+ .filter(item => !item.box.isEmpty());
346
+ if (siblingsWithInfo.length === 0) {
347
+ return { row: 0, column: 0 };
348
+ }
349
+ const mouseY = mousePoint.y;
350
+ const mouseX = mousePoint.x;
351
+ // 使用已有行号信息进行分组
352
+ const rowGroups = groupItemsByRow(siblingsWithInfo.map(s => ({
353
+ ...s,
354
+ row: s.layoutInfo?.row !== undefined ? Math.floor(s.layoutInfo.row) : 0,
355
+ })));
356
+ let maxRow = 0;
357
+ let minRow = 0;
358
+ for (const row of rowGroups.keys()) {
359
+ maxRow = Math.max(maxRow, row);
360
+ minRow = Math.min(minRow, row);
361
+ }
362
+ const rowInfos = [];
363
+ for (const [row, items] of rowGroups) {
364
+ const minY = Math.min(...items.map(i => i.box.min.y));
365
+ const maxY = Math.max(...items.map(i => i.box.max.y));
366
+ const centerY = (minY + maxY) / 2;
367
+ const height = maxY - minY;
368
+ rowInfos.push({ row, centerY, minY, maxY, height });
369
+ }
370
+ // 按 Y 坐标排序
371
+ rowInfos.sort((a, b) => a.centerY - b.centerY);
372
+ // 确定目标行号
373
+ let targetRow = 0;
374
+ // 检查鼠标是否在某行的垂直范围内
375
+ let foundRow = false;
376
+ for (const currentRow of rowInfos) {
377
+ if (mouseY >= currentRow.minY && mouseY <= currentRow.maxY) {
378
+ targetRow = currentRow.row;
379
+ foundRow = true;
380
+ break;
381
+ }
382
+ }
383
+ // 如果鼠标在所有行上方,创建新行(row -1)
384
+ if (!foundRow && rowInfos.length > 0) {
385
+ const firstRow = rowInfos[0];
386
+ if (firstRow && mouseY < firstRow.minY) {
387
+ targetRow = minRow - 1;
388
+ foundRow = true;
389
+ }
390
+ }
391
+ // 如果鼠标在所有行下方,创建新行
392
+ if (!foundRow && rowInfos.length > 0) {
393
+ const lastRow = rowInfos[rowInfos.length - 1];
394
+ if (lastRow && mouseY > lastRow.maxY) {
395
+ targetRow = maxRow + 1;
396
+ foundRow = true;
397
+ }
398
+ }
399
+ // 默认使用最近的行
400
+ if (!foundRow) {
401
+ let minDistance = Infinity;
402
+ for (const rowInfo of rowInfos) {
403
+ const distance = Math.abs(mouseY - rowInfo.centerY);
404
+ if (distance < minDistance) {
405
+ minDistance = distance;
406
+ targetRow = rowInfo.row;
407
+ }
408
+ }
409
+ }
410
+ // 在目标行内确定列号
411
+ const targetRowItems = rowGroups.get(targetRow) ?? [];
412
+ // 如果目标行没有元素,从第0列开始
413
+ if (targetRowItems.length === 0) {
414
+ return { row: targetRow, column: 0 };
415
+ }
416
+ // 按 X 坐标排序
417
+ targetRowItems.sort((a, b) => a.center.x - b.center.x);
418
+ // 找到鼠标的插入位置
419
+ for (let i = 0; i < targetRowItems.length; i++) {
420
+ const item = targetRowItems[i];
421
+ if (item && mouseX < item.center.x) {
422
+ return { row: targetRow, column: i };
423
+ }
424
+ }
425
+ // 鼠标在该行所有元素右侧,添加到最后
426
+ return { row: targetRow, column: targetRowItems.length };
427
+ }
428
+ /**
429
+ * @description 基于鼠标位置计算插入位置(用于显示指示器)
430
+ * @param mousePoint 鼠标位置
431
+ * @param allItems 所有子元素
432
+ * @param excludeItemId 需要排除的元素ID(被拖拽的元素)
433
+ * @param parent 父画板
434
+ * @param originalRow 原始行号
435
+ * @param getSelectedItemsBox 获取选中项包围盒的函数
436
+ * @returns 插入位置信息和计算出的行列位置
437
+ */
438
+ export function calculateInsertPositionFromLayout(mousePoint, allItems, excludeItemId, parent, originalRow, getViewBoxById, getSelectedItemsBox) {
439
+ const mouseX = mousePoint.x;
440
+ const mouseY = mousePoint.y;
441
+ // 排除被拖拽的元素
442
+ const siblings = allItems.filter(item => item.id !== excludeItemId);
443
+ if (siblings.length === 0) {
444
+ return { index: 0, direction: 'after', row: -1, column: 0 };
445
+ }
446
+ const siblingsWithInfo = siblings
447
+ .map(item => {
448
+ const box = getViewBoxById(item.id);
449
+ if (box.isEmpty()) {
450
+ return null;
451
+ }
452
+ const layoutInfo = parent.property.layoutInfos?.[item.id];
453
+ return {
454
+ item,
455
+ box,
456
+ row: Math.floor(layoutInfo?.row ?? 0),
457
+ column: layoutInfo?.column ?? 0,
458
+ centerX: box.getCenter().x,
459
+ };
460
+ })
461
+ .filter((info) => info !== null);
462
+ if (siblingsWithInfo.length === 0) {
463
+ return { index: 0, direction: 'after', row: -1, column: 0 };
464
+ }
465
+ // 按行号分组,计算每行的垂直边界
466
+ const rowGroups = groupItemsByRow(siblingsWithInfo);
467
+ // 计算每行的边界(minY, maxY, minX, maxX)
468
+ const rowBounds = new Map();
469
+ for (const [row, items] of rowGroups) {
470
+ const minY = Math.min(...items.map(i => i.box.min.y));
471
+ const maxY = Math.max(...items.map(i => i.box.max.y));
472
+ const minX = Math.min(...items.map(i => i.box.min.x));
473
+ const maxX = Math.max(...items.map(i => i.box.max.x));
474
+ rowBounds.set(row, { minY, maxY, minX, maxX });
475
+ }
476
+ // 按行号排序
477
+ const sortedRows = Array.from(rowGroups.keys()).sort((a, b) => a - b);
478
+ // 步骤1:确定目标行
479
+ let targetRow = -1;
480
+ // 检查鼠标是否在某行的垂直范围内
481
+ for (const row of sortedRows) {
482
+ const bounds = rowBounds.get(row);
483
+ if (bounds && mouseY >= bounds.minY && mouseY <= bounds.maxY) {
484
+ targetRow = row;
485
+ break;
486
+ }
487
+ }
488
+ // 如果没有找到行,检查鼠标是否在所有行上方
489
+ if (targetRow === -1 && sortedRows.length > 0) {
490
+ const firstRowNum = sortedRows[0];
491
+ if (firstRowNum === undefined) {
492
+ targetRow = -1;
493
+ }
494
+ else {
495
+ const firstRowBounds = rowBounds.get(firstRowNum);
496
+ if (firstRowBounds && mouseY < firstRowBounds.minY) {
497
+ targetRow = -1; // 在所有行上方
498
+ }
499
+ else {
500
+ // 鼠标在所有行下方,使用最后一行
501
+ targetRow = sortedRows[sortedRows.length - 1] ?? -1;
502
+ }
503
+ }
504
+ }
505
+ // 计算是否换行
506
+ const currentRowBounds = rowBounds.get(originalRow);
507
+ const isRowChange = targetRow !== originalRow;
508
+ // 步骤2:在目标行内确定插入位置
509
+ const targetRowItems = rowGroups.get(targetRow) ?? [];
510
+ // 如果目标行没有元素(可能是 -1 行或空行),需要找到参考点
511
+ if (targetRowItems.length === 0) {
512
+ // 找到目标行位置的第一个元素作为参考
513
+ let referenceItem = null;
514
+ for (const [row, items] of rowGroups) {
515
+ if (row > targetRow && items.length > 0) {
516
+ const firstItem = items[0];
517
+ if (firstItem) {
518
+ referenceItem = firstItem.item;
519
+ break;
520
+ }
521
+ }
522
+ }
523
+ if (!referenceItem) {
524
+ // 使用最后一个元素
525
+ const lastRow = sortedRows[sortedRows.length - 1];
526
+ const lastRowItems = lastRow !== undefined ? rowGroups.get(lastRow) ?? [] : [];
527
+ referenceItem = lastRowItems[lastRowItems.length - 1]?.item ?? null;
528
+ }
529
+ if (referenceItem) {
530
+ const refIndex = parent.children.indexOf(referenceItem.id);
531
+ if (refIndex !== -1) {
532
+ return {
533
+ index: targetRow === -1 ? refIndex : refIndex + 1,
534
+ direction: targetRow === -1 ? 'before' : 'after',
535
+ siblingId: referenceItem.id,
536
+ row: targetRow,
537
+ column: targetRow === -1 ? 0 : (rowGroups.get(targetRow)?.length ?? 0),
538
+ rowChange: isRowChange && currentRowBounds ? {
539
+ fromRow: originalRow,
540
+ toRow: targetRow,
541
+ rowBounds: currentRowBounds,
542
+ isRowChange,
543
+ } : undefined,
544
+ };
545
+ }
546
+ }
547
+ return {
548
+ index: 0,
549
+ direction: 'after',
550
+ row: targetRow,
551
+ column: 0,
552
+ rowChange: isRowChange && currentRowBounds ? {
553
+ fromRow: originalRow,
554
+ toRow: targetRow,
555
+ rowBounds: currentRowBounds,
556
+ isRowChange,
557
+ } : undefined,
558
+ };
559
+ }
560
+ // 按X坐标排序
561
+ targetRowItems.sort((a, b) => a.centerX - b.centerX);
562
+ // 步骤2:在行内基于鼠标X坐标确定插入位置
563
+ for (const info of targetRowItems) {
564
+ if (mouseX < info.centerX) {
565
+ // 鼠标在元素中心左侧,插入到该元素之前
566
+ const refIndex = parent.children.indexOf(info.item.id);
567
+ if (refIndex !== -1) {
568
+ return {
569
+ index: refIndex,
570
+ direction: 'before',
571
+ siblingId: info.item.id,
572
+ row: targetRow,
573
+ column: info.column,
574
+ rowChange: isRowChange && currentRowBounds ? {
575
+ fromRow: originalRow,
576
+ toRow: targetRow,
577
+ rowBounds: currentRowBounds,
578
+ isRowChange: true,
579
+ } : undefined,
580
+ };
581
+ }
582
+ }
583
+ }
584
+ // 鼠标在该行所有元素右侧,添加到最后
585
+ const lastItem = targetRowItems[targetRowItems.length - 1];
586
+ if (lastItem) {
587
+ const refIndex = parent.children.indexOf(lastItem.item.id);
588
+ if (refIndex !== -1) {
589
+ return {
590
+ index: refIndex + 1,
591
+ direction: 'after',
592
+ siblingId: lastItem.item.id,
593
+ row: targetRow,
594
+ column: lastItem.column + 1,
595
+ rowChange: isRowChange && currentRowBounds ? {
596
+ fromRow: originalRow,
597
+ toRow: targetRow,
598
+ rowBounds: currentRowBounds,
599
+ isRowChange: true,
600
+ } : undefined,
601
+ };
602
+ }
603
+ }
604
+ return {
605
+ index: 0,
606
+ direction: 'after',
607
+ row: -1,
608
+ column: 0,
609
+ rowChange: isRowChange && currentRowBounds ? {
610
+ fromRow: originalRow,
611
+ toRow: targetRow,
612
+ rowBounds: currentRowBounds,
613
+ isRowChange: true,
614
+ } : undefined,
615
+ };
616
+ }
617
+ /**
618
+ * @description 更新元素的 layoutInfo
619
+ * @param item 要更新的元素
620
+ * @param parent 父画板
621
+ * @param layoutPosition 新的布局位置
622
+ */
623
+ export function updateItemLayoutInfo(item, parent, layoutPosition) {
624
+ // 初始化 layoutInfos 如果不存在
625
+ parent.property.layoutInfos ??= {};
626
+ // 更新被拖拽元素的 layoutInfo
627
+ const currentInfo = parent.property.layoutInfos[item.id];
628
+ parent.property.layoutInfos[item.id] = {
629
+ ...currentInfo,
630
+ row: layoutPosition.row,
631
+ column: layoutPosition.column,
632
+ };
633
+ }
634
+ // ============================================
635
+ // 全局自适应排布
636
+ // ============================================
637
+ /**
638
+ * @description 分析元素特征,判断更适合哪种排列方式
639
+ * @param elements 元素列表
640
+ * @returns aspectRatioHint 建议的宽高比倾向 (>1倾向于横向, <1倾向于纵向)
641
+ */
642
+ function analyzeElements(elements) {
643
+ if (elements.length === 0) {
644
+ return 1;
645
+ }
646
+ let totalWidth = 0;
647
+ let totalHeight = 0;
648
+ let tallCount = 0; // 高瘦元素数量
649
+ let wideCount = 0; // 矮胖元素数量
650
+ for (const el of elements) {
651
+ const size = el.box.getSize();
652
+ totalWidth += size.x;
653
+ totalHeight += size.y;
654
+ // 判断元素形状:宽高比 > 1.2 认为是宽胖的,< 0.8 认为是高瘦的
655
+ const aspectRatio = size.x / size.y;
656
+ if (aspectRatio < 0.8) {
657
+ tallCount++;
658
+ }
659
+ else if (aspectRatio > 1.2) {
660
+ wideCount++;
661
+ }
662
+ }
663
+ // 计算平均宽高比
664
+ const avgAspectRatio = totalWidth / totalHeight;
665
+ // 综合判断:高瘦元素多时倾向横向排列(行内堆叠)
666
+ // 矮胖元素多时倾向纵向排列(列内堆叠)
667
+ if (tallCount > wideCount) {
668
+ return Math.max(avgAspectRatio, 1.5);
669
+ }
670
+ else if (wideCount > tallCount) {
671
+ return Math.min(avgAspectRatio, 0.67);
672
+ }
673
+ return avgAspectRatio;
674
+ }
675
+ /**
676
+ * @description 计算元素总面积和最大尺寸
677
+ */
678
+ function calculateElementsStats(elements, gap) {
679
+ let totalArea = 0;
680
+ let maxWidth = 0;
681
+ let maxHeight = 0;
682
+ for (const el of elements) {
683
+ const size = el.box.getSize();
684
+ const w = size.x + gap;
685
+ const h = size.y + gap;
686
+ totalArea += w * h;
687
+ maxWidth = Math.max(maxWidth, w);
688
+ maxHeight = Math.max(maxHeight, h);
689
+ }
690
+ return { totalArea, maxWidth, maxHeight };
691
+ }
692
+ /**
693
+ * @description 计算目标尺寸
694
+ * 根据元素总面积和目标宽高比,计算容器尺寸
695
+ */
696
+ function calculateTargetSize(items, gap, targetAspectRatio = 1) {
697
+ if (items.length === 0) {
698
+ return { targetWidth: 0, targetHeight: 0 };
699
+ }
700
+ const { totalArea, maxWidth, maxHeight } = calculateElementsStats(items, gap);
701
+ // 减去最后一个元素的gap
702
+ const adjustedArea = totalArea - gap * gap * items.length;
703
+ // 根据目标宽高比计算容器尺寸
704
+ // width / height = targetAspectRatio
705
+ // width * height = totalArea
706
+ // => width = sqrt(totalArea * targetAspectRatio)
707
+ // => height = sqrt(totalArea / targetAspectRatio)
708
+ const targetWidth = Math.sqrt(adjustedArea * targetAspectRatio);
709
+ const targetHeight = Math.sqrt(adjustedArea / targetAspectRatio);
710
+ return {
711
+ targetWidth: Math.max(targetWidth, maxWidth),
712
+ targetHeight: Math.max(targetHeight, maxHeight),
713
+ };
714
+ }
715
+ /**
716
+ * @description 纵向排列策略(列优先)
717
+ * 元素在垂直方向堆叠,溢出后开新列
718
+ */
719
+ function calculateColumnLayout(elements, gap, targetAspectRatio = 1) {
720
+ const { targetHeight } = calculateTargetSize(elements, gap, targetAspectRatio);
721
+ // 按高度从高到低排序
722
+ const sortedElements = [...elements].sort((a, b) => {
723
+ return b.box.getSize().y - a.box.getSize().y;
724
+ });
725
+ const columns = [];
726
+ const resultItems = [];
727
+ for (const element of sortedElements) {
728
+ const size = element.box.getSize();
729
+ const elementHeight = size.y + gap;
730
+ const elementWidth = size.x;
731
+ let placed = false;
732
+ for (const col of columns) {
733
+ if (col.filledHeight + elementHeight <= targetHeight + gap) {
734
+ resultItems.push({
735
+ id: element.id,
736
+ position: [
737
+ col.x + elementWidth / 2,
738
+ col.filledHeight + size.y / 2,
739
+ ],
740
+ });
741
+ col.filledHeight += elementHeight;
742
+ col.maxWidth = Math.max(col.maxWidth, elementWidth);
743
+ placed = true;
744
+ break;
745
+ }
746
+ }
747
+ if (!placed) {
748
+ const lastCol = columns[columns.length - 1];
749
+ const newX = lastCol ? lastCol.x + lastCol.maxWidth + gap : 0;
750
+ resultItems.push({
751
+ id: element.id,
752
+ position: [
753
+ newX + elementWidth / 2,
754
+ size.y / 2,
755
+ ],
756
+ });
757
+ columns.push({
758
+ x: newX,
759
+ filledHeight: elementHeight,
760
+ maxWidth: elementWidth,
761
+ });
762
+ }
763
+ }
764
+ let maxX = 0;
765
+ let maxY = 0;
766
+ for (const col of columns) {
767
+ maxX = Math.max(maxX, col.x + col.maxWidth);
768
+ maxY = Math.max(maxY, col.filledHeight);
769
+ }
770
+ if (columns.length > 0) {
771
+ maxY -= gap;
772
+ }
773
+ const boundingBox = new Box2(new Vector2(0, 0), new Vector2(maxX, Math.max(0, maxY)));
774
+ const score = calculateLayoutScore(boundingBox);
775
+ return { items: resultItems, boundingBox, score };
776
+ }
777
+ /**
778
+ * @description 几何工具函数
779
+ */
780
+ /**
781
+ * @description 判断矩形是否包含另一个矩形
782
+ */
783
+ function rectContains(outer, inner) {
784
+ return inner.x >= outer.x &&
785
+ inner.y >= outer.y &&
786
+ inner.x + inner.width <= outer.x + outer.width &&
787
+ inner.y + inner.height <= outer.y + outer.height;
788
+ }
789
+ /**
790
+ * @description 判断两个矩形是否相交
791
+ */
792
+ function rectIntersects(a, b) {
793
+ return a.x < b.x + b.width &&
794
+ a.x + a.width > b.x &&
795
+ a.y < b.y + b.height &&
796
+ a.y + a.height > b.y;
797
+ }
798
+ /**
799
+ * @description 计算布局结果分数(越接近正方形越好)
800
+ */
801
+ function calculateLayoutScore(boundingBox) {
802
+ const size = boundingBox.getSize();
803
+ const aspect = size.x / Math.max(size.y, 1);
804
+ // 分数:越接近1(正方形)越好,同时考虑面积利用率
805
+ return -Math.abs(aspect - 1) * 100 + Math.min(size.x * size.y, 10000);
806
+ }
807
+ /**
808
+ * @description 分割空闲矩形,返回不与放置矩形重叠的部分
809
+ * 使用 MaxRects 的标准分割策略:四向分割
810
+ */
811
+ function splitFreeRect(freeRect, placed) {
812
+ const result = [];
813
+ // 如果放置矩形与空闲矩形不相交,直接返回原矩形
814
+ if (!rectIntersects(freeRect, placed)) {
815
+ return [freeRect];
816
+ }
817
+ // 上方区域
818
+ if (placed.y > freeRect.y) {
819
+ result.push({
820
+ x: freeRect.x,
821
+ y: freeRect.y,
822
+ width: freeRect.width,
823
+ height: placed.y - freeRect.y,
824
+ });
825
+ }
826
+ // 下方区域
827
+ const placedBottom = placed.y + placed.height;
828
+ const freeBottom = freeRect.y + freeRect.height;
829
+ if (placedBottom < freeBottom) {
830
+ result.push({
831
+ x: freeRect.x,
832
+ y: placedBottom,
833
+ width: freeRect.width,
834
+ height: freeBottom - placedBottom,
835
+ });
836
+ }
837
+ // 左侧区域
838
+ if (placed.x > freeRect.x) {
839
+ result.push({
840
+ x: freeRect.x,
841
+ y: freeRect.y,
842
+ width: placed.x - freeRect.x,
843
+ height: freeRect.height,
844
+ });
845
+ }
846
+ // 右侧区域
847
+ const placedRight = placed.x + placed.width;
848
+ const freeRight = freeRect.x + freeRect.width;
849
+ if (placedRight < freeRight) {
850
+ result.push({
851
+ x: placedRight,
852
+ y: freeRect.y,
853
+ width: freeRight - placedRight,
854
+ height: freeRect.height,
855
+ });
856
+ }
857
+ return result;
858
+ }
859
+ /**
860
+ * @description 过滤掉被其他矩形包含的冗余矩形
861
+ * 优化:先按面积排序,面积大的只可能包含面积小的,不会相反
862
+ */
863
+ function pruneFreeRects(rects) {
864
+ if (rects.length <= 1) {
865
+ return rects;
866
+ }
867
+ // 按面积从大到小排序
868
+ const sortedRects = [...rects].sort((a, b) => (b.width * b.height) - (a.width * a.height));
869
+ const result = [];
870
+ for (let i = 0; i < sortedRects.length; i++) {
871
+ const rect = sortedRects[i];
872
+ if (!rect) {
873
+ continue;
874
+ }
875
+ let contained = false;
876
+ // 只需要检查面积更大的矩形(前面的)
877
+ for (let j = 0; j < i; j++) {
878
+ const largerRect = sortedRects[j];
879
+ if (largerRect && rectContains(largerRect, rect)) {
880
+ contained = true;
881
+ break;
882
+ }
883
+ }
884
+ if (!contained) {
885
+ result.push(rect);
886
+ }
887
+ }
888
+ return result;
889
+ }
890
+ /**
891
+ * @description 从空闲矩形列表中找出最佳放置位置
892
+ * 使用 Bottom-Left 策略:优先 y 小,然后 x 小
893
+ */
894
+ function findBestPlacement(freeRects, width, height) {
895
+ let best = null;
896
+ for (const rect of freeRects) {
897
+ // 检查是否能容纳(考虑间隙,但放置时不包含间隙)
898
+ if (rect.width >= width && rect.height >= height) {
899
+ // BLF 评分:y 坐标越小越好,y 相同则 x 越小越好
900
+ const score = rect.y * 10000 + rect.x;
901
+ if (!best || score < best.score) {
902
+ best = { rect, x: rect.x, y: rect.y, score };
903
+ }
904
+ }
905
+ }
906
+ return best ? { rect: best.rect, x: best.x, y: best.y } : null;
907
+ }
908
+ /**
909
+ * @description MaxRects 算法实现的布局
910
+ *
911
+ * MaxRects(Maximum Rectangles)是一种高效的 2D 矩形打包算法:
912
+ * 1. 维护一个"最大空闲矩形"列表,表示所有可用的放置空间
913
+ * 2. 每次放置元素时,从空闲矩形中选择一个最佳位置
914
+ * 3. 放置后,将相交的空闲矩形分割,并移除被完全包含的冗余矩形
915
+ * 4. 相比 BLF,MaxRects 能利用更多不规则的空间,生成更紧凑的布局
916
+ *
917
+ * 算法优势:
918
+ * - 能利用 L 型等不规则空隙
919
+ * - 支持多种放置策略(Bottom-Left、Best-Fit 等)
920
+ * - 布局密度通常比 Shelf 和 BLF 更高
921
+ */
922
+ function calculateMaxRectsLayout(elements, gap, targetAspectRatio = 1) {
923
+ if (elements.length === 0) {
924
+ return {
925
+ items: [],
926
+ boundingBox: new Box2(new Vector2(0, 0), new Vector2(0, 0)),
927
+ score: 0,
928
+ };
929
+ }
930
+ void targetAspectRatio;
931
+ // 按面积从大到小排序(大的先放置有助于形成稳定的基础结构)
932
+ const sortedElements = [...elements].sort((a, b) => {
933
+ const sizeA = a.box.getSize();
934
+ const sizeB = b.box.getSize();
935
+ return sizeB.x * sizeB.y - sizeA.x * sizeA.y;
936
+ });
937
+ // 计算预估容器大小
938
+ let totalWidth = 0;
939
+ let totalHeight = 0;
940
+ const { maxWidth: maxElementWidth, maxHeight: maxElementHeight } = calculateElementsStats(sortedElements, 0);
941
+ for (const el of sortedElements) {
942
+ const size = el.box.getSize();
943
+ totalWidth += size.x;
944
+ totalHeight += size.y;
945
+ }
946
+ // 初始空闲矩形:足够容纳所有元素
947
+ const gridSize = Math.ceil(Math.sqrt(sortedElements.length));
948
+ const initialWidth = Math.max(totalWidth, maxElementWidth * gridSize);
949
+ const initialHeight = Math.max(totalHeight, maxElementHeight * gridSize);
950
+ // 空闲矩形列表,初始为一个大矩形
951
+ let freeRects = [{
952
+ x: 0,
953
+ y: 0,
954
+ width: initialWidth * 2,
955
+ height: initialHeight * 2,
956
+ }];
957
+ const placedItems = [];
958
+ const resultItems = [];
959
+ for (const element of sortedElements) {
960
+ const size = element.box.getSize();
961
+ const width = size.x;
962
+ const height = size.y;
963
+ // 包含间隙的实际占用大小
964
+ const occupiedWidth = width + gap;
965
+ const occupiedHeight = height + gap;
966
+ // 在空闲矩形中查找最佳位置
967
+ const placement = findBestPlacement(freeRects, occupiedWidth, occupiedHeight);
968
+ if (!placement) {
969
+ // 理论上不应该发生,但如果发生,扩大容器重试
970
+ console.warn(`MaxRects: 无法为元素 ${element.id} 找到放置位置`);
971
+ continue;
972
+ }
973
+ // 放置元素
974
+ const placedRect = {
975
+ id: element.id,
976
+ x: placement.x,
977
+ y: placement.y,
978
+ width: occupiedWidth,
979
+ height: occupiedHeight,
980
+ };
981
+ placedItems.push(placedRect);
982
+ resultItems.push({
983
+ id: element.id,
984
+ position: [
985
+ placement.x + width / 2,
986
+ placement.y + height / 2,
987
+ ],
988
+ });
989
+ // 更新空闲矩形列表
990
+ const newFreeRects = [];
991
+ for (const freeRect of freeRects) {
992
+ // 将相交的空闲矩形分割
993
+ const splitRects = splitFreeRect(freeRect, placedRect);
994
+ newFreeRects.push(...splitRects);
995
+ }
996
+ // 过滤掉被包含的冗余矩形
997
+ freeRects = pruneFreeRects(newFreeRects);
998
+ // 限制空闲矩形数量,避免性能问题
999
+ if (freeRects.length > 100) {
1000
+ // 优先保留靠近原点的矩形(更可能被使用)
1001
+ freeRects.sort((a, b) => (a.y + a.x) - (b.y + b.x));
1002
+ freeRects = freeRects.slice(0, 100);
1003
+ }
1004
+ }
1005
+ // 计算包围盒
1006
+ let maxX = 0;
1007
+ let maxY = 0;
1008
+ for (const item of placedItems) {
1009
+ // 减去间隙,只计算实际内容区域
1010
+ maxX = Math.max(maxX, item.x + item.width - gap);
1011
+ maxY = Math.max(maxY, item.y + item.height - gap);
1012
+ }
1013
+ const boundingBox = new Box2(new Vector2(0, 0), new Vector2(maxX, maxY));
1014
+ const score = calculateLayoutScore(boundingBox);
1015
+ return { items: resultItems, boundingBox, score };
1016
+ }
1017
+ /**
1018
+ * @description 横向排列策略(使用 MaxRects 算法)
1019
+ * MaxRects 算法能生成比 Shelf 和 BLF 更紧凑的二维布局
1020
+ */
1021
+ function calculateRowLayout(elements, gap, targetAspectRatio = 1) {
1022
+ // 使用 MaxRects 算法作为横向排列的实现
1023
+ return calculateMaxRectsLayout(elements, gap, targetAspectRatio);
1024
+ }
1025
+ /**
1026
+ * @description 基于顺序的流式行排布(Shelf算法)
1027
+ * 保持元素输入顺序,每行尽可能多地放置元素
1028
+ *
1029
+ * 特点:
1030
+ * - 不限制每行元素个数,只要空间够就继续放置
1031
+ * - 通过目标行宽控制整体布局接近正方形
1032
+ * - 保持输入顺序,行优先
1033
+ *
1034
+ * 例如:元素1,2在第一行放得下,元素3,4,5在第二行也能放下,则第二行放3,4,5
1035
+ */
1036
+ function calculateOrderedGridLayout(elements, gap, targetAspectRatio = 1) {
1037
+ if (elements.length === 0) {
1038
+ return {
1039
+ items: [],
1040
+ boundingBox: new Box2(new Vector2(0, 0), new Vector2(0, 0)),
1041
+ };
1042
+ }
1043
+ // 计算总面积
1044
+ let totalArea = 0;
1045
+ for (const el of elements) {
1046
+ const size = el.box.getSize();
1047
+ totalArea += (size.x + gap) * (size.y + gap);
1048
+ }
1049
+ // 估算目标行宽,使整体接近目标宽高比
1050
+ // 总面积 ≈ 行宽 × 行高,目标:行宽 / 行高 ≈ targetAspectRatio
1051
+ // => 行宽 ≈ sqrt(总面积 × targetAspectRatio)
1052
+ const targetRowWidth = Math.sqrt(totalArea * targetAspectRatio);
1053
+ const rows = [];
1054
+ let currentRow = { elements: [], height: 0, y: 0 };
1055
+ let currentRowWidth = 0;
1056
+ for (const el of elements) {
1057
+ const size = el.box.getSize();
1058
+ const elementWidth = size.x + gap;
1059
+ const elementHeight = size.y;
1060
+ // 检查是否需要换行:当前行已有元素,且加上新元素会超过目标行宽
1061
+ const shouldWrap = currentRow.elements.length > 0 &&
1062
+ currentRowWidth + elementWidth > targetRowWidth + gap;
1063
+ if (shouldWrap) {
1064
+ // 保存当前行
1065
+ rows.push(currentRow);
1066
+ // 开始新行
1067
+ currentRow = { elements: [], height: 0, y: 0 };
1068
+ currentRowWidth = 0;
1069
+ }
1070
+ // 放置元素到当前行
1071
+ currentRow.elements.push({ el, x: currentRowWidth });
1072
+ currentRow.height = Math.max(currentRow.height, elementHeight);
1073
+ currentRowWidth += elementWidth;
1074
+ }
1075
+ // 添加最后一行
1076
+ if (currentRow.elements.length > 0) {
1077
+ rows.push(currentRow);
1078
+ }
1079
+ // 计算每行的Y坐标
1080
+ let currentY = 0;
1081
+ for (const row of rows) {
1082
+ row.y = currentY;
1083
+ currentY += row.height + gap;
1084
+ }
1085
+ // 生成结果
1086
+ const resultItems = [];
1087
+ for (const row of rows) {
1088
+ for (const item of row.elements) {
1089
+ const size = item.el.box.getSize();
1090
+ resultItems.push({
1091
+ id: item.el.id,
1092
+ position: [
1093
+ item.x + size.x / 2,
1094
+ row.y + size.y / 2,
1095
+ ],
1096
+ });
1097
+ }
1098
+ }
1099
+ // 计算包围盒
1100
+ let maxX = 0;
1101
+ let maxY = 0;
1102
+ for (const row of rows) {
1103
+ if (row.elements.length === 0) {
1104
+ continue;
1105
+ }
1106
+ const lastItem = row.elements[row.elements.length - 1];
1107
+ if (!lastItem) {
1108
+ continue;
1109
+ }
1110
+ const lastSize = lastItem.el.box.getSize();
1111
+ const rowWidth = lastItem.x + lastSize.x;
1112
+ maxX = Math.max(maxX, rowWidth);
1113
+ maxY = Math.max(maxY, row.y + row.height);
1114
+ }
1115
+ const boundingBox = new Box2(new Vector2(0, 0), new Vector2(maxX, maxY));
1116
+ return { items: resultItems, boundingBox };
1117
+ }
1118
+ /**
1119
+ * @description 全局自适应排布
1120
+ * 支持两种模式:
1121
+ * 1. 'adaptive'(默认): 基于元素包围盒自适应排布(MaxRects算法,更紧凑)
1122
+ * 2. 'ordered': 基于元素顺序的流式行排布(保持输入顺序,每行尽可能多放元素)
1123
+ *
1124
+ * @param elements 元素列表(包含ID和包围盒)
1125
+ * @param options 配置选项
1126
+ * @returns 各元素位置和整体包围盒
1127
+ */
1128
+ export function globalAutoLayout(elements, options = {}) {
1129
+ const { gap = 10, targetAspectRatio = 1, mode = 'adaptive', } = options;
1130
+ if (elements.length === 0) {
1131
+ return {
1132
+ items: [],
1133
+ boundingBox: new Box2(new Vector2(0, 0), new Vector2(0, 0)),
1134
+ };
1135
+ }
1136
+ if (mode === 'ordered') {
1137
+ // 基于顺序的流式行排布
1138
+ return calculateOrderedGridLayout(elements, gap, targetAspectRatio);
1139
+ }
1140
+ // 自适应模式:分析元素特征,获取宽高比倾向建议
1141
+ const aspectHint = analyzeElements(elements);
1142
+ // 调整目标宽高比:如果元素普遍高瘦,倾向于更宽的目标;反之亦然
1143
+ const adjustedRatio = targetAspectRatio * (aspectHint > 1 ? 1.3 : aspectHint < 1 ? 0.7 : 1);
1144
+ // 同时计算两种布局策略
1145
+ const columnResult = calculateColumnLayout(elements, gap, adjustedRatio);
1146
+ const rowResult = calculateRowLayout(elements, gap, adjustedRatio);
1147
+ // 选择更接近正方形的方案(分数更高的)
1148
+ const bestResult = columnResult.score >= rowResult.score ? columnResult : rowResult;
1149
+ return {
1150
+ items: bestResult.items,
1151
+ boundingBox: bestResult.boundingBox,
1152
+ };
1153
+ }
1154
+ /**
1155
+ * @description 调整其他元素的行列信息
1156
+ * 当被拖拽元素移动到新位置时,需要调整其他受影响的元素:
1157
+ * 1. 同行内移动:原位置到新位置之间的元素需要相应调整列号
1158
+ * 2. 跨行移动:原行中后面的元素前移,目标行中后面的元素后移
1159
+ * @param parent 父画板
1160
+ * @param draggedItemId 被拖拽的元素ID
1161
+ * @param fromRow 原始行号
1162
+ * @param fromColumn 原始列号
1163
+ * @param toRow 目标行号
1164
+ * @param toColumn 目标列号
1165
+ */
1166
+ export function adjustOtherElementsLayoutInfo(parent, draggedItemId, fromRow, fromColumn, toRow, toColumn, getSDKItem) {
1167
+ // 如果没有变化,不需要调整
1168
+ if (fromRow === toRow && fromColumn === toColumn) {
1169
+ return;
1170
+ }
1171
+ const layoutInfos = JSON.parse(JSON.stringify(parent.property.layoutInfos ?? {}));
1172
+ if (fromRow === toRow) {
1173
+ // ===== 情况1:同行内移动 =====
1174
+ // 移动中间元素
1175
+ for (const [id, info] of Object.entries(layoutInfos)) {
1176
+ if (id === draggedItemId) {
1177
+ continue;
1178
+ }
1179
+ if (info.row !== fromRow) {
1180
+ continue;
1181
+ }
1182
+ if (fromColumn < toColumn) {
1183
+ // 向前移动(如从列1移到列3):between(1, 3)的元素列号-1
1184
+ if (info.column > fromColumn && info.column <= toColumn) {
1185
+ info.column--;
1186
+ }
1187
+ }
1188
+ else if (fromColumn > toColumn) {
1189
+ // 向后移动(如从列3移到列1):between(1, 3)的元素列号+1
1190
+ if (info.column >= toColumn && info.column < fromColumn) {
1191
+ info.column++;
1192
+ }
1193
+ }
1194
+ }
1195
+ }
1196
+ else {
1197
+ // ===== 情况2:跨行移动 =====
1198
+ // 2.1 原行:原列之后的元素前移(列号-1)
1199
+ for (const [id, info] of Object.entries(layoutInfos)) {
1200
+ if (id === draggedItemId) {
1201
+ continue;
1202
+ }
1203
+ if (info.row === fromRow && info.column > fromColumn) {
1204
+ info.column--;
1205
+ }
1206
+ }
1207
+ // 2.2 目标行:目标列及之后的元素后移(列号+1)
1208
+ // 按列号从大到小排序,避免前面元素后移影响后面元素的判断
1209
+ const targetRowItems = Object.entries(layoutInfos)
1210
+ .filter(([id, info]) => id !== draggedItemId && info.row === toRow && info.column >= toColumn)
1211
+ .sort((a, b) => b[1].column - a[1].column);
1212
+ for (const [, info] of targetRowItems) {
1213
+ info.column++;
1214
+ }
1215
+ }
1216
+ parent.property.layoutInfos = layoutInfos;
1217
+ }