@elementor/editor-canvas 4.2.0-898 → 4.2.0-900
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +511 -278
- package/dist/index.mjs +390 -157
- package/package.json +18 -18
- package/src/components/elements-overlays.tsx +14 -1
- package/src/components/grid-outline/__tests__/grid-outline-overlay.test.tsx +151 -0
- package/src/components/grid-outline/__tests__/grid-outline.test.tsx +131 -0
- package/src/components/grid-outline/grid-outline-line.tsx +27 -0
- package/src/components/grid-outline/grid-outline-overlay.tsx +41 -0
- package/src/components/grid-outline/grid-outline.tsx +45 -0
- package/src/components/grid-outline/index.ts +1 -0
- package/src/hooks/__tests__/use-grid-tracks.test.ts +152 -0
- package/src/hooks/use-grid-tracks.ts +52 -0
- package/src/mcp/tools/build-composition/__tests__/xml-leaf-wrapper.test.ts +117 -0
- package/src/mcp/tools/build-composition/tool.ts +12 -7
- package/src/mcp/tools/build-composition/xml-leaf-wrapper.ts +68 -0
- package/src/utils/__tests__/grid-outline-utils.test.ts +142 -0
- package/src/utils/grid-outline-utils.ts +70 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
import { parseTrackList, toPx } from '../utils/grid-outline-utils';
|
|
4
|
+
|
|
5
|
+
export type GridTracks = {
|
|
6
|
+
columns: number[];
|
|
7
|
+
rows: number[];
|
|
8
|
+
columnGap: number;
|
|
9
|
+
rowGap: number;
|
|
10
|
+
padding: { top: number; right: number; bottom: number; left: number };
|
|
11
|
+
borderColor: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const EMPTY: GridTracks = {
|
|
15
|
+
columns: [],
|
|
16
|
+
rows: [],
|
|
17
|
+
columnGap: 0,
|
|
18
|
+
rowGap: 0,
|
|
19
|
+
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
20
|
+
borderColor: '',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function useGridTracks( element: HTMLElement | null, rect: DOMRect ): GridTracks {
|
|
24
|
+
return useMemo( () => {
|
|
25
|
+
if ( ! element ) {
|
|
26
|
+
return EMPTY;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const previewWindow = element.ownerDocument?.defaultView;
|
|
30
|
+
|
|
31
|
+
if ( ! previewWindow ) {
|
|
32
|
+
return EMPTY;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const computedStyle = previewWindow.getComputedStyle( element );
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
columns: parseTrackList( computedStyle.gridTemplateColumns ),
|
|
39
|
+
rows: parseTrackList( computedStyle.gridTemplateRows ),
|
|
40
|
+
columnGap: toPx( computedStyle.columnGap ),
|
|
41
|
+
rowGap: toPx( computedStyle.rowGap ),
|
|
42
|
+
padding: {
|
|
43
|
+
top: toPx( computedStyle.paddingTop ),
|
|
44
|
+
right: toPx( computedStyle.paddingRight ),
|
|
45
|
+
bottom: toPx( computedStyle.paddingBottom ),
|
|
46
|
+
left: toPx( computedStyle.paddingLeft ),
|
|
47
|
+
},
|
|
48
|
+
borderColor: computedStyle.getPropertyValue( '--e-a-border-color-bold' ).trim(),
|
|
49
|
+
};
|
|
50
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
|
+
}, [ element, rect.width, rect.height ] );
|
|
52
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type V1ElementConfig } from '@elementor/editor-elements';
|
|
2
|
+
|
|
3
|
+
import { adaptLeafRootParams, type BuildCompositionParams, DIV_BLOCK_TAG, ZERO_SPACING } from '../xml-leaf-wrapper';
|
|
4
|
+
|
|
5
|
+
const LEAF_WIDGET_TAG = 'e-heading';
|
|
6
|
+
const CONTAINER_TAG = 'e-div-block';
|
|
7
|
+
const DIV_BLOCK_TITLE = 'Div Block';
|
|
8
|
+
|
|
9
|
+
const makeWidgetsCache = (
|
|
10
|
+
overrides: Record< string, Partial< V1ElementConfig > > = {}
|
|
11
|
+
): Record< string, V1ElementConfig > =>
|
|
12
|
+
( {
|
|
13
|
+
[ LEAF_WIDGET_TAG ]: { title: 'Heading', controls: {}, elType: 'widget' },
|
|
14
|
+
[ CONTAINER_TAG ]: { title: DIV_BLOCK_TITLE, controls: {}, elType: 'e-div-block' },
|
|
15
|
+
...overrides,
|
|
16
|
+
} ) as Record< string, V1ElementConfig >;
|
|
17
|
+
|
|
18
|
+
const parseXml = ( xml: string ) => new DOMParser().parseFromString( xml, 'application/xml' );
|
|
19
|
+
|
|
20
|
+
const expectElementAttribute = ( element: Element, attribute: string, value: string ) => {
|
|
21
|
+
// eslint-disable-next-line jest-dom/prefer-to-have-attribute -- XML Element, not HTMLElement
|
|
22
|
+
expect( element.getAttribute( attribute ) ).toBe( value );
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const makeParams = ( xmlStructure: string, overrides = {} ) =>
|
|
26
|
+
( {
|
|
27
|
+
xmlStructure,
|
|
28
|
+
stylesConfig: {},
|
|
29
|
+
elementConfig: {},
|
|
30
|
+
widgetsCache: makeWidgetsCache(),
|
|
31
|
+
...overrides,
|
|
32
|
+
} ) as BuildCompositionParams;
|
|
33
|
+
|
|
34
|
+
describe( 'adaptLeafRootParams', () => {
|
|
35
|
+
it( 'wraps a leaf widget root in a div-block', () => {
|
|
36
|
+
// Arrange
|
|
37
|
+
const params = makeParams( `<${ LEAF_WIDGET_TAG } configuration-id="heading-1" />` );
|
|
38
|
+
|
|
39
|
+
// Act
|
|
40
|
+
const result = adaptLeafRootParams( params );
|
|
41
|
+
|
|
42
|
+
// Assert
|
|
43
|
+
const doc = parseXml( result.xmlStructure );
|
|
44
|
+
expect( doc.documentElement.tagName ).toBe( DIV_BLOCK_TAG );
|
|
45
|
+
expect( doc.documentElement.children[ 0 ].tagName ).toBe( LEAF_WIDGET_TAG );
|
|
46
|
+
expectElementAttribute( doc.documentElement, 'configuration-id', DIV_BLOCK_TITLE );
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
it( 'preserves existing stylesConfig entries when wrapping', () => {
|
|
50
|
+
// Arrange
|
|
51
|
+
const params = makeParams( `<${ LEAF_WIDGET_TAG } configuration-id="heading-1" />`, {
|
|
52
|
+
stylesConfig: { 'heading-1': { color: 'red' } },
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
// Act
|
|
56
|
+
const result = adaptLeafRootParams( params );
|
|
57
|
+
|
|
58
|
+
// Assert
|
|
59
|
+
expect( result.stylesConfig[ 'heading-1' ] ).toEqual( { color: 'red' } );
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
it( 'does not wrap when root is a container', () => {
|
|
63
|
+
// Arrange
|
|
64
|
+
const params = makeParams(
|
|
65
|
+
`<${ CONTAINER_TAG } configuration-id="container-1"><${ LEAF_WIDGET_TAG } configuration-id="heading-1" /></${ CONTAINER_TAG }>`
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Act
|
|
69
|
+
const result = adaptLeafRootParams( params );
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
72
|
+
expect( result ).toBe( params );
|
|
73
|
+
} );
|
|
74
|
+
|
|
75
|
+
it( 'preserves extra params fields unchanged', () => {
|
|
76
|
+
// Arrange
|
|
77
|
+
const params = makeParams( `<${ LEAF_WIDGET_TAG } configuration-id="heading-1" />`, {
|
|
78
|
+
elementConfig: { 'heading-1': { title: 'Hello' } },
|
|
79
|
+
stylesConfig: { 'heading-1': { color: 'red' } },
|
|
80
|
+
} );
|
|
81
|
+
|
|
82
|
+
// Act
|
|
83
|
+
const result = adaptLeafRootParams( params );
|
|
84
|
+
|
|
85
|
+
// Assert
|
|
86
|
+
expect( result.stylesConfig ).toEqual( {
|
|
87
|
+
'Div Block': {
|
|
88
|
+
margin: ZERO_SPACING,
|
|
89
|
+
padding: ZERO_SPACING,
|
|
90
|
+
},
|
|
91
|
+
...params.stylesConfig,
|
|
92
|
+
} );
|
|
93
|
+
} );
|
|
94
|
+
|
|
95
|
+
it( 'preserves leaf element attributes when wrapping', () => {
|
|
96
|
+
// Arrange
|
|
97
|
+
const params = makeParams( `<${ LEAF_WIDGET_TAG } configuration-id="heading-1" />` );
|
|
98
|
+
|
|
99
|
+
// Act
|
|
100
|
+
const { xmlStructure } = adaptLeafRootParams( params );
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
103
|
+
const doc = parseXml( xmlStructure );
|
|
104
|
+
expectElementAttribute( doc.documentElement.children[ 0 ], 'configuration-id', 'heading-1' );
|
|
105
|
+
} );
|
|
106
|
+
|
|
107
|
+
it( 'returns unchanged params for unknown element types', () => {
|
|
108
|
+
// Arrange
|
|
109
|
+
const params = makeParams( '<unknown-widget configuration-id="w1" />' );
|
|
110
|
+
|
|
111
|
+
// Act
|
|
112
|
+
const result = adaptLeafRootParams( params );
|
|
113
|
+
|
|
114
|
+
// Assert
|
|
115
|
+
expect( result ).toBe( params );
|
|
116
|
+
} );
|
|
117
|
+
} );
|
|
@@ -12,9 +12,11 @@ import { CompositionBuilder } from '../../../composition-builder/composition-bui
|
|
|
12
12
|
import { AVAILABLE_WIDGETS_URI_V4 } from '../../resources/available-widgets-resource';
|
|
13
13
|
import { BEST_PRACTICES_URI, STYLE_SCHEMA_URI, WIDGET_SCHEMA_URI } from '../../resources/widgets-schema-resource';
|
|
14
14
|
import { doUpdateElementProperty } from '../../utils/do-update-element-property';
|
|
15
|
+
import { isWidgetAvailableForLLM } from '../../utils/element-data-util';
|
|
15
16
|
import { getCompositionTargetContainer } from '../../utils/get-composition-target-container';
|
|
16
17
|
import { BUILD_COMPOSITIONS_GUIDE_URI, generatePrompt } from './prompt';
|
|
17
18
|
import { inputSchema as schema, outputSchema } from './schema';
|
|
19
|
+
import { adaptLeafRootParams } from './xml-leaf-wrapper';
|
|
18
20
|
|
|
19
21
|
export const initBuildCompositionsTool = ( reg: MCPRegistryEntry ) => {
|
|
20
22
|
const { addTool, resource } = reg;
|
|
@@ -46,9 +48,13 @@ export const initBuildCompositionsTool = ( reg: MCPRegistryEntry ) => {
|
|
|
46
48
|
{ description: 'Available widgets for this tool', uri: AVAILABLE_WIDGETS_URI_V4 },
|
|
47
49
|
],
|
|
48
50
|
outputSchema,
|
|
49
|
-
handler: async (
|
|
50
|
-
assertCompositionXmlUsesV4WidgetsOnly(
|
|
51
|
-
const { xmlStructure, elementConfig, stylesConfig, customCSS } =
|
|
51
|
+
handler: async ( rawParams ) => {
|
|
52
|
+
assertCompositionXmlUsesV4WidgetsOnly( rawParams.xmlStructure );
|
|
53
|
+
const { xmlStructure, elementConfig, stylesConfig, customCSS } = adaptLeafRootParams( {
|
|
54
|
+
...rawParams,
|
|
55
|
+
widgetsCache: getWidgetsCache() ?? {},
|
|
56
|
+
} );
|
|
57
|
+
|
|
52
58
|
let generatedXML: string = '';
|
|
53
59
|
const errors: Error[] = [];
|
|
54
60
|
const rootContainers: V1Element[] = [];
|
|
@@ -164,16 +170,15 @@ function assertCompositionXmlUsesV4WidgetsOnly( xmlStructure: string ) {
|
|
|
164
170
|
for ( const node of doc.querySelectorAll( '*' ) ) {
|
|
165
171
|
const type = node.tagName;
|
|
166
172
|
const widgetData = widgetsCache[ type ];
|
|
173
|
+
|
|
167
174
|
if ( ! widgetData ) {
|
|
168
175
|
continue;
|
|
169
176
|
}
|
|
170
177
|
if ( widgetData.elType !== 'widget' ) {
|
|
171
178
|
continue;
|
|
172
179
|
}
|
|
173
|
-
if ( ! widgetData.atomic_props_schema ) {
|
|
174
|
-
throw new Error(
|
|
175
|
-
`This tool does not support V3 elements. Please use the elementor-v3-mcp tools instead for element type: ${ type }`
|
|
176
|
-
);
|
|
180
|
+
if ( ! isWidgetAvailableForLLM( widgetData ) || ! widgetData.atomic_props_schema ) {
|
|
181
|
+
throw new Error( `This tool does not support element type: ${ type }` );
|
|
177
182
|
}
|
|
178
183
|
}
|
|
179
184
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type V1ElementConfig } from '@elementor/editor-elements';
|
|
2
|
+
|
|
3
|
+
export const DIV_BLOCK_TAG = 'e-div-block';
|
|
4
|
+
|
|
5
|
+
export const ZERO_SPACING = {
|
|
6
|
+
$$type: 'size',
|
|
7
|
+
value: {
|
|
8
|
+
size: {
|
|
9
|
+
$$type: 'number',
|
|
10
|
+
value: 0,
|
|
11
|
+
},
|
|
12
|
+
unit: {
|
|
13
|
+
$$type: 'string',
|
|
14
|
+
value: 'px',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type BuildCompositionParams = {
|
|
20
|
+
xmlStructure: string;
|
|
21
|
+
stylesConfig: Record< string, Record< string, unknown > >;
|
|
22
|
+
widgetsCache: Record< string, V1ElementConfig >;
|
|
23
|
+
elementConfig: Record< string, Record< string, unknown > >;
|
|
24
|
+
[ key: string ]: unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function adaptLeafRootParams< T extends BuildCompositionParams >( params: T ): T {
|
|
28
|
+
const doc = new DOMParser().parseFromString( params.xmlStructure, 'application/xml' );
|
|
29
|
+
const rootElement = doc.documentElement;
|
|
30
|
+
|
|
31
|
+
if ( ! isLeafWidget( rootElement.tagName, params.widgetsCache ) ) {
|
|
32
|
+
return params;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const wrapperConfigId = getDivBlockWrapperConfigId( params.widgetsCache );
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
...params,
|
|
39
|
+
xmlStructure: serializeWrapped( doc, rootElement, wrapperConfigId ),
|
|
40
|
+
stylesConfig: {
|
|
41
|
+
...params.stylesConfig,
|
|
42
|
+
[ wrapperConfigId ]: {
|
|
43
|
+
margin: ZERO_SPACING,
|
|
44
|
+
padding: ZERO_SPACING,
|
|
45
|
+
...params.stylesConfig[ wrapperConfigId ],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getDivBlockWrapperConfigId( widgetsCache: Record< string, V1ElementConfig > ): string {
|
|
52
|
+
return widgetsCache[ DIV_BLOCK_TAG ]?.title ?? DIV_BLOCK_TAG;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isLeafWidget( tagName: string, widgetsCache: Record< string, V1ElementConfig > ): boolean {
|
|
56
|
+
return widgetsCache[ tagName ]?.elType === 'widget';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function serializeWrapped( doc: Document, rootElement: Element, wrapperConfigId: string ): string {
|
|
60
|
+
const wrapper = doc.createElement( DIV_BLOCK_TAG );
|
|
61
|
+
wrapper.setAttribute( 'configuration-id', wrapperConfigId );
|
|
62
|
+
wrapper.appendChild( rootElement.cloneNode( true ) );
|
|
63
|
+
|
|
64
|
+
const wrappedDoc = new DOMParser().parseFromString( `<${ DIV_BLOCK_TAG } />`, 'application/xml' );
|
|
65
|
+
wrappedDoc.replaceChild( wrapper, wrappedDoc.documentElement );
|
|
66
|
+
|
|
67
|
+
return new XMLSerializer().serializeToString( wrappedDoc );
|
|
68
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { type GridTracks } from '../../hooks/use-grid-tracks';
|
|
2
|
+
import { computeOutlineGeometry, parseTrackList, snapToHalfPixel, toPx } from '../grid-outline-utils';
|
|
3
|
+
|
|
4
|
+
function makeTracks( partial: Partial< GridTracks > = {} ): GridTracks {
|
|
5
|
+
return {
|
|
6
|
+
columns: [],
|
|
7
|
+
rows: [],
|
|
8
|
+
columnGap: 0,
|
|
9
|
+
rowGap: 0,
|
|
10
|
+
padding: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
11
|
+
borderColor: '',
|
|
12
|
+
...partial,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe( 'computeOutlineGeometry', () => {
|
|
17
|
+
it( 'returns empty boundary lists when there are no tracks', () => {
|
|
18
|
+
const geometry = computeOutlineGeometry( makeTracks(), 100, 100 );
|
|
19
|
+
|
|
20
|
+
expect( geometry.vertical ).toEqual( [] );
|
|
21
|
+
expect( geometry.horizontal ).toEqual( [] );
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
it( 'shifts boundaries by the padding offset on each axis', () => {
|
|
25
|
+
const tracks = makeTracks( {
|
|
26
|
+
columns: [ 100, 100 ],
|
|
27
|
+
rows: [ 80, 80 ],
|
|
28
|
+
padding: { top: 5, right: 0, bottom: 0, left: 20 },
|
|
29
|
+
} );
|
|
30
|
+
|
|
31
|
+
const geometry = computeOutlineGeometry( tracks, 300, 300 );
|
|
32
|
+
|
|
33
|
+
expect( geometry.vertical ).toEqual( [ 20, 120, 220 ] );
|
|
34
|
+
expect( geometry.horizontal ).toEqual( [ 5, 85, 165 ] );
|
|
35
|
+
} );
|
|
36
|
+
|
|
37
|
+
it( 'emits both edges of every gap between tracks', () => {
|
|
38
|
+
const tracks = makeTracks( {
|
|
39
|
+
columns: [ 100, 100, 100 ],
|
|
40
|
+
columnGap: 10,
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
const geometry = computeOutlineGeometry( tracks, 400, 100 );
|
|
44
|
+
|
|
45
|
+
// 0 — 100 [gap 10] 110 — 210 [gap 10] 220 — 320
|
|
46
|
+
expect( geometry.vertical ).toEqual( [ 0, 100, 110, 210, 220, 320 ] );
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
it( 'collapses gap boundaries when the gap is zero', () => {
|
|
50
|
+
const tracks = makeTracks( {
|
|
51
|
+
rows: [ 50, 50, 50 ],
|
|
52
|
+
rowGap: 0,
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
const geometry = computeOutlineGeometry( tracks, 100, 200 );
|
|
56
|
+
|
|
57
|
+
expect( geometry.horizontal ).toEqual( [ 0, 50, 100, 150 ] );
|
|
58
|
+
} );
|
|
59
|
+
|
|
60
|
+
it( 'handles uneven track sizes', () => {
|
|
61
|
+
const tracks = makeTracks( {
|
|
62
|
+
columns: [ 100, 200, 100 ],
|
|
63
|
+
columnGap: 5,
|
|
64
|
+
padding: { top: 0, right: 0, bottom: 0, left: 10 },
|
|
65
|
+
} );
|
|
66
|
+
|
|
67
|
+
const geometry = computeOutlineGeometry( tracks, 500, 100 );
|
|
68
|
+
|
|
69
|
+
// 10 — 110 [5] 115 — 315 [5] 320 — 420
|
|
70
|
+
expect( geometry.vertical ).toEqual( [ 10, 110, 115, 315, 320, 420 ] );
|
|
71
|
+
} );
|
|
72
|
+
|
|
73
|
+
it( 'derives the content rect from element size and padding', () => {
|
|
74
|
+
const tracks = makeTracks( {
|
|
75
|
+
padding: { top: 5, right: 8, bottom: 12, left: 20 },
|
|
76
|
+
} );
|
|
77
|
+
|
|
78
|
+
const geometry = computeOutlineGeometry( tracks, 300, 200 );
|
|
79
|
+
|
|
80
|
+
expect( geometry.top ).toBe( 5 );
|
|
81
|
+
expect( geometry.left ).toBe( 20 );
|
|
82
|
+
expect( geometry.right ).toBe( 292 );
|
|
83
|
+
expect( geometry.bottom ).toBe( 188 );
|
|
84
|
+
} );
|
|
85
|
+
} );
|
|
86
|
+
|
|
87
|
+
describe( 'snapToHalfPixel', () => {
|
|
88
|
+
it.each( [
|
|
89
|
+
[ 0, 0.5 ],
|
|
90
|
+
[ 0.4, 0.5 ],
|
|
91
|
+
[ 0.6, 1.5 ],
|
|
92
|
+
[ 99.2, 99.5 ],
|
|
93
|
+
[ 100, 100.5 ],
|
|
94
|
+
[ -0.4, 0.5 ],
|
|
95
|
+
[ -1.6, -1.5 ],
|
|
96
|
+
] )( 'snaps %p to %p for crisp 1px strokes', ( input, expected ) => {
|
|
97
|
+
expect( snapToHalfPixel( input ) ).toBe( expected );
|
|
98
|
+
} );
|
|
99
|
+
} );
|
|
100
|
+
|
|
101
|
+
describe( 'parseTrackList', () => {
|
|
102
|
+
it( 'returns an empty list for empty input', () => {
|
|
103
|
+
expect( parseTrackList( '' ) ).toEqual( [] );
|
|
104
|
+
} );
|
|
105
|
+
|
|
106
|
+
it( 'returns an empty list for the "none" keyword', () => {
|
|
107
|
+
expect( parseTrackList( 'none' ) ).toEqual( [] );
|
|
108
|
+
} );
|
|
109
|
+
|
|
110
|
+
it( 'parses a single resolved px track', () => {
|
|
111
|
+
expect( parseTrackList( '200px' ) ).toEqual( [ 200 ] );
|
|
112
|
+
} );
|
|
113
|
+
|
|
114
|
+
it( 'parses multiple resolved px tracks', () => {
|
|
115
|
+
expect( parseTrackList( '100px 200px 100px' ) ).toEqual( [ 100, 200, 100 ] );
|
|
116
|
+
} );
|
|
117
|
+
|
|
118
|
+
it( 'parses fractional pixel sizes', () => {
|
|
119
|
+
expect( parseTrackList( '99.5px 100.25px' ) ).toEqual( [ 99.5, 100.25 ] );
|
|
120
|
+
} );
|
|
121
|
+
|
|
122
|
+
it( 'collapses runs of whitespace between tracks', () => {
|
|
123
|
+
expect( parseTrackList( ' 100px 200px\t300px ' ) ).toEqual( [ 100, 200, 300 ] );
|
|
124
|
+
} );
|
|
125
|
+
|
|
126
|
+
it( 'drops zero-width entries (e.g. tracks resolved to 0)', () => {
|
|
127
|
+
expect( parseTrackList( '100px 0px 200px' ) ).toEqual( [ 100, 200 ] );
|
|
128
|
+
} );
|
|
129
|
+
} );
|
|
130
|
+
|
|
131
|
+
describe( 'toPx', () => {
|
|
132
|
+
it.each( [
|
|
133
|
+
[ '0px', 0 ],
|
|
134
|
+
[ '10px', 10 ],
|
|
135
|
+
[ '99.5px', 99.5 ],
|
|
136
|
+
[ 'normal', 0 ],
|
|
137
|
+
[ '', 0 ],
|
|
138
|
+
[ 'auto', 0 ],
|
|
139
|
+
] )( 'parses %p as %p', ( input, expected ) => {
|
|
140
|
+
expect( toPx( input ) ).toBe( expected );
|
|
141
|
+
} );
|
|
142
|
+
} );
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type GridTracks } from '../hooks/use-grid-tracks';
|
|
2
|
+
|
|
3
|
+
export type OutlineGeometry = {
|
|
4
|
+
vertical: number[];
|
|
5
|
+
horizontal: number[];
|
|
6
|
+
top: number;
|
|
7
|
+
bottom: number;
|
|
8
|
+
left: number;
|
|
9
|
+
right: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function computeOutlineGeometry( tracks: GridTracks, width: number, height: number ): OutlineGeometry {
|
|
13
|
+
const { columns, rows, columnGap, rowGap, padding } = tracks;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
vertical: computeBoundaries( columns, columnGap, padding.left ),
|
|
17
|
+
horizontal: computeBoundaries( rows, rowGap, padding.top ),
|
|
18
|
+
top: padding.top,
|
|
19
|
+
bottom: height - padding.bottom,
|
|
20
|
+
left: padding.left,
|
|
21
|
+
right: width - padding.right,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function computeBoundaries( sizes: number[], gap: number, offset: number ): number[] {
|
|
26
|
+
if ( sizes.length === 0 ) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const boundaries: number[] = [];
|
|
31
|
+
let cursor = offset;
|
|
32
|
+
|
|
33
|
+
for ( let i = 0; i < sizes.length; i++ ) {
|
|
34
|
+
if ( i === 0 ) {
|
|
35
|
+
boundaries.push( cursor );
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
cursor += sizes[ i ];
|
|
39
|
+
boundaries.push( cursor );
|
|
40
|
+
|
|
41
|
+
if ( i < sizes.length - 1 && gap > 0 ) {
|
|
42
|
+
cursor += gap;
|
|
43
|
+
boundaries.push( cursor );
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return boundaries;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function snapToHalfPixel( value: number ): number {
|
|
51
|
+
return Math.round( value ) + 0.5;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseTrackList( value: string ): number[] {
|
|
55
|
+
if ( ! value || value === 'none' ) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return value
|
|
60
|
+
.trim()
|
|
61
|
+
.split( /\s+/ )
|
|
62
|
+
.map( toPx )
|
|
63
|
+
.filter( ( n ) => n > 0 );
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function toPx( value: string ): number {
|
|
67
|
+
const parsed = parseFloat( value );
|
|
68
|
+
|
|
69
|
+
return Number.isFinite( parsed ) ? parsed : 0;
|
|
70
|
+
}
|