@nocobase/flow-engine 2.1.0-alpha.3 → 2.1.0-alpha.30

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 (160) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/JSRunner.d.ts +10 -1
  4. package/lib/JSRunner.js +50 -5
  5. package/lib/ViewScopedFlowEngine.js +5 -1
  6. package/lib/components/FieldModelRenderer.js +2 -2
  7. package/lib/components/FlowModelRenderer.d.ts +3 -1
  8. package/lib/components/FlowModelRenderer.js +12 -6
  9. package/lib/components/MobilePopup.js +6 -5
  10. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  11. package/lib/components/dnd/gridDragPlanner.js +601 -21
  12. package/lib/components/dnd/index.d.ts +19 -1
  13. package/lib/components/dnd/index.js +243 -23
  14. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  15. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  16. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  17. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +68 -10
  18. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  19. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  20. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  21. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  22. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  23. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  24. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  25. package/lib/components/subModel/AddSubModelButton.js +27 -1
  26. package/lib/components/subModel/index.d.ts +1 -0
  27. package/lib/components/subModel/index.js +19 -0
  28. package/lib/components/subModel/utils.d.ts +1 -1
  29. package/lib/components/subModel/utils.js +2 -2
  30. package/lib/data-source/index.d.ts +73 -0
  31. package/lib/data-source/index.js +211 -1
  32. package/lib/executor/FlowExecutor.js +31 -8
  33. package/lib/flowContext.d.ts +2 -0
  34. package/lib/flowContext.js +31 -1
  35. package/lib/flowEngine.d.ts +151 -1
  36. package/lib/flowEngine.js +389 -15
  37. package/lib/flowI18n.js +2 -1
  38. package/lib/flowSettings.d.ts +14 -6
  39. package/lib/flowSettings.js +34 -6
  40. package/lib/lazy-helper.d.ts +14 -0
  41. package/lib/lazy-helper.js +71 -0
  42. package/lib/locale/en-US.json +1 -0
  43. package/lib/locale/index.d.ts +2 -0
  44. package/lib/locale/zh-CN.json +1 -0
  45. package/lib/models/DisplayItemModel.d.ts +1 -1
  46. package/lib/models/EditableItemModel.d.ts +1 -1
  47. package/lib/models/FilterableItemModel.d.ts +1 -1
  48. package/lib/models/flowModel.d.ts +13 -10
  49. package/lib/models/flowModel.js +78 -18
  50. package/lib/provider.js +38 -23
  51. package/lib/reactive/observer.js +46 -16
  52. package/lib/runjs-context/registry.d.ts +1 -1
  53. package/lib/runjs-context/setup.js +20 -12
  54. package/lib/runjs-context/snippets/index.js +13 -2
  55. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  56. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  57. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  58. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  59. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  60. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  61. package/lib/types.d.ts +47 -1
  62. package/lib/utils/createCollectionContextMeta.js +6 -2
  63. package/lib/utils/index.d.ts +2 -2
  64. package/lib/utils/index.js +4 -0
  65. package/lib/utils/parsePathnameToViewParams.js +1 -1
  66. package/lib/utils/runjsTemplateCompat.js +1 -1
  67. package/lib/utils/runjsValue.js +41 -11
  68. package/lib/utils/schema-utils.d.ts +7 -1
  69. package/lib/utils/schema-utils.js +19 -0
  70. package/lib/views/FlowView.d.ts +7 -1
  71. package/lib/views/runViewBeforeClose.d.ts +10 -0
  72. package/lib/views/runViewBeforeClose.js +45 -0
  73. package/lib/views/useDialog.d.ts +2 -1
  74. package/lib/views/useDialog.js +20 -3
  75. package/lib/views/useDrawer.d.ts +2 -1
  76. package/lib/views/useDrawer.js +20 -3
  77. package/lib/views/usePage.d.ts +2 -1
  78. package/lib/views/usePage.js +10 -3
  79. package/package.json +6 -5
  80. package/src/JSRunner.ts +68 -4
  81. package/src/ViewScopedFlowEngine.ts +4 -0
  82. package/src/__tests__/JSRunner.test.ts +27 -1
  83. package/src/__tests__/flow-engine.test.ts +166 -0
  84. package/src/__tests__/flowContext.test.ts +65 -1
  85. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  86. package/src/__tests__/flowSettings.test.ts +94 -15
  87. package/src/__tests__/objectVariable.test.ts +24 -0
  88. package/src/__tests__/provider.test.tsx +24 -2
  89. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  90. package/src/__tests__/runjsContext.test.ts +16 -0
  91. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  92. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  93. package/src/__tests__/runjsSnippets.test.ts +21 -0
  94. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  95. package/src/components/FieldModelRenderer.tsx +2 -1
  96. package/src/components/FlowModelRenderer.tsx +18 -6
  97. package/src/components/MobilePopup.tsx +4 -2
  98. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  99. package/src/components/__tests__/dnd.test.ts +44 -0
  100. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  101. package/src/components/__tests__/gridDragPlanner.test.ts +512 -3
  102. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  103. package/src/components/dnd/gridDragPlanner.ts +743 -19
  104. package/src/components/dnd/index.tsx +291 -27
  105. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  106. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +88 -10
  107. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  108. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  109. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +189 -3
  110. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  111. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  112. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  113. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  114. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +142 -32
  115. package/src/components/subModel/index.ts +1 -0
  116. package/src/components/subModel/utils.ts +1 -1
  117. package/src/data-source/__tests__/index.test.ts +34 -1
  118. package/src/data-source/index.ts +258 -2
  119. package/src/executor/FlowExecutor.ts +34 -9
  120. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  121. package/src/flowContext.ts +37 -3
  122. package/src/flowEngine.ts +445 -11
  123. package/src/flowI18n.ts +2 -1
  124. package/src/flowSettings.ts +40 -6
  125. package/src/lazy-helper.tsx +57 -0
  126. package/src/locale/en-US.json +1 -0
  127. package/src/locale/zh-CN.json +1 -0
  128. package/src/models/DisplayItemModel.tsx +1 -1
  129. package/src/models/EditableItemModel.tsx +1 -1
  130. package/src/models/FilterableItemModel.tsx +1 -1
  131. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  132. package/src/models/__tests__/flowModel.test.ts +19 -3
  133. package/src/models/flowModel.tsx +119 -33
  134. package/src/provider.tsx +41 -25
  135. package/src/reactive/__tests__/observer.test.tsx +82 -0
  136. package/src/reactive/observer.tsx +87 -25
  137. package/src/runjs-context/registry.ts +1 -1
  138. package/src/runjs-context/setup.ts +22 -12
  139. package/src/runjs-context/snippets/index.ts +12 -1
  140. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  141. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  142. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  143. package/src/types.ts +60 -0
  144. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  145. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  146. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  147. package/src/utils/__tests__/utils.test.ts +62 -0
  148. package/src/utils/createCollectionContextMeta.ts +6 -2
  149. package/src/utils/index.ts +2 -1
  150. package/src/utils/parsePathnameToViewParams.ts +2 -2
  151. package/src/utils/runjsTemplateCompat.ts +1 -1
  152. package/src/utils/runjsValue.ts +50 -11
  153. package/src/utils/schema-utils.ts +30 -1
  154. package/src/views/FlowView.tsx +11 -1
  155. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  156. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  157. package/src/views/runViewBeforeClose.ts +19 -0
  158. package/src/views/useDialog.tsx +25 -3
  159. package/src/views/useDrawer.tsx +25 -3
  160. package/src/views/usePage.tsx +12 -3
@@ -47,6 +47,39 @@ export interface GridLayoutData {
47
47
  rows: Record<string, string[][]>;
48
48
  sizes: Record<string, number[]>;
49
49
  rowOrder?: string[];
50
+ layout?: GridLayoutV2;
51
+ }
52
+
53
+ export interface GridLayoutV2 {
54
+ version: 2;
55
+ rows: GridRowV2[];
56
+ }
57
+
58
+ export interface GridRowV2 {
59
+ id: string;
60
+ cells: GridCellV2[];
61
+ sizes: number[];
62
+ }
63
+
64
+ export interface GridCellV2 {
65
+ id: string;
66
+ items?: string[];
67
+ rows?: GridRowV2[];
68
+ }
69
+
70
+ export interface GridLayoutPathEntry {
71
+ rowId: string;
72
+ cellId?: string;
73
+ }
74
+
75
+ export type GridLayoutPath = GridLayoutPathEntry[];
76
+
77
+ export interface GridLayoutPosition {
78
+ path: GridLayoutPath;
79
+ rowIndex: number;
80
+ cellIndex: number;
81
+ itemIndex: number;
82
+ itemUid: string;
50
83
  }
51
84
 
52
85
  export interface ColumnSlot {
@@ -56,6 +89,7 @@ export interface ColumnSlot {
56
89
  insertIndex: number;
57
90
  position: 'before' | 'after';
58
91
  rect: Rect;
92
+ path?: GridLayoutPath;
59
93
  }
60
94
 
61
95
  export interface ColumnEdgeSlot {
@@ -64,6 +98,7 @@ export interface ColumnEdgeSlot {
64
98
  columnIndex: number;
65
99
  direction: 'left' | 'right';
66
100
  rect: Rect;
101
+ path?: GridLayoutPath;
67
102
  }
68
103
 
69
104
  export interface RowGapSlot {
@@ -71,6 +106,7 @@ export interface RowGapSlot {
71
106
  targetRowId: string;
72
107
  position: 'above' | 'below';
73
108
  rect: Rect;
109
+ path?: GridLayoutPath;
74
110
  }
75
111
 
76
112
  export interface EmptyRowSlot {
@@ -83,9 +119,21 @@ export interface EmptyColumnSlot {
83
119
  rowId: string;
84
120
  columnIndex: number;
85
121
  rect: Rect;
122
+ path?: GridLayoutPath;
86
123
  }
87
124
 
88
- export type LayoutSlot = ColumnSlot | ColumnEdgeSlot | RowGapSlot | EmptyRowSlot | EmptyColumnSlot;
125
+ export interface ItemEdgeSlot {
126
+ type: 'item-edge';
127
+ rowId: string;
128
+ columnIndex: number;
129
+ itemIndex: number;
130
+ itemUid: string;
131
+ direction: 'left' | 'right';
132
+ rect: Rect;
133
+ path?: GridLayoutPath;
134
+ }
135
+
136
+ export type LayoutSlot = ColumnSlot | ColumnEdgeSlot | RowGapSlot | EmptyRowSlot | EmptyColumnSlot | ItemEdgeSlot;
89
137
 
90
138
  /**
91
139
  * 列内插入的配置
@@ -267,6 +315,39 @@ const createColumnInsertRect = (itemRect: Rect, position: 'before' | 'after'): R
267
315
  });
268
316
  };
269
317
 
318
+ const parseLayoutPath = (value?: string): GridLayoutPath | undefined => {
319
+ if (!value) {
320
+ return undefined;
321
+ }
322
+ try {
323
+ const parsed = JSON.parse(value);
324
+ if (!Array.isArray(parsed)) {
325
+ return undefined;
326
+ }
327
+ const path = parsed
328
+ .map((entry) => {
329
+ if (!entry || typeof entry !== 'object' || typeof entry.rowId !== 'string') {
330
+ return null;
331
+ }
332
+ return {
333
+ rowId: entry.rowId,
334
+ ...(typeof entry.cellId === 'string' ? { cellId: entry.cellId } : {}),
335
+ };
336
+ })
337
+ .filter(Boolean) as GridLayoutPath;
338
+ return path.length ? path : undefined;
339
+ } catch (error) {
340
+ return undefined;
341
+ }
342
+ };
343
+
344
+ const createLegacyCellPath = (rowId: string, columnIndex: number): GridLayoutPath => [
345
+ {
346
+ rowId,
347
+ cellId: `${rowId}:cell:${columnIndex}`,
348
+ },
349
+ ];
350
+
270
351
  const expandColumnRect = (columnRect: Rect): Rect => ({
271
352
  top: columnRect.top,
272
353
  left: columnRect.left,
@@ -274,6 +355,12 @@ const expandColumnRect = (columnRect: Rect): Rect => ({
274
355
  height: Math.max(columnRect.height, MIN_SLOT_THICKNESS),
275
356
  });
276
357
 
358
+ const hasDirectNestedRows = (columnElement: HTMLElement) => {
359
+ return Array.from(columnElement.querySelectorAll('[data-grid-row-id]')).some((rowElement) => {
360
+ return (rowElement as HTMLElement).closest('[data-grid-column-row-id][data-grid-column-index]') === columnElement;
361
+ });
362
+ };
363
+
277
364
  export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions): LayoutSnapshot => {
278
365
  if (!container) {
279
366
  return {
@@ -282,23 +369,29 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
282
369
  };
283
370
  }
284
371
 
285
- const containerRect = toRect(container.getBoundingClientRect());
372
+ const scope = (
373
+ container.hasAttribute('data-grid-root') ? container : container.querySelector('[data-grid-root]')
374
+ ) as HTMLElement | null;
375
+ const layoutContainer = scope || container;
376
+ const containerRect = toRect(layoutContainer.getBoundingClientRect());
286
377
  const slots: LayoutSlot[] = [];
287
378
 
288
379
  // 获取所有行元素,但只保留直接属于当前容器的(不在子 Grid 中的)
289
- const allRowElements = Array.from(container.querySelectorAll('[data-grid-row-id]'));
380
+ const allRowElements = Array.from(layoutContainer.querySelectorAll('[data-grid-row-id]'));
381
+ const hasGridRootScope = layoutContainer.hasAttribute('data-grid-root');
290
382
  const rowElements = allRowElements.filter((el) => {
291
383
  const htmlEl = el as HTMLElement;
292
- // 查找该元素最近的带 data-grid-row-id 的祖先容器
384
+ if (hasGridRootScope) {
385
+ return htmlEl.closest('[data-grid-root]') === layoutContainer;
386
+ }
387
+ // 兼容旧测试和旧 DOM:只保留当前容器的直接 Grid 行。
293
388
  let parent = htmlEl.parentElement;
294
- while (parent && parent !== container) {
389
+ while (parent && parent !== layoutContainer) {
295
390
  if (parent.hasAttribute('data-grid-row-id')) {
296
- // 说明这个元素在另一个 Grid 行内,是嵌套的
297
391
  return false;
298
392
  }
299
393
  parent = parent.parentElement;
300
394
  }
301
- // 如果遍历到 container 都没遇到其他 grid-row,说明是直接子元素
302
395
  return true;
303
396
  }) as HTMLElement[];
304
397
 
@@ -315,25 +408,47 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
315
408
  return { slots, containerRect };
316
409
  }
317
410
 
318
- rowElements.forEach((rowElement, rowIndex) => {
411
+ const getRowScopeElement = (rowElement: HTMLElement) => {
412
+ const parent = rowElement.parentElement;
413
+ if (!parent || !layoutContainer.contains(parent)) {
414
+ return layoutContainer;
415
+ }
416
+ return parent;
417
+ };
418
+
419
+ const rowElementsByScope = new Map<HTMLElement, HTMLElement[]>();
420
+ rowElements.forEach((rowElement) => {
421
+ const rowScopeElement = getRowScopeElement(rowElement);
422
+ rowElementsByScope.set(rowScopeElement, [...(rowElementsByScope.get(rowScopeElement) || []), rowElement]);
423
+ });
424
+
425
+ rowElements.forEach((rowElement) => {
319
426
  const rowId = rowElement.dataset.gridRowId;
320
427
  if (!rowId) {
321
428
  return;
322
429
  }
323
430
  const rowRect = toRect(rowElement.getBoundingClientRect());
431
+ const rowPath = parseLayoutPath(rowElement.dataset.gridPath) || [{ rowId }];
432
+ const rowScopeElement = getRowScopeElement(rowElement);
433
+ const rowScopeRect = toRect(rowScopeElement.getBoundingClientRect());
434
+ const rowElementsInScope = rowElementsByScope.get(rowScopeElement) || [];
324
435
 
325
- if (rowIndex === 0) {
436
+ if (rowElementsInScope[0] === rowElement) {
326
437
  slots.push({
327
438
  type: 'row-gap',
328
439
  targetRowId: rowId,
329
440
  position: 'above',
330
- rect: createRowGapRect(rowRect, 'above', containerRect),
441
+ rect: createRowGapRect(rowRect, 'above', rowScopeRect),
442
+ path: rowPath,
331
443
  });
332
444
  }
333
445
 
334
446
  const columnElements = Array.from(
335
- container.querySelectorAll(`[data-grid-column-row-id="${rowId}"][data-grid-column-index]`),
336
- ) as HTMLElement[];
447
+ layoutContainer.querySelectorAll(`[data-grid-column-row-id="${rowId}"][data-grid-column-index]`),
448
+ ).filter((el) => {
449
+ // 只保留当前 row 下的直接列,避免嵌套 Grid 中相同 rowId 的列混入
450
+ return (el as HTMLElement).closest('[data-grid-row-id]') === rowElement;
451
+ }) as HTMLElement[];
337
452
 
338
453
  const sortedColumns = columnElements.sort((a, b) => {
339
454
  const indexA = Number(a.dataset.gridColumnIndex || 0);
@@ -344,6 +459,7 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
344
459
  sortedColumns.forEach((columnElement) => {
345
460
  const columnIndex = Number(columnElement.dataset.gridColumnIndex || 0);
346
461
  const columnRect = toRect(columnElement.getBoundingClientRect());
462
+ const columnPath = parseLayoutPath(columnElement.dataset.gridPath) || createLegacyCellPath(rowId, columnIndex);
347
463
 
348
464
  slots.push({
349
465
  type: 'column-edge',
@@ -351,6 +467,7 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
351
467
  columnIndex,
352
468
  direction: 'left',
353
469
  rect: createColumnEdgeRect(columnRect, 'left'),
470
+ path: columnPath,
354
471
  });
355
472
 
356
473
  slots.push({
@@ -359,11 +476,15 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
359
476
  columnIndex,
360
477
  direction: 'right',
361
478
  rect: createColumnEdgeRect(columnRect, 'right'),
479
+ path: columnPath,
362
480
  });
363
481
 
364
482
  const itemElements = Array.from(
365
483
  columnElement.querySelectorAll(`[data-grid-item-row-id="${rowId}"][data-grid-column-index="${columnIndex}"]`),
366
- ) as HTMLElement[];
484
+ ).filter((el) => {
485
+ // 只保留当前 column 下的直接 item,避免命中更深层嵌套 column 的 item
486
+ return (el as HTMLElement).closest('[data-grid-column-row-id][data-grid-column-index]') === columnElement;
487
+ }) as HTMLElement[];
367
488
 
368
489
  const sortedItems = itemElements.sort((a, b) => {
369
490
  const indexA = Number(a.dataset.gridItemIndex || 0);
@@ -372,11 +493,16 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
372
493
  });
373
494
 
374
495
  if (sortedItems.length === 0) {
496
+ if (hasDirectNestedRows(columnElement)) {
497
+ return;
498
+ }
499
+
375
500
  slots.push({
376
501
  type: 'empty-column',
377
502
  rowId,
378
503
  columnIndex,
379
504
  rect: expandColumnRect(columnRect),
505
+ path: columnPath,
380
506
  });
381
507
  return;
382
508
  }
@@ -389,10 +515,35 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
389
515
  insertIndex: 0,
390
516
  position: 'before',
391
517
  rect: createColumnInsertRect(firstItemRect, 'before'),
518
+ path: columnPath,
392
519
  });
393
520
 
394
521
  sortedItems.forEach((itemElement, itemIndex) => {
395
522
  const itemRect = toRect(itemElement.getBoundingClientRect());
523
+ const itemPath = parseLayoutPath(itemElement.dataset.gridPath) || columnPath;
524
+ const itemUid = itemElement.dataset.gridItemUid || '';
525
+ slots.push({
526
+ type: 'item-edge',
527
+ rowId,
528
+ columnIndex,
529
+ itemIndex,
530
+ itemUid,
531
+ direction: 'left',
532
+ rect: createColumnEdgeRect(itemRect, 'left'),
533
+ path: itemPath,
534
+ });
535
+
536
+ slots.push({
537
+ type: 'item-edge',
538
+ rowId,
539
+ columnIndex,
540
+ itemIndex,
541
+ itemUid,
542
+ direction: 'right',
543
+ rect: createColumnEdgeRect(itemRect, 'right'),
544
+ path: itemPath,
545
+ });
546
+
396
547
  slots.push({
397
548
  type: 'column',
398
549
  rowId,
@@ -400,6 +551,7 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
400
551
  insertIndex: itemIndex + 1,
401
552
  position: 'after',
402
553
  rect: createColumnInsertRect(itemRect, 'after'),
554
+ path: itemPath,
403
555
  });
404
556
  });
405
557
  });
@@ -408,7 +560,8 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
408
560
  type: 'row-gap',
409
561
  targetRowId: rowId,
410
562
  position: 'below',
411
- rect: createRowGapRect(rowRect, 'below', containerRect),
563
+ rect: createRowGapRect(rowRect, 'below', rowScopeRect),
564
+ path: rowPath,
412
565
  });
413
566
  });
414
567
 
@@ -430,6 +583,8 @@ export const getSlotKey = (slot: LayoutSlot): string => {
430
583
  return `${slot.type}`;
431
584
  case 'empty-column':
432
585
  return `${slot.type}:${slot.rowId}:${slot.columnIndex}`;
586
+ case 'item-edge':
587
+ return `${slot.type}:${slot.rowId}:${slot.columnIndex}:${slot.itemUid}:${slot.direction}`;
433
588
  }
434
589
  };
435
590
 
@@ -448,21 +603,45 @@ const distanceToRect = (point: Point, rect: Rect): number => {
448
603
  return Math.sqrt(dx * dx + dy * dy);
449
604
  };
450
605
 
606
+ const slotPriority: Record<LayoutSlot['type'], number> = {
607
+ 'item-edge': 5,
608
+ 'column-edge': 4,
609
+ column: 3,
610
+ 'row-gap': 2,
611
+ 'empty-column': 1,
612
+ 'empty-row': 0,
613
+ };
614
+
451
615
  export const resolveDropIntent = (point: Point, slots: LayoutSlot[]): LayoutSlot | null => {
452
616
  if (!slots.length) {
453
617
  return null;
454
618
  }
455
619
 
456
- const insideSlot = slots.find((slot) => isPointInsideRect(point, slot.rect));
457
- if (insideSlot) {
458
- return insideSlot;
620
+ let bestInsideSlot: LayoutSlot | null = null;
621
+ let bestInsidePriority = Number.NEGATIVE_INFINITY;
622
+ slots.forEach((slot) => {
623
+ if (!isPointInsideRect(point, slot.rect)) {
624
+ return;
625
+ }
626
+ const priority = slotPriority[slot.type];
627
+ if (priority > bestInsidePriority) {
628
+ bestInsidePriority = priority;
629
+ bestInsideSlot = slot;
630
+ }
631
+ });
632
+
633
+ if (bestInsideSlot) {
634
+ return bestInsideSlot;
459
635
  }
460
636
 
461
637
  let closest: LayoutSlot | null = null;
462
638
  let minDistance = Number.POSITIVE_INFINITY;
463
639
  slots.forEach((slot) => {
464
640
  const distance = distanceToRect(point, slot.rect);
465
- if (distance < minDistance) {
641
+ if (
642
+ distance < minDistance ||
643
+ (distance === minDistance && closest && slotPriority[slot.type] > slotPriority[closest.type])
644
+ ) {
466
645
  minDistance = distance;
467
646
  closest = slot;
468
647
  }
@@ -522,7 +701,10 @@ const toIntSizes = (weights: number[], count: number): number[] => {
522
701
  return [];
523
702
  }
524
703
 
525
- const normalizedWeights = weights.map((weight) => (Number.isFinite(weight) && weight > 0 ? weight : 1));
704
+ const normalizedWeights = Array.from({ length: count }, (_, index) => {
705
+ const weight = weights[index];
706
+ return Number.isFinite(weight) && weight > 0 ? weight : 1;
707
+ });
526
708
  const total = normalizedWeights.reduce((sum, weight) => sum + weight, 0) || count;
527
709
  const ratios = normalizedWeights.map((weight) => weight / total);
528
710
  const raw = ratios.map((ratio) => ratio * DEFAULT_GRID_COLUMNS);
@@ -563,6 +745,294 @@ const toIntSizes = (weights: number[], count: number): number[] => {
563
745
  return floors;
564
746
  };
565
747
 
748
+ const EMPTY_COLUMN_VALUE = 'EMPTY_COLUMN';
749
+
750
+ const createTopLevelRow = (itemUid: string, id: string): GridRowV2 => ({
751
+ id,
752
+ cells: [
753
+ {
754
+ id: `${id}:cell:0`,
755
+ items: [itemUid],
756
+ },
757
+ ],
758
+ sizes: [DEFAULT_GRID_COLUMNS],
759
+ });
760
+
761
+ const convertLegacyRowsToLayout = (
762
+ rows: Record<string, string[][]> = {},
763
+ sizes: Record<string, number[]> = {},
764
+ rowOrder?: string[],
765
+ ): GridLayoutV2 => {
766
+ const order = deriveRowOrder(rows, rowOrder);
767
+ return {
768
+ version: 2,
769
+ rows: order
770
+ .map((rowId) => {
771
+ const cells = (rows[rowId] || []).map((items, columnIndex) => ({
772
+ id: `${rowId}:cell:${columnIndex}`,
773
+ items: [...items],
774
+ }));
775
+ return {
776
+ id: rowId,
777
+ cells,
778
+ sizes: toIntSizes(sizes[rowId] || new Array(cells.length).fill(1), cells.length),
779
+ };
780
+ })
781
+ .filter((row) => row.cells.length > 0),
782
+ };
783
+ };
784
+
785
+ const collectCellItems = (cell: GridCellV2): string[] => {
786
+ if (Array.isArray(cell.items)) {
787
+ return cell.items;
788
+ }
789
+
790
+ return (cell.rows || []).flatMap((row) => row.cells.flatMap((childCell) => collectCellItems(childCell)));
791
+ };
792
+
793
+ export const projectLayoutToLegacyRows = (layout: GridLayoutV2): GridLayoutData => {
794
+ const rows: Record<string, string[][]> = {};
795
+ const sizes: Record<string, number[]> = {};
796
+ const rowOrder: string[] = [];
797
+
798
+ const appendRows = (sourceRows: GridRowV2[], prefix = '') => {
799
+ sourceRows.forEach((row) => {
800
+ const rowId = prefix ? `${prefix}/${row.id}` : row.id;
801
+ const cells = row.cells.map((cell) => collectCellItems(cell)).filter((items) => items.length > 0);
802
+ if (cells.length > 0) {
803
+ rows[rowId] = cells;
804
+ sizes[rowId] = toIntSizes(row.sizes || new Array(cells.length).fill(1), cells.length);
805
+ rowOrder.push(rowId);
806
+ }
807
+ });
808
+ };
809
+
810
+ appendRows(layout.rows || []);
811
+ return { rows, sizes, rowOrder, layout };
812
+ };
813
+
814
+ const normalizeGridRows = (
815
+ rows: GridRowV2[],
816
+ options: {
817
+ validUids?: Set<string>;
818
+ seenUids: Set<string>;
819
+ },
820
+ ): GridRowV2[] => {
821
+ return (Array.isArray(rows) ? rows : [])
822
+ .map((row, rowIndex) => {
823
+ const rawCells = Array.isArray(row?.cells) ? row.cells : [];
824
+ const rawSizes = Array.isArray(row?.sizes) ? row.sizes : [];
825
+ const cellsWithSizes = rawCells
826
+ .map((cell, cellIndex) => {
827
+ const id =
828
+ typeof cell?.id === 'string' && cell.id ? cell.id : `${row?.id || `row:${rowIndex}`}:cell:${cellIndex}`;
829
+ const size = rawSizes[cellIndex];
830
+ if (Array.isArray(cell?.rows) && cell.rows.length > 0) {
831
+ const childRows = normalizeGridRows(cell.rows, options);
832
+ if (childRows.length > 0) {
833
+ return { cell: { id, rows: childRows } as GridCellV2, size };
834
+ }
835
+ }
836
+
837
+ const rawItems = Array.isArray(cell?.items) ? cell.items : undefined;
838
+ const items = (rawItems || [])
839
+ .filter((itemUid) => typeof itemUid === 'string' && itemUid)
840
+ .filter((itemUid) => !options.validUids || options.validUids.has(itemUid) || itemUid === EMPTY_COLUMN_VALUE)
841
+ .filter((itemUid) => {
842
+ if (itemUid === EMPTY_COLUMN_VALUE) {
843
+ return true;
844
+ }
845
+ if (options.seenUids.has(itemUid)) {
846
+ return false;
847
+ }
848
+ options.seenUids.add(itemUid);
849
+ return true;
850
+ });
851
+
852
+ if (rawItems && (items.length > 0 || rawItems.length === 0)) {
853
+ return { cell: { id, items } as GridCellV2, size };
854
+ }
855
+
856
+ return null;
857
+ })
858
+ .filter(Boolean) as { cell: GridCellV2; size: number }[];
859
+ const cells = cellsWithSizes.map((entry) => entry.cell);
860
+
861
+ if (cells.length === 0) {
862
+ return null;
863
+ }
864
+
865
+ return {
866
+ id: typeof row?.id === 'string' && row.id ? row.id : `row:${rowIndex}`,
867
+ cells,
868
+ sizes: toIntSizes(
869
+ cellsWithSizes.map((entry) => entry.size),
870
+ cells.length,
871
+ ),
872
+ } as GridRowV2;
873
+ })
874
+ .filter(Boolean) as GridRowV2[];
875
+ };
876
+
877
+ const collapseCell = (cell: GridCellV2): GridCellV2 | null => {
878
+ if (cell.rows?.length) {
879
+ const rows = collapseRows(cell.rows);
880
+ if (rows.length === 0) {
881
+ return null;
882
+ }
883
+ if (rows.length === 1 && rows[0].cells.length === 1 && rows[0].sizes[0] === DEFAULT_GRID_COLUMNS) {
884
+ return {
885
+ id: cell.id,
886
+ ..._.omit(rows[0].cells[0], ['id']),
887
+ };
888
+ }
889
+ return { id: cell.id, rows };
890
+ }
891
+
892
+ if (Array.isArray(cell.items)) {
893
+ return { id: cell.id, items: cell.items.filter(Boolean) };
894
+ }
895
+ return null;
896
+ };
897
+
898
+ const collapseRows = (rows: GridRowV2[]): GridRowV2[] => {
899
+ return rows
900
+ .map((row) => {
901
+ const cellsWithSizes = row.cells
902
+ .map((cell, index) => {
903
+ const collapsed = collapseCell(cell);
904
+ return collapsed ? { cell: collapsed, size: row.sizes?.[index] } : null;
905
+ })
906
+ .filter(Boolean) as { cell: GridCellV2; size: number }[];
907
+ const cells = cellsWithSizes.map((entry) => entry.cell);
908
+ if (cells.length === 0) {
909
+ return null;
910
+ }
911
+ return {
912
+ id: row.id,
913
+ cells,
914
+ sizes: toIntSizes(
915
+ cellsWithSizes.map((entry) => entry.size),
916
+ cells.length,
917
+ ),
918
+ };
919
+ })
920
+ .filter(Boolean) as GridRowV2[];
921
+ };
922
+
923
+ export const normalizeGridLayout = ({
924
+ layout,
925
+ rows,
926
+ sizes,
927
+ rowOrder,
928
+ itemUids,
929
+ generateId = uid,
930
+ logger,
931
+ gridUid,
932
+ }: {
933
+ layout?: GridLayoutV2 | null;
934
+ rows?: Record<string, string[][]>;
935
+ sizes?: Record<string, number[]>;
936
+ rowOrder?: string[];
937
+ itemUids?: string[];
938
+ generateId?: () => string;
939
+ logger?: Pick<Console, 'warn'>;
940
+ gridUid?: string;
941
+ }): GridLayoutV2 => {
942
+ const validUids = itemUids ? new Set(itemUids) : undefined;
943
+ if (validUids) {
944
+ validUids.add(EMPTY_COLUMN_VALUE);
945
+ }
946
+
947
+ try {
948
+ const source =
949
+ layout?.version === 2
950
+ ? _.cloneDeep(layout)
951
+ : convertLegacyRowsToLayout(rows || {}, sizes || {}, rowOrder || Object.keys(rows || {}));
952
+ const seenUids = new Set<string>();
953
+ const next: GridLayoutV2 = {
954
+ version: 2,
955
+ rows: collapseRows(
956
+ normalizeGridRows(source.rows || [], {
957
+ validUids,
958
+ seenUids,
959
+ }),
960
+ ),
961
+ };
962
+
963
+ if (itemUids?.length) {
964
+ itemUids.forEach((itemUid) => {
965
+ if (itemUid === EMPTY_COLUMN_VALUE || seenUids.has(itemUid)) {
966
+ return;
967
+ }
968
+ const rowId = generateId();
969
+ next.rows.push(createTopLevelRow(itemUid, rowId));
970
+ seenUids.add(itemUid);
971
+ });
972
+ }
973
+
974
+ return next;
975
+ } catch (error) {
976
+ logger?.warn?.(`[GridModel] Failed to normalize grid layout${gridUid ? ` (${gridUid})` : ''}.`, error);
977
+ return {
978
+ version: 2,
979
+ rows: (itemUids || [])
980
+ .filter((itemUid) => itemUid !== EMPTY_COLUMN_VALUE)
981
+ .map((itemUid) => createTopLevelRow(itemUid, generateId())),
982
+ };
983
+ }
984
+ };
985
+
986
+ export const replaceUidInGridLayout = (layout: GridLayoutV2, fromUid: string, toUid: string): GridLayoutV2 => {
987
+ const replaceRows = (rows: GridRowV2[]): GridRowV2[] =>
988
+ rows.map((row) => ({
989
+ ...row,
990
+ cells: row.cells.map((cell) => {
991
+ if (cell.rows) {
992
+ return { ...cell, rows: replaceRows(cell.rows) };
993
+ }
994
+ return {
995
+ ...cell,
996
+ items: (cell.items || []).map((itemUid) => (itemUid === fromUid ? toUid : itemUid)),
997
+ };
998
+ }),
999
+ }));
1000
+ return { version: 2, rows: replaceRows(_.cloneDeep(layout.rows || [])) };
1001
+ };
1002
+
1003
+ export const findModelUidLayoutPosition = (layout: GridLayoutV2, uidValue: string): GridLayoutPosition | null => {
1004
+ const visitRows = (rows: GridRowV2[], parentPath: GridLayoutPath): GridLayoutPosition | null => {
1005
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
1006
+ const row = rows[rowIndex];
1007
+ for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex += 1) {
1008
+ const cell = row.cells[cellIndex];
1009
+ const path = [...parentPath, { rowId: row.id, cellId: cell.id }];
1010
+ if (cell.items) {
1011
+ const itemIndex = cell.items.indexOf(uidValue);
1012
+ if (itemIndex !== -1) {
1013
+ return {
1014
+ path,
1015
+ rowIndex,
1016
+ cellIndex,
1017
+ itemIndex,
1018
+ itemUid: uidValue,
1019
+ };
1020
+ }
1021
+ }
1022
+ if (cell.rows) {
1023
+ const result = visitRows(cell.rows, path);
1024
+ if (result) {
1025
+ return result;
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ return null;
1031
+ };
1032
+
1033
+ return visitRows(layout.rows || [], []);
1034
+ };
1035
+
566
1036
  const normalizeRowSizes = (rowId: string, layout: GridLayoutData) => {
567
1037
  const columns = layout.rows[rowId];
568
1038
  if (!columns || columns.length === 0) {
@@ -622,11 +1092,251 @@ const distributeSizesWithNewColumn = (
622
1092
  return toIntSizes(weights, columnCount);
623
1093
  };
624
1094
 
1095
+ const resolveCellPath = (layout: GridLayoutV2, slot: LayoutSlot): GridLayoutPath | undefined => {
1096
+ if ('path' in slot && slot.path?.length) {
1097
+ return slot.path;
1098
+ }
1099
+ if ('rowId' in slot && 'columnIndex' in slot) {
1100
+ return createLegacyCellPath(slot.rowId, slot.columnIndex);
1101
+ }
1102
+ return undefined;
1103
+ };
1104
+
1105
+ const findRowListByPath = (layout: GridLayoutV2, path?: GridLayoutPath): GridRowV2[] => {
1106
+ if (!path || path.length <= 1) {
1107
+ return layout.rows;
1108
+ }
1109
+
1110
+ let rows = layout.rows;
1111
+ for (let i = 0; i < path.length - 1; i += 1) {
1112
+ const entry = path[i];
1113
+ const row = rows.find((candidate) => candidate.id === entry.rowId);
1114
+ const cell = row?.cells.find((candidate) => candidate.id === entry.cellId);
1115
+ if (!cell?.rows) {
1116
+ return rows;
1117
+ }
1118
+ rows = cell.rows;
1119
+ }
1120
+ return rows;
1121
+ };
1122
+
1123
+ const findCellByPath = (layout: GridLayoutV2, path?: GridLayoutPath) => {
1124
+ if (!path?.length) {
1125
+ return null;
1126
+ }
1127
+
1128
+ let rows = layout.rows;
1129
+ for (let i = 0; i < path.length; i += 1) {
1130
+ const entry = path[i];
1131
+ const rowIndex = rows.findIndex((candidate) => candidate.id === entry.rowId);
1132
+ const row = rows[rowIndex];
1133
+ if (!row || !entry.cellId) {
1134
+ return null;
1135
+ }
1136
+ const cellIndex = row.cells.findIndex((candidate) => candidate.id === entry.cellId);
1137
+ const cell = row.cells[cellIndex];
1138
+ if (!cell) {
1139
+ return null;
1140
+ }
1141
+ if (i === path.length - 1) {
1142
+ return { rows, row, cell, rowIndex, cellIndex };
1143
+ }
1144
+ rows = cell.rows || [];
1145
+ }
1146
+ return null;
1147
+ };
1148
+
1149
+ const removeItemFromGridLayout = (layout: GridLayoutV2, sourceUid: string) => {
1150
+ const removeFromRows = (rows: GridRowV2[]): GridRowV2[] =>
1151
+ rows
1152
+ .map((row) => {
1153
+ const cellsWithSizes = row.cells
1154
+ .map((cell, index) => {
1155
+ if (cell.rows) {
1156
+ const childRows = removeFromRows(cell.rows);
1157
+ return childRows.length ? { cell: { ...cell, rows: childRows }, size: row.sizes?.[index] } : null;
1158
+ }
1159
+ const currentItems = cell.items || [];
1160
+ const hadSourceUid = currentItems.includes(sourceUid);
1161
+ const items = currentItems.filter((itemUid) => itemUid !== sourceUid);
1162
+ if (hadSourceUid && !items.length) {
1163
+ return null;
1164
+ }
1165
+ return { cell: { ...cell, items }, size: row.sizes?.[index] };
1166
+ })
1167
+ .filter(Boolean) as { cell: GridCellV2; size: number }[];
1168
+ const cells = cellsWithSizes.map((entry) => entry.cell);
1169
+
1170
+ return cells.length
1171
+ ? {
1172
+ ...row,
1173
+ cells,
1174
+ sizes: toIntSizes(
1175
+ cellsWithSizes.map((entry) => entry.size),
1176
+ cells.length,
1177
+ ),
1178
+ }
1179
+ : null;
1180
+ })
1181
+ .filter(Boolean) as GridRowV2[];
1182
+
1183
+ layout.rows = collapseRows(removeFromRows(layout.rows));
1184
+ };
1185
+
1186
+ const createSingleCellRow = (itemUid: string, rowId: string, cellId: string): GridRowV2 => ({
1187
+ id: rowId,
1188
+ cells: [{ id: cellId, items: [itemUid] }],
1189
+ sizes: [DEFAULT_GRID_COLUMNS],
1190
+ });
1191
+
1192
+ const getGeneratedId = (
1193
+ key: string,
1194
+ options?: {
1195
+ generatedIds?: Map<string, string>;
1196
+ generateId?: (key: string) => string;
1197
+ },
1198
+ ) => {
1199
+ const existing = options?.generatedIds?.get(key);
1200
+ if (existing) {
1201
+ return existing;
1202
+ }
1203
+ const value = options?.generateId?.(key) || uid();
1204
+ options?.generatedIds?.set(key, value);
1205
+ return value;
1206
+ };
1207
+
1208
+ const simulateGridLayoutForSlot = ({
1209
+ slot,
1210
+ sourceUid,
1211
+ layout,
1212
+ generatedIds,
1213
+ generateId,
1214
+ }: SimulateLayoutOptions): GridLayoutV2 => {
1215
+ const original = normalizeGridLayout({
1216
+ layout: layout.layout,
1217
+ rows: layout.rows,
1218
+ sizes: layout.sizes,
1219
+ rowOrder: layout.rowOrder,
1220
+ });
1221
+ const cloned = _.cloneDeep(original);
1222
+ const slotKey = getSlotKey(slot);
1223
+ const sourcePosition = findModelUidLayoutPosition(cloned, sourceUid);
1224
+ if (slot.type === 'item-edge' && slot.itemUid === sourceUid) {
1225
+ return cloned;
1226
+ }
1227
+
1228
+ const targetPath = resolveCellPath(cloned, slot);
1229
+ const targetItemUid = slot.type === 'item-edge' ? slot.itemUid : undefined;
1230
+ removeItemFromGridLayout(cloned, sourceUid);
1231
+
1232
+ switch (slot.type) {
1233
+ case 'column': {
1234
+ const target = findCellByPath(cloned, targetPath);
1235
+ if (!target) {
1236
+ break;
1237
+ }
1238
+ if (target.cell.rows) {
1239
+ target.cell.rows.push(
1240
+ createTopLevelRow(sourceUid, getGeneratedId(`${slotKey}:row`, { generatedIds, generateId })),
1241
+ );
1242
+ break;
1243
+ }
1244
+ target.cell.items ||= [];
1245
+ const insertIndex = Math.max(0, Math.min(slot.insertIndex, target.cell.items.length));
1246
+ target.cell.items.splice(insertIndex, 0, sourceUid);
1247
+ break;
1248
+ }
1249
+ case 'empty-column': {
1250
+ const target = findCellByPath(cloned, targetPath);
1251
+ if (target) {
1252
+ delete target.cell.rows;
1253
+ target.cell.items = [sourceUid];
1254
+ }
1255
+ break;
1256
+ }
1257
+ case 'column-edge': {
1258
+ const target = findCellByPath(cloned, targetPath);
1259
+ if (!target) {
1260
+ break;
1261
+ }
1262
+ const insertIndex = slot.direction === 'left' ? target.cellIndex : target.cellIndex + 1;
1263
+ const cellId = getGeneratedId(`${slotKey}:cell`, { generatedIds, generateId });
1264
+ target.row.cells.splice(insertIndex, 0, { id: cellId, items: [sourceUid] });
1265
+ target.row.sizes = distributeSizesWithNewColumn(target.row.sizes, insertIndex, target.row.cells.length);
1266
+ break;
1267
+ }
1268
+ case 'row-gap': {
1269
+ const rows = findRowListByPath(cloned, slot.path);
1270
+ const targetIndex = rows.findIndex((row) => row.id === slot.targetRowId);
1271
+ const insertIndex = targetIndex === -1 ? rows.length : slot.position === 'above' ? targetIndex : targetIndex + 1;
1272
+ const rowId = getGeneratedId(`${slotKey}:row`, { generatedIds, generateId });
1273
+ rows.splice(insertIndex, 0, createSingleCellRow(sourceUid, rowId, `${rowId}:cell:0`));
1274
+ break;
1275
+ }
1276
+ case 'empty-row': {
1277
+ const rowId = getGeneratedId(`${slotKey}:row`, { generatedIds, generateId });
1278
+ cloned.rows.push(createSingleCellRow(sourceUid, rowId, `${rowId}:cell:0`));
1279
+ break;
1280
+ }
1281
+ case 'item-edge': {
1282
+ if (!targetItemUid) {
1283
+ break;
1284
+ }
1285
+ const target = findCellByPath(cloned, targetPath);
1286
+ if (!target?.cell.items) {
1287
+ break;
1288
+ }
1289
+ const targetIndex = target.cell.items.indexOf(targetItemUid);
1290
+ if (targetIndex === -1) {
1291
+ break;
1292
+ }
1293
+
1294
+ const rows: GridRowV2[] = [];
1295
+ target.cell.items.forEach((itemUid, index) => {
1296
+ if (index === targetIndex) {
1297
+ const rowId = getGeneratedId(`${slotKey}:target-row`, { generatedIds, generateId });
1298
+ const leftItem = slot.direction === 'left' ? sourceUid : itemUid;
1299
+ const rightItem = slot.direction === 'left' ? itemUid : sourceUid;
1300
+ rows.push({
1301
+ id: rowId,
1302
+ cells: [
1303
+ { id: getGeneratedId(`${slotKey}:target-cell:0`, { generatedIds, generateId }), items: [leftItem] },
1304
+ { id: getGeneratedId(`${slotKey}:target-cell:1`, { generatedIds, generateId }), items: [rightItem] },
1305
+ ],
1306
+ sizes: [12, 12],
1307
+ });
1308
+ return;
1309
+ }
1310
+
1311
+ const rowId = getGeneratedId(`${slotKey}:row:${index}`, { generatedIds, generateId });
1312
+ rows.push(createSingleCellRow(itemUid, rowId, `${rowId}:cell:0`));
1313
+ });
1314
+ delete target.cell.items;
1315
+ target.cell.rows = rows;
1316
+ break;
1317
+ }
1318
+ default:
1319
+ break;
1320
+ }
1321
+
1322
+ const normalized = normalizeGridLayout({ layout: cloned });
1323
+ if (sourcePosition && isSameGridLayout(normalized, original)) {
1324
+ return original;
1325
+ }
1326
+ return normalized;
1327
+ };
1328
+
1329
+ export const isSameGridLayout = (a: GridLayoutV2, b: GridLayoutV2): boolean => {
1330
+ return JSON.stringify(a) === JSON.stringify(b);
1331
+ };
1332
+
625
1333
  export interface SimulateLayoutOptions {
626
1334
  slot: LayoutSlot;
627
1335
  sourceUid: string;
628
1336
  layout: GridLayoutData;
629
1337
  generateRowId?: () => string;
1338
+ generatedIds?: Map<string, string>;
1339
+ generateId?: (key: string) => string;
630
1340
  }
631
1341
 
632
1342
  export const simulateLayoutForSlot = ({
@@ -634,7 +1344,21 @@ export const simulateLayoutForSlot = ({
634
1344
  sourceUid,
635
1345
  layout,
636
1346
  generateRowId,
1347
+ generatedIds,
1348
+ generateId,
637
1349
  }: SimulateLayoutOptions): GridLayoutData => {
1350
+ if (layout.layout || slot.type === 'item-edge' || ('path' in slot && slot.path?.length)) {
1351
+ const nextLayout = simulateGridLayoutForSlot({
1352
+ slot,
1353
+ sourceUid,
1354
+ layout,
1355
+ generateRowId,
1356
+ generatedIds,
1357
+ generateId,
1358
+ });
1359
+ return projectLayoutToLegacyRows(nextLayout);
1360
+ }
1361
+
638
1362
  const cloned: GridLayoutData = {
639
1363
  rows: _.cloneDeep(layout.rows),
640
1364
  sizes: _.cloneDeep(layout.sizes),