@elementor/editor-canvas 4.2.0-926 → 4.2.0-928

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.
@@ -28,7 +28,7 @@ function getSvg( container: HTMLElement ): SVGSVGElement {
28
28
  describe( '<GridOutline />', () => {
29
29
  it( 'sizes the svg to the element rect', () => {
30
30
  const { container } = renderWithTheme(
31
- <GridOutline tracks={ makeTracks( { columns: [ 100 ] } ) } width={ 320 } height={ 200 } />
31
+ <GridOutline element={ null } tracks={ makeTracks( { columns: [ 100 ] } ) } width={ 320 } height={ 200 } />
32
32
  );
33
33
 
34
34
  const svg = getSvg( container );
@@ -40,6 +40,7 @@ describe( '<GridOutline />', () => {
40
40
  it( 'draws one line per unique boundary for an N×M grid', () => {
41
41
  const { container } = renderWithTheme(
42
42
  <GridOutline
43
+ element={ null }
43
44
  tracks={ makeTracks( { columns: [ 100, 100, 100 ], rows: [ 80, 80 ] } ) }
44
45
  width={ 300 }
45
46
  height={ 160 }
@@ -53,6 +54,7 @@ describe( '<GridOutline />', () => {
53
54
  it( 'snaps line coordinates to half pixels for crisp 1px strokes', () => {
54
55
  const { container } = renderWithTheme(
55
56
  <GridOutline
57
+ element={ null }
56
58
  tracks={ makeTracks( {
57
59
  columns: [ 100, 100 ],
58
60
  rows: [ 80 ],
@@ -72,6 +74,7 @@ describe( '<GridOutline />', () => {
72
74
  it( 'passes the resolved iframe border color through to each line', () => {
73
75
  const { container } = renderWithTheme(
74
76
  <GridOutline
77
+ element={ null }
75
78
  tracks={ makeTracks( { columns: [ 100 ], rows: [ 100 ], borderColor: '#abcdef' } ) }
76
79
  width={ 100 }
77
80
  height={ 100 }
@@ -87,7 +90,7 @@ describe( '<GridOutline />', () => {
87
90
 
88
91
  it( 'renders nothing when there are no tracks on either axis', () => {
89
92
  const { container } = renderWithTheme(
90
- <GridOutline tracks={ makeTracks() } width={ 100 } height={ 100 } />
93
+ <GridOutline element={ null } tracks={ makeTracks() } width={ 100 } height={ 100 } />
91
94
  );
92
95
 
93
96
  expect( container.querySelectorAll( 'line' ) ).toHaveLength( 0 );
@@ -95,10 +98,65 @@ describe( '<GridOutline />', () => {
95
98
  } );
96
99
  } );
97
100
 
101
+ describe( 'first-empty-cell indicator', () => {
102
+ it( 'renders a + glyph in the first empty cell when one exists', () => {
103
+ const element = document.createElement( 'div' );
104
+ document.body.appendChild( element );
105
+
106
+ const { container } = renderWithTheme(
107
+ <GridOutline
108
+ element={ element }
109
+ tracks={ makeTracks( { columns: [ 100, 100, 100 ], rows: [ 80, 80 ] } ) }
110
+ width={ 300 }
111
+ height={ 160 }
112
+ />
113
+ );
114
+
115
+ expect( container.querySelector( '.eicon-plus' ) ).not.toBeNull();
116
+ } );
117
+
118
+ it( 'does not render the + glyph when the grid is fully occupied', () => {
119
+ const element = document.createElement( 'div' );
120
+
121
+ for ( let i = 0; i < 6; i++ ) {
122
+ const child = document.createElement( 'div' );
123
+ child.classList.add( 'elementor-element' );
124
+ element.appendChild( child );
125
+ }
126
+
127
+ document.body.appendChild( element );
128
+
129
+ const { container } = renderWithTheme(
130
+ <GridOutline
131
+ element={ element }
132
+ tracks={ makeTracks( { columns: [ 100, 100, 100 ], rows: [ 80, 80 ] } ) }
133
+ width={ 300 }
134
+ height={ 160 }
135
+ />
136
+ );
137
+
138
+ expect( container.querySelector( '.eicon-plus' ) ).toBeNull();
139
+ } );
140
+
141
+ it( 'does not render the + glyph when no element is provided', () => {
142
+ const { container } = renderWithTheme(
143
+ <GridOutline
144
+ element={ null }
145
+ tracks={ makeTracks( { columns: [ 100 ], rows: [ 80 ] } ) }
146
+ width={ 100 }
147
+ height={ 80 }
148
+ />
149
+ );
150
+
151
+ expect( container.querySelector( '.eicon-plus' ) ).toBeNull();
152
+ } );
153
+ } );
154
+
98
155
  describe( 'with a gap', () => {
99
156
  it( 'draws one rect per cell so each cell has its own framed perimeter', () => {
100
157
  const { container } = renderWithTheme(
101
158
  <GridOutline
159
+ element={ null }
102
160
  tracks={ makeTracks( {
103
161
  columns: [ 100, 100, 100 ],
104
162
  rows: [ 80, 80 ],
@@ -117,6 +175,7 @@ describe( '<GridOutline />', () => {
117
175
  it( 'offsets cells past the gap', () => {
118
176
  const { container } = renderWithTheme(
119
177
  <GridOutline
178
+ element={ null }
120
179
  tracks={ makeTracks( {
121
180
  columns: [ 100, 100, 100 ],
122
181
  rows: [ 80 ],
@@ -134,6 +193,7 @@ describe( '<GridOutline />', () => {
134
193
  it( 'passes the resolved iframe border color through to each cell', () => {
135
194
  const { container } = renderWithTheme(
136
195
  <GridOutline
196
+ element={ null }
137
197
  tracks={ makeTracks( {
138
198
  columns: [ 100, 100 ],
139
199
  rows: [ 100 ],
@@ -0,0 +1,38 @@
1
+ import * as React from 'react';
2
+
3
+ import { type CellRect } from '../../utils/grid-outline-utils';
4
+
5
+ const GLYPH_SIZE = 19;
6
+
7
+ type Props = {
8
+ rect: CellRect;
9
+ color: string;
10
+ };
11
+
12
+ export function FirstEmptyCell( { rect, color }: Props ) {
13
+ const size = Math.min( GLYPH_SIZE, rect.width, rect.height );
14
+
15
+ if ( size <= 0 ) {
16
+ return null;
17
+ }
18
+
19
+ const centerX = rect.x + rect.width / 2;
20
+ const centerY = rect.y + rect.height / 2;
21
+
22
+ return (
23
+ <i
24
+ className="eicon-plus"
25
+ aria-hidden="true"
26
+ style={ {
27
+ position: 'absolute',
28
+ left: centerX,
29
+ top: centerY,
30
+ transform: 'translate(-50%, -50%)',
31
+ fontSize: size,
32
+ color,
33
+ lineHeight: 1,
34
+ pointerEvents: 'none',
35
+ } }
36
+ />
37
+ );
38
+ }
@@ -34,7 +34,7 @@ export const GridOutlineOverlay = ( { element, id, isSelected }: ElementOverlayP
34
34
  data-grid-outline={ id }
35
35
  role="presentation"
36
36
  >
37
- <GridOutline tracks={ tracks } width={ rect.width } height={ rect.height } />
37
+ <GridOutline element={ element } tracks={ tracks } width={ rect.width } height={ rect.height } />
38
38
  </Box>
39
39
  </FloatingPortal>
40
40
  );
@@ -1,25 +1,29 @@
1
1
  import * as React from 'react';
2
+ import { useMemo } from 'react';
2
3
 
3
4
  import { type GridTracks } from '../../hooks/use-grid-tracks';
4
- import { computeCellRects, computeGridLines, snapToHalfPixel } from '../../utils/grid-outline-utils';
5
+ import { findFirstEmptyCell } from '../../utils/find-first-empty-cell';
6
+ import { type CellRect, computeCellRects, computeGridLines, snapToHalfPixel } from '../../utils/grid-outline-utils';
5
7
  import { Cell } from './cell';
8
+ import { FirstEmptyCell } from './first-empty-cell';
6
9
  import { Line } from './line';
7
10
 
8
11
  type Props = {
12
+ element: HTMLElement | null;
9
13
  tracks: GridTracks;
10
14
  width: number;
11
15
  height: number;
12
16
  };
13
17
 
14
- const renderCells = ( tracks: GridTracks, width: number, height: number ) =>
15
- computeCellRects( tracks, width, height ).map( ( cell, i ) => (
18
+ const renderCells = ( cells: CellRect[], color: string ) =>
19
+ cells.map( ( cell, i ) => (
16
20
  <Cell
17
21
  key={ i }
18
22
  x={ snapToHalfPixel( cell.x ) }
19
23
  y={ snapToHalfPixel( cell.y ) }
20
24
  width={ Math.round( cell.width ) }
21
25
  height={ Math.round( cell.height ) }
22
- color={ tracks.borderColor }
26
+ color={ color }
23
27
  />
24
28
  ) );
25
29
 
@@ -50,17 +54,29 @@ const renderLines = ( tracks: GridTracks, width: number, height: number ) => {
50
54
  ];
51
55
  };
52
56
 
53
- export function GridOutline( { tracks, width, height }: Props ) {
57
+ export function GridOutline( { element, tracks, width, height }: Props ) {
58
+ const cells = useMemo( () => computeCellRects( tracks, width, height ), [ tracks, width, height ] );
54
59
  const hasGap = tracks.columnGap > 0 || tracks.rowGap > 0;
60
+ const firstEmpty = useMemo(
61
+ () => findFirstEmptyCell( element, tracks.columns.length, tracks.rows.length ),
62
+ [ element, tracks ]
63
+ );
64
+ const emptyCellRect =
65
+ firstEmpty && tracks.columns.length > 0
66
+ ? cells[ firstEmpty.row * tracks.columns.length + firstEmpty.col ]
67
+ : null;
55
68
 
56
69
  return (
57
- <svg
58
- width={ width }
59
- height={ height }
60
- style={ { position: 'absolute', inset: 0, overflow: 'visible' } }
61
- xmlns="http://www.w3.org/2000/svg"
62
- >
63
- { hasGap ? renderCells( tracks, width, height ) : renderLines( tracks, width, height ) }
64
- </svg>
70
+ <>
71
+ <svg
72
+ width={ width }
73
+ height={ height }
74
+ style={ { position: 'absolute', inset: 0, overflow: 'visible' } }
75
+ xmlns="http://www.w3.org/2000/svg"
76
+ >
77
+ { hasGap ? renderCells( cells, tracks.borderColor ) : renderLines( tracks, width, height ) }
78
+ </svg>
79
+ { emptyCellRect && <FirstEmptyCell rect={ emptyCellRect } color={ tracks.borderColor } /> }
80
+ </>
65
81
  );
66
82
  }
@@ -0,0 +1,30 @@
1
+ const TOOLBAR_SELECTOR = ':scope > .elementor-element-overlay > .elementor-editor-element-settings';
2
+ const WING_OVERHANG = 14;
3
+ const OUTLINE_OVERHANG = 3;
4
+
5
+ export type ToolbarCutoutRect = { x: number; y: number; width: number; height: number };
6
+
7
+ export function useToolbarRect( element: HTMLElement | null, rect: DOMRect ): ToolbarCutoutRect | null {
8
+ if ( ! element ) {
9
+ return null;
10
+ }
11
+
12
+ const toolbar = element.querySelector( TOOLBAR_SELECTOR );
13
+
14
+ if ( ! toolbar ) {
15
+ return null;
16
+ }
17
+
18
+ const toolbarRect = toolbar.getBoundingClientRect();
19
+
20
+ if ( toolbarRect.width === 0 && toolbarRect.height === 0 ) {
21
+ return null;
22
+ }
23
+
24
+ return {
25
+ x: toolbarRect.left - rect.left - WING_OVERHANG,
26
+ y: toolbarRect.top - rect.top - OUTLINE_OVERHANG,
27
+ width: toolbarRect.width + WING_OVERHANG * 2,
28
+ height: toolbarRect.height + OUTLINE_OVERHANG * 2,
29
+ };
30
+ }
@@ -40,8 +40,11 @@ function createPromotionView( BaseView: typeof ElementView ): typeof ElementView
40
40
  _afterRender() {
41
41
  super._afterRender();
42
42
 
43
- this.$el.off( 'click', '.e-form-placeholder__remove-btn' );
44
- this.$el.on( 'click', '.e-form-placeholder__remove-btn', ( e: Event ) => {
43
+ const removeBtnSelector = '.e-pro-promotion-placeholder__remove-btn';
44
+ const unlockBtnSelector = '.e-pro-promotion-placeholder__unlock-btn';
45
+
46
+ this.$el.off( 'click', removeBtnSelector );
47
+ this.$el.on( 'click', removeBtnSelector, ( e: Event ) => {
45
48
  e.preventDefault();
46
49
  e.stopPropagation();
47
50
 
@@ -51,8 +54,8 @@ function createPromotionView( BaseView: typeof ElementView ): typeof ElementView
51
54
  );
52
55
  } );
53
56
 
54
- this.$el.off( 'click', '.e-form-placeholder__unlock-btn' );
55
- this.$el.on( 'click', '.e-form-placeholder__unlock-btn', ( e: Event ) => {
57
+ this.$el.off( 'click', unlockBtnSelector );
58
+ this.$el.on( 'click', unlockBtnSelector, ( e: Event ) => {
56
59
  e.stopPropagation();
57
60
  } );
58
61
  }
@@ -75,8 +78,8 @@ function createPromotionView( BaseView: typeof ElementView ): typeof ElementView
75
78
 
76
79
  onDestroy( ...args: unknown[] ) {
77
80
  super.onDestroy( ...args );
78
- this.$el.off( 'click', '.e-form-placeholder__remove-btn' );
79
- this.$el.off( 'click', '.e-form-placeholder__unlock-btn' );
81
+ this.$el.off( 'click', '.e-pro-promotion-placeholder__remove-btn' );
82
+ this.$el.off( 'click', '.e-pro-promotion-placeholder__unlock-btn' );
80
83
  }
81
84
  } as unknown as typeof ElementView;
82
85
  }
@@ -0,0 +1,132 @@
1
+ import { findFirstEmptyCell } from '../find-first-empty-cell';
2
+
3
+ type ChildSpec = {
4
+ gridColumnStart?: string;
5
+ gridColumnEnd?: string;
6
+ gridRowStart?: string;
7
+ gridRowEnd?: string;
8
+ display?: string;
9
+ };
10
+
11
+ function makeGrid( {
12
+ autoFlow = 'row',
13
+ children = [],
14
+ }: { autoFlow?: string; children?: ChildSpec[] } = {} ): HTMLElement {
15
+ const element = document.createElement( 'div' );
16
+ element.style.gridAutoFlow = autoFlow;
17
+
18
+ for ( const spec of children ) {
19
+ const child = document.createElement( 'div' );
20
+ child.classList.add( 'elementor-element' );
21
+
22
+ if ( spec.gridColumnStart ) {
23
+ child.style.gridColumnStart = spec.gridColumnStart;
24
+ }
25
+ if ( spec.gridColumnEnd ) {
26
+ child.style.gridColumnEnd = spec.gridColumnEnd;
27
+ }
28
+ if ( spec.gridRowStart ) {
29
+ child.style.gridRowStart = spec.gridRowStart;
30
+ }
31
+ if ( spec.gridRowEnd ) {
32
+ child.style.gridRowEnd = spec.gridRowEnd;
33
+ }
34
+ if ( spec.display ) {
35
+ child.style.display = spec.display;
36
+ }
37
+
38
+ element.appendChild( child );
39
+ }
40
+
41
+ document.body.appendChild( element );
42
+ return element;
43
+ }
44
+
45
+ describe( 'findFirstEmptyCell', () => {
46
+ it( 'returns null when the element is null', () => {
47
+ expect( findFirstEmptyCell( null, 3, 3 ) ).toBeNull();
48
+ } );
49
+
50
+ it( 'returns null when either axis has zero tracks', () => {
51
+ const element = makeGrid();
52
+ expect( findFirstEmptyCell( element, 0, 3 ) ).toBeNull();
53
+ expect( findFirstEmptyCell( element, 3, 0 ) ).toBeNull();
54
+ } );
55
+
56
+ it( 'returns the first cell when the grid is empty', () => {
57
+ const element = makeGrid();
58
+ expect( findFirstEmptyCell( element, 3, 2 ) ).toEqual( { row: 0, col: 0 } );
59
+ } );
60
+
61
+ it( 'walks row-major when grid-auto-flow is row', () => {
62
+ const element = makeGrid( {
63
+ autoFlow: 'row',
64
+ children: [ {}, {} ],
65
+ } );
66
+ expect( findFirstEmptyCell( element, 3, 2 ) ).toEqual( { row: 0, col: 2 } );
67
+ } );
68
+
69
+ it( 'walks column-major when grid-auto-flow is column', () => {
70
+ const element = makeGrid( {
71
+ autoFlow: 'column',
72
+ children: [ {}, {} ],
73
+ } );
74
+ expect( findFirstEmptyCell( element, 3, 2 ) ).toEqual( { row: 0, col: 1 } );
75
+ } );
76
+
77
+ it( 'honors explicit grid-column-start / grid-row-start', () => {
78
+ const element = makeGrid( {
79
+ children: [ { gridColumnStart: '3', gridRowStart: '1' } ],
80
+ } );
81
+ expect( findFirstEmptyCell( element, 3, 2 ) ).toEqual( { row: 0, col: 0 } );
82
+ } );
83
+
84
+ it( 'skips cells covered by an explicit span', () => {
85
+ const element = makeGrid( {
86
+ children: [ { gridColumnStart: '1', gridColumnEnd: 'span 2' } ],
87
+ } );
88
+ expect( findFirstEmptyCell( element, 3, 2 ) ).toEqual( { row: 0, col: 2 } );
89
+ } );
90
+
91
+ it( 'skips rows covered by a row span', () => {
92
+ const element = makeGrid( {
93
+ children: [ { gridRowStart: '1', gridRowEnd: 'span 2' } ],
94
+ } );
95
+ expect( findFirstEmptyCell( element, 2, 2 ) ).toEqual( { row: 0, col: 1 } );
96
+ } );
97
+
98
+ it( 'auto-places remaining children around explicit placements', () => {
99
+ const element = makeGrid( {
100
+ children: [ { gridColumnStart: '2', gridRowStart: '1' }, {} ],
101
+ } );
102
+ expect( findFirstEmptyCell( element, 3, 2 ) ).toEqual( { row: 0, col: 2 } );
103
+ } );
104
+
105
+ it( 'returns null when the grid is fully occupied', () => {
106
+ const element = makeGrid( {
107
+ children: [ {}, {}, {}, {}, {}, {} ],
108
+ } );
109
+ expect( findFirstEmptyCell( element, 3, 2 ) ).toBeNull();
110
+ } );
111
+
112
+ it( 'ignores children with display: none', () => {
113
+ const element = makeGrid( {
114
+ children: [ { display: 'none' } ],
115
+ } );
116
+ expect( findFirstEmptyCell( element, 2, 1 ) ).toEqual( { row: 0, col: 0 } );
117
+ } );
118
+
119
+ it( 'ignores scaffolding children that are not .elementor-element', () => {
120
+ const element = makeGrid( { children: [ {} ] } );
121
+
122
+ const overlay = document.createElement( 'div' );
123
+ overlay.classList.add( 'elementor-element-overlay' );
124
+ element.insertBefore( overlay, element.firstChild );
125
+
126
+ const emptyView = document.createElement( 'div' );
127
+ emptyView.classList.add( 'elementor-empty-view' );
128
+ element.appendChild( emptyView );
129
+
130
+ expect( findFirstEmptyCell( element, 3, 1 ) ).toEqual( { row: 0, col: 1 } );
131
+ } );
132
+ } );
@@ -0,0 +1,11 @@
1
+ type Size = { width: number; height: number };
2
+ type CutoutRect = { x: number; y: number; width: number; height: number };
3
+
4
+ export function rectCutoutClipPath( outer: Size, inner: CutoutRect ): string {
5
+ const { width: ow, height: oh } = outer;
6
+ const { x, y, width: iw, height: ih } = inner;
7
+
8
+ return `path(evenodd, 'M 0 0 L ${ ow } 0 L ${ ow } ${ oh } L 0 ${ oh } Z M ${ x } ${ y } L ${ x + iw } ${ y } L ${
9
+ x + iw
10
+ } ${ y + ih } L ${ x } ${ y + ih } L ${ x } ${ y } Z')`;
11
+ }
@@ -0,0 +1,226 @@
1
+ export type EmptyCell = {
2
+ row: number;
3
+ col: number;
4
+ };
5
+
6
+ type LineValue = 'auto' | number | { kind: 'span'; n: number };
7
+
8
+ type ResolvedPlacement = {
9
+ start: number | null;
10
+ span: number;
11
+ };
12
+
13
+ export function findFirstEmptyCell(
14
+ element: HTMLElement | null,
15
+ columnCount: number,
16
+ rowCount: number
17
+ ): EmptyCell | null {
18
+ if ( ! element || columnCount === 0 || rowCount === 0 ) {
19
+ return null;
20
+ }
21
+
22
+ const previewWindow = element.ownerDocument?.defaultView;
23
+
24
+ if ( ! previewWindow ) {
25
+ return null;
26
+ }
27
+
28
+ const containerStyle = previewWindow.getComputedStyle( element );
29
+ const flowsByColumn = containerStyle.gridAutoFlow.trim().startsWith( 'column' );
30
+
31
+ const matrix: boolean[][] = Array.from( { length: rowCount }, () => new Array( columnCount ).fill( false ) );
32
+
33
+ const explicit: Array< { col: number | null; colSpan: number; row: number | null; rowSpan: number } > = [];
34
+ const autoPlaced: Array< { colSpan: number; rowSpan: number } > = [];
35
+
36
+ for ( const child of Array.from( element.children ) ) {
37
+ if ( ! child.classList.contains( 'elementor-element' ) ) {
38
+ continue;
39
+ }
40
+
41
+ const style = previewWindow.getComputedStyle( child );
42
+
43
+ if ( style.display === 'none' ) {
44
+ continue;
45
+ }
46
+
47
+ const col = resolvePlacement( style.gridColumnStart, style.gridColumnEnd );
48
+ const row = resolvePlacement( style.gridRowStart, style.gridRowEnd );
49
+
50
+ if ( col.start !== null || row.start !== null ) {
51
+ explicit.push( { col: col.start, colSpan: col.span, row: row.start, rowSpan: row.span } );
52
+ } else {
53
+ autoPlaced.push( { colSpan: col.span, rowSpan: row.span } );
54
+ }
55
+ }
56
+
57
+ for ( const child of explicit ) {
58
+ fillMatrix( matrix, child.col ?? 0, child.row ?? 0, child.colSpan, child.rowSpan );
59
+ }
60
+
61
+ for ( const child of autoPlaced ) {
62
+ const slot = findNextFreeSlot( matrix, child.colSpan, child.rowSpan, flowsByColumn );
63
+
64
+ if ( slot ) {
65
+ fillMatrix( matrix, slot.col, slot.row, child.colSpan, child.rowSpan );
66
+ }
67
+ }
68
+
69
+ return scanFirstEmpty( matrix, flowsByColumn );
70
+ }
71
+
72
+ function resolvePlacement( startRaw: string, endRaw: string ): ResolvedPlacement {
73
+ const start = parseLineValue( startRaw );
74
+ const end = parseLineValue( endRaw );
75
+
76
+ if ( typeof start === 'number' ) {
77
+ const zeroIndexedStart = start - 1;
78
+
79
+ if ( typeof end === 'number' ) {
80
+ return { start: zeroIndexedStart, span: Math.max( 1, end - start ) };
81
+ }
82
+
83
+ if ( isSpan( end ) ) {
84
+ return { start: zeroIndexedStart, span: end.n };
85
+ }
86
+
87
+ return { start: zeroIndexedStart, span: 1 };
88
+ }
89
+
90
+ if ( isSpan( start ) ) {
91
+ if ( typeof end === 'number' ) {
92
+ const zeroIndexedStart = end - 1 - start.n;
93
+ return { start: zeroIndexedStart >= 0 ? zeroIndexedStart : null, span: start.n };
94
+ }
95
+
96
+ return { start: null, span: start.n };
97
+ }
98
+
99
+ if ( typeof end === 'number' ) {
100
+ const zeroIndexedStart = end - 2;
101
+ return { start: zeroIndexedStart >= 0 ? zeroIndexedStart : null, span: 1 };
102
+ }
103
+
104
+ if ( isSpan( end ) ) {
105
+ return { start: null, span: end.n };
106
+ }
107
+
108
+ return { start: null, span: 1 };
109
+ }
110
+
111
+ function parseLineValue( raw: string ): LineValue {
112
+ const trimmed = raw.trim();
113
+
114
+ if ( trimmed === '' || trimmed === 'auto' ) {
115
+ return 'auto';
116
+ }
117
+
118
+ const spanMatch = trimmed.match( /^span\s+(\d+)$/ );
119
+
120
+ if ( spanMatch ) {
121
+ const n = parseInt( spanMatch[ 1 ], 10 );
122
+ return { kind: 'span', n: Math.max( 1, n ) };
123
+ }
124
+
125
+ const parsed = parseInt( trimmed, 10 );
126
+
127
+ if ( Number.isFinite( parsed ) && parsed > 0 ) {
128
+ return parsed;
129
+ }
130
+
131
+ return 'auto';
132
+ }
133
+
134
+ function isSpan( value: LineValue ): value is { kind: 'span'; n: number } {
135
+ return typeof value === 'object' && value !== null && 'kind' in value && value.kind === 'span';
136
+ }
137
+
138
+ function fillMatrix( matrix: boolean[][], col: number, row: number, colSpan: number, rowSpan: number ): void {
139
+ const rows = matrix.length;
140
+ const cols = rows > 0 ? matrix[ 0 ].length : 0;
141
+
142
+ const startRow = Math.max( 0, row );
143
+ const startCol = Math.max( 0, col );
144
+ const endRow = Math.min( rows, row + rowSpan );
145
+ const endCol = Math.min( cols, col + colSpan );
146
+
147
+ for ( let r = startRow; r < endRow; r++ ) {
148
+ for ( let c = startCol; c < endCol; c++ ) {
149
+ matrix[ r ][ c ] = true;
150
+ }
151
+ }
152
+ }
153
+
154
+ function findNextFreeSlot(
155
+ matrix: boolean[][],
156
+ colSpan: number,
157
+ rowSpan: number,
158
+ flowsByColumn: boolean
159
+ ): EmptyCell | null {
160
+ const rows = matrix.length;
161
+ const cols = rows > 0 ? matrix[ 0 ].length : 0;
162
+
163
+ const maxCol = cols - colSpan;
164
+ const maxRow = rows - rowSpan;
165
+
166
+ if ( maxCol < 0 || maxRow < 0 ) {
167
+ return null;
168
+ }
169
+
170
+ if ( flowsByColumn ) {
171
+ for ( let col = 0; col <= maxCol; col++ ) {
172
+ for ( let row = 0; row <= maxRow; row++ ) {
173
+ if ( canFit( matrix, col, row, colSpan, rowSpan ) ) {
174
+ return { row, col };
175
+ }
176
+ }
177
+ }
178
+ } else {
179
+ for ( let row = 0; row <= maxRow; row++ ) {
180
+ for ( let col = 0; col <= maxCol; col++ ) {
181
+ if ( canFit( matrix, col, row, colSpan, rowSpan ) ) {
182
+ return { row, col };
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ return null;
189
+ }
190
+
191
+ function canFit( matrix: boolean[][], col: number, row: number, colSpan: number, rowSpan: number ): boolean {
192
+ for ( let r = row; r < row + rowSpan; r++ ) {
193
+ for ( let c = col; c < col + colSpan; c++ ) {
194
+ if ( matrix[ r ][ c ] ) {
195
+ return false;
196
+ }
197
+ }
198
+ }
199
+
200
+ return true;
201
+ }
202
+
203
+ function scanFirstEmpty( matrix: boolean[][], flowsByColumn: boolean ): EmptyCell | null {
204
+ const rows = matrix.length;
205
+ const cols = rows > 0 ? matrix[ 0 ].length : 0;
206
+
207
+ if ( flowsByColumn ) {
208
+ for ( let col = 0; col < cols; col++ ) {
209
+ for ( let row = 0; row < rows; row++ ) {
210
+ if ( ! matrix[ row ][ col ] ) {
211
+ return { row, col };
212
+ }
213
+ }
214
+ }
215
+ } else {
216
+ for ( let row = 0; row < rows; row++ ) {
217
+ for ( let col = 0; col < cols; col++ ) {
218
+ if ( ! matrix[ row ][ col ] ) {
219
+ return { row, col };
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ return null;
226
+ }