@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.
@@ -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 ( params ) => {
50
- assertCompositionXmlUsesV4WidgetsOnly( params.xmlStructure );
51
- const { xmlStructure, elementConfig, stylesConfig, customCSS } = params;
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
+ }