@nocobase/flow-engine 2.1.0-beta.23 → 2.1.0-beta.25

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.
@@ -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,24 +408,43 @@ 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]`),
447
+ layoutContainer.querySelectorAll(`[data-grid-column-row-id="${rowId}"][data-grid-column-index]`),
336
448
  ).filter((el) => {
337
449
  // 只保留当前 row 下的直接列,避免嵌套 Grid 中相同 rowId 的列混入
338
450
  return (el as HTMLElement).closest('[data-grid-row-id]') === rowElement;
@@ -347,6 +459,7 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
347
459
  sortedColumns.forEach((columnElement) => {
348
460
  const columnIndex = Number(columnElement.dataset.gridColumnIndex || 0);
349
461
  const columnRect = toRect(columnElement.getBoundingClientRect());
462
+ const columnPath = parseLayoutPath(columnElement.dataset.gridPath) || createLegacyCellPath(rowId, columnIndex);
350
463
 
351
464
  slots.push({
352
465
  type: 'column-edge',
@@ -354,6 +467,7 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
354
467
  columnIndex,
355
468
  direction: 'left',
356
469
  rect: createColumnEdgeRect(columnRect, 'left'),
470
+ path: columnPath,
357
471
  });
358
472
 
359
473
  slots.push({
@@ -362,6 +476,7 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
362
476
  columnIndex,
363
477
  direction: 'right',
364
478
  rect: createColumnEdgeRect(columnRect, 'right'),
479
+ path: columnPath,
365
480
  });
366
481
 
367
482
  const itemElements = Array.from(
@@ -378,11 +493,16 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
378
493
  });
379
494
 
380
495
  if (sortedItems.length === 0) {
496
+ if (hasDirectNestedRows(columnElement)) {
497
+ return;
498
+ }
499
+
381
500
  slots.push({
382
501
  type: 'empty-column',
383
502
  rowId,
384
503
  columnIndex,
385
504
  rect: expandColumnRect(columnRect),
505
+ path: columnPath,
386
506
  });
387
507
  return;
388
508
  }
@@ -395,10 +515,35 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
395
515
  insertIndex: 0,
396
516
  position: 'before',
397
517
  rect: createColumnInsertRect(firstItemRect, 'before'),
518
+ path: columnPath,
398
519
  });
399
520
 
400
521
  sortedItems.forEach((itemElement, itemIndex) => {
401
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
+
402
547
  slots.push({
403
548
  type: 'column',
404
549
  rowId,
@@ -406,6 +551,7 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
406
551
  insertIndex: itemIndex + 1,
407
552
  position: 'after',
408
553
  rect: createColumnInsertRect(itemRect, 'after'),
554
+ path: itemPath,
409
555
  });
410
556
  });
411
557
  });
@@ -414,7 +560,8 @@ export const buildLayoutSnapshot = ({ container }: BuildLayoutSnapshotOptions):
414
560
  type: 'row-gap',
415
561
  targetRowId: rowId,
416
562
  position: 'below',
417
- rect: createRowGapRect(rowRect, 'below', containerRect),
563
+ rect: createRowGapRect(rowRect, 'below', rowScopeRect),
564
+ path: rowPath,
418
565
  });
419
566
  });
420
567
 
@@ -436,6 +583,8 @@ export const getSlotKey = (slot: LayoutSlot): string => {
436
583
  return `${slot.type}`;
437
584
  case 'empty-column':
438
585
  return `${slot.type}:${slot.rowId}:${slot.columnIndex}`;
586
+ case 'item-edge':
587
+ return `${slot.type}:${slot.rowId}:${slot.columnIndex}:${slot.itemUid}:${slot.direction}`;
439
588
  }
440
589
  };
441
590
 
@@ -454,21 +603,45 @@ const distanceToRect = (point: Point, rect: Rect): number => {
454
603
  return Math.sqrt(dx * dx + dy * dy);
455
604
  };
456
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
+
457
615
  export const resolveDropIntent = (point: Point, slots: LayoutSlot[]): LayoutSlot | null => {
458
616
  if (!slots.length) {
459
617
  return null;
460
618
  }
461
619
 
462
- const insideSlot = slots.find((slot) => isPointInsideRect(point, slot.rect));
463
- if (insideSlot) {
464
- 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;
465
635
  }
466
636
 
467
637
  let closest: LayoutSlot | null = null;
468
638
  let minDistance = Number.POSITIVE_INFINITY;
469
639
  slots.forEach((slot) => {
470
640
  const distance = distanceToRect(point, slot.rect);
471
- if (distance < minDistance) {
641
+ if (
642
+ distance < minDistance ||
643
+ (distance === minDistance && closest && slotPriority[slot.type] > slotPriority[closest.type])
644
+ ) {
472
645
  minDistance = distance;
473
646
  closest = slot;
474
647
  }
@@ -528,7 +701,10 @@ const toIntSizes = (weights: number[], count: number): number[] => {
528
701
  return [];
529
702
  }
530
703
 
531
- 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
+ });
532
708
  const total = normalizedWeights.reduce((sum, weight) => sum + weight, 0) || count;
533
709
  const ratios = normalizedWeights.map((weight) => weight / total);
534
710
  const raw = ratios.map((ratio) => ratio * DEFAULT_GRID_COLUMNS);
@@ -569,6 +745,294 @@ const toIntSizes = (weights: number[], count: number): number[] => {
569
745
  return floors;
570
746
  };
571
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
+
572
1036
  const normalizeRowSizes = (rowId: string, layout: GridLayoutData) => {
573
1037
  const columns = layout.rows[rowId];
574
1038
  if (!columns || columns.length === 0) {
@@ -628,11 +1092,251 @@ const distributeSizesWithNewColumn = (
628
1092
  return toIntSizes(weights, columnCount);
629
1093
  };
630
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
+
631
1333
  export interface SimulateLayoutOptions {
632
1334
  slot: LayoutSlot;
633
1335
  sourceUid: string;
634
1336
  layout: GridLayoutData;
635
1337
  generateRowId?: () => string;
1338
+ generatedIds?: Map<string, string>;
1339
+ generateId?: (key: string) => string;
636
1340
  }
637
1341
 
638
1342
  export const simulateLayoutForSlot = ({
@@ -640,7 +1344,21 @@ export const simulateLayoutForSlot = ({
640
1344
  sourceUid,
641
1345
  layout,
642
1346
  generateRowId,
1347
+ generatedIds,
1348
+ generateId,
643
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
+
644
1362
  const cloned: GridLayoutData = {
645
1363
  rows: _.cloneDeep(layout.rows),
646
1364
  sizes: _.cloneDeep(layout.sizes),