@elementor/editor-canvas 4.0.0-682 → 4.0.0-beta5
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.d.mts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +260 -79
- package/dist/index.mjs +261 -80
- package/package.json +19 -18
- package/src/components/__tests__/style-renderer.test.tsx +4 -0
- package/src/hooks/__tests__/use-style-items.test.ts +119 -0
- package/src/hooks/use-style-items.ts +39 -15
- package/src/init-settings-transformers.ts +2 -0
- package/src/legacy/create-nested-templated-element-type.ts +15 -2
- package/src/legacy/create-templated-element-type.ts +8 -0
- package/src/legacy/replacements/base.ts +4 -0
- package/src/legacy/replacements/inline-editing/canvas-inline-editor.tsx +49 -27
- package/src/legacy/replacements/inline-editing/inline-editing-elements.tsx +16 -10
- package/src/legacy/replacements/inline-editing/inline-editing-utils.ts +12 -1
- package/src/legacy/replacements/manager.ts +13 -0
- package/src/legacy/types.ts +4 -0
- package/src/mcp/canvas-mcp.ts +5 -7
- package/src/mcp/resources/breakpoints-resource.ts +11 -4
- package/src/mcp/resources/document-structure-resource.ts +18 -13
- package/src/mcp/tools/build-composition/tool.ts +5 -1
- package/src/mcp/utils/__tests__/get-composition-target-container.test.ts +59 -0
- package/src/mcp/utils/get-composition-target-container.ts +15 -0
- package/src/renderers/__tests__/create-styles-renderer.test.ts +117 -0
- package/src/renderers/create-styles-renderer.ts +13 -3
- package/src/transformers/shared/__tests__/svg-src-transformer.test.ts +184 -0
- package/src/transformers/shared/svg-src-transformer.ts +87 -0
- package/src/transformers/styles/__tests__/size-transformer.test.ts +24 -0
- package/src/transformers/styles/size-transformer.ts +3 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
2
|
+
|
|
1
3
|
import type { CreateTemplatedElementTypeOptions } from '../create-templated-element-type';
|
|
2
4
|
import { createTemplatedElementView } from '../create-templated-element-type';
|
|
3
5
|
import type { ElementType, ElementView, LegacyWindow, ReplacementSettings } from '../types';
|
|
@@ -34,11 +36,18 @@ export const createViewWithReplacements = ( options: CreateTemplatedElementTypeO
|
|
|
34
36
|
return class extends TemplatedView {
|
|
35
37
|
#replacement: ReplacementBaseInterface | null = null;
|
|
36
38
|
#config: ReplacementSettings;
|
|
39
|
+
#reactContainer: HTMLElement;
|
|
40
|
+
#reactRoot: Root;
|
|
37
41
|
|
|
38
42
|
constructor( ...args: unknown[] ) {
|
|
39
43
|
super( ...args );
|
|
40
44
|
const settings = this.model.get( 'settings' );
|
|
41
45
|
|
|
46
|
+
this.#reactContainer = this.el.ownerDocument.createElement( 'div' );
|
|
47
|
+
this.#reactContainer.style.display = 'none';
|
|
48
|
+
this.el.ownerDocument.body.appendChild( this.#reactContainer );
|
|
49
|
+
this.#reactRoot = createRoot( this.#reactContainer );
|
|
50
|
+
|
|
42
51
|
this.#config = {
|
|
43
52
|
getSetting: settings.get.bind( settings ),
|
|
44
53
|
setSetting: settings.set.bind( settings ),
|
|
@@ -46,6 +55,8 @@ export const createViewWithReplacements = ( options: CreateTemplatedElementTypeO
|
|
|
46
55
|
type: this?.model?.get( 'widgetType' ) ?? this.container?.model?.get( 'elType' ) ?? null,
|
|
47
56
|
id: this?.model?.get( 'id' ) ?? null,
|
|
48
57
|
refreshView: this.refreshView.bind( this ),
|
|
58
|
+
reactRoot: this.#reactRoot,
|
|
59
|
+
reactContainer: this.#reactContainer,
|
|
49
60
|
};
|
|
50
61
|
}
|
|
51
62
|
|
|
@@ -72,6 +83,8 @@ export const createViewWithReplacements = ( options: CreateTemplatedElementTypeO
|
|
|
72
83
|
|
|
73
84
|
onDestroy() {
|
|
74
85
|
this.#triggerAltMethod( 'onDestroy' );
|
|
86
|
+
this.#reactRoot.unmount();
|
|
87
|
+
this.#reactContainer.remove();
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
_afterRender() {
|
package/src/legacy/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Root } from 'react-dom/client';
|
|
1
2
|
import { type V1Element } from '@elementor/editor-elements';
|
|
2
3
|
import { type Props, type PropValue } from '@elementor/editor-props';
|
|
3
4
|
|
|
@@ -203,6 +204,7 @@ type BackboneCollection< Model extends object > = {
|
|
|
203
204
|
|
|
204
205
|
export type ElementModel = {
|
|
205
206
|
id: string;
|
|
207
|
+
originId?: string;
|
|
206
208
|
elType: string;
|
|
207
209
|
settings: BackboneModel< Props >;
|
|
208
210
|
editor_settings: Record< string, unknown >;
|
|
@@ -240,4 +242,6 @@ export type ReplacementSettings = {
|
|
|
240
242
|
id: string;
|
|
241
243
|
element: HTMLElement;
|
|
242
244
|
refreshView: () => void;
|
|
245
|
+
reactRoot: Root;
|
|
246
|
+
reactContainer: HTMLElement;
|
|
243
247
|
};
|
package/src/mcp/canvas-mcp.ts
CHANGED
|
@@ -10,14 +10,12 @@ import { initGetElementConfigTool } from './tools/get-element-config/tool';
|
|
|
10
10
|
export const initCanvasMcp = ( reg: MCPRegistryEntry ) => {
|
|
11
11
|
const { setMCPDescription } = reg;
|
|
12
12
|
setMCPDescription(
|
|
13
|
-
`Everything related to
|
|
13
|
+
`Everything related to V4 ( Atomic ) canvas.
|
|
14
14
|
# Canvas workflow for new compositions
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
- Build valid XML with minimal inline styles (layout/positioning only)
|
|
20
|
-
- Apply global classes to elements`
|
|
15
|
+
- Configure elements settings and styles
|
|
16
|
+
- Build compositions/sections out of V4 atomic elements using context aware designs using the website resources
|
|
17
|
+
- Get and retrieve element configuration values
|
|
18
|
+
`
|
|
21
19
|
);
|
|
22
20
|
initWidgetsSchemaResource( reg );
|
|
23
21
|
initDocumentStructureResource( reg );
|
|
@@ -5,7 +5,7 @@ import { v1ReadyEvent } from '@elementor/editor-v1-adapters';
|
|
|
5
5
|
export const BREAKPOINTS_SCHEMA_URI = 'elementor://breakpoints/list';
|
|
6
6
|
|
|
7
7
|
export const initBreakpointsResource = ( reg: MCPRegistryEntry ) => {
|
|
8
|
-
const {
|
|
8
|
+
const { resource, sendResourceUpdated } = reg;
|
|
9
9
|
|
|
10
10
|
const getBreakpointsList = () => {
|
|
11
11
|
const { breakpoints } = ( window as unknown as ExtendedWindow ).elementor?.config?.responsive || {};
|
|
@@ -34,9 +34,16 @@ export const initBreakpointsResource = ( reg: MCPRegistryEntry ) => {
|
|
|
34
34
|
],
|
|
35
35
|
} );
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
resource(
|
|
38
|
+
'breakpoints ',
|
|
39
|
+
BREAKPOINTS_SCHEMA_URI,
|
|
40
|
+
{
|
|
41
|
+
description: 'Breakpoints list.',
|
|
42
|
+
},
|
|
43
|
+
() => {
|
|
44
|
+
return buildResourceResponse();
|
|
45
|
+
}
|
|
46
|
+
);
|
|
40
47
|
|
|
41
48
|
window.addEventListener( v1ReadyEvent().name, () => {
|
|
42
49
|
sendResourceUpdated( {
|
|
@@ -39,7 +39,7 @@ type ElementorContainer = {
|
|
|
39
39
|
export const DOCUMENT_STRUCTURE_URI = 'elementor://document/structure';
|
|
40
40
|
|
|
41
41
|
export const initDocumentStructureResource = ( reg: MCPRegistryEntry ) => {
|
|
42
|
-
const {
|
|
42
|
+
const { resource, sendResourceUpdated } = reg;
|
|
43
43
|
|
|
44
44
|
let currentDocumentStructure: string | null = null;
|
|
45
45
|
|
|
@@ -69,18 +69,23 @@ export const initDocumentStructureResource = ( reg: MCPRegistryEntry ) => {
|
|
|
69
69
|
// Initialize on load
|
|
70
70
|
updateDocumentStructure();
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
72
|
+
resource(
|
|
73
|
+
'document-structure',
|
|
74
|
+
DOCUMENT_STRUCTURE_URI,
|
|
75
|
+
{
|
|
76
|
+
description: 'Document structure.',
|
|
77
|
+
},
|
|
78
|
+
async () => {
|
|
79
|
+
return {
|
|
80
|
+
contents: [
|
|
81
|
+
{
|
|
82
|
+
uri: DOCUMENT_STRUCTURE_URI,
|
|
83
|
+
text: JSON.stringify( getDocumentStructure(), null, 2 ),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
);
|
|
84
89
|
};
|
|
85
90
|
|
|
86
91
|
function getDocumentStructure() {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getCurrentDocument } from '@elementor/editor-documents';
|
|
1
2
|
import {
|
|
2
3
|
createElement,
|
|
3
4
|
deleteElement,
|
|
@@ -10,6 +11,7 @@ import { type MCPRegistryEntry } from '@elementor/editor-mcp';
|
|
|
10
11
|
import { CompositionBuilder } from '../../../composition-builder/composition-builder';
|
|
11
12
|
import { BEST_PRACTICES_URI, STYLE_SCHEMA_URI, WIDGET_SCHEMA_URI } from '../../resources/widgets-schema-resource';
|
|
12
13
|
import { doUpdateElementProperty } from '../../utils/do-update-element-property';
|
|
14
|
+
import { getCompositionTargetContainer } from '../../utils/get-composition-target-container';
|
|
13
15
|
import { generatePrompt } from './prompt';
|
|
14
16
|
import { inputSchema as schema, outputSchema } from './schema';
|
|
15
17
|
|
|
@@ -37,6 +39,8 @@ export const initBuildCompositionsTool = ( reg: MCPRegistryEntry ) => {
|
|
|
37
39
|
const errors: Error[] = [];
|
|
38
40
|
const rootContainers: V1Element[] = [];
|
|
39
41
|
const documentContainer = getContainer( 'document' ) as unknown as V1Element;
|
|
42
|
+
const currentDocument = getCurrentDocument();
|
|
43
|
+
const targetContainer = getCompositionTargetContainer( documentContainer, currentDocument?.type.value );
|
|
40
44
|
try {
|
|
41
45
|
const compositionBuilder = CompositionBuilder.fromXMLString( xmlStructure, {
|
|
42
46
|
createElement,
|
|
@@ -50,7 +54,7 @@ export const initBuildCompositionsTool = ( reg: MCPRegistryEntry ) => {
|
|
|
50
54
|
configErrors,
|
|
51
55
|
invalidStyles,
|
|
52
56
|
rootContainers: generatedRootContainers,
|
|
53
|
-
} = compositionBuilder.build(
|
|
57
|
+
} = compositionBuilder.build( targetContainer );
|
|
54
58
|
|
|
55
59
|
rootContainers.push( ...generatedRootContainers );
|
|
56
60
|
generatedXML = new XMLSerializer().serializeToString( compositionBuilder.getXML() );
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type V1Element } from '@elementor/editor-elements';
|
|
2
|
+
|
|
3
|
+
import { getCompositionTargetContainer } from '../get-composition-target-container';
|
|
4
|
+
|
|
5
|
+
const createMockContainer = ( id: string, children?: V1Element[] ): V1Element =>
|
|
6
|
+
( {
|
|
7
|
+
id,
|
|
8
|
+
model: { get: jest.fn(), set: jest.fn(), toJSON: jest.fn() },
|
|
9
|
+
settings: { get: jest.fn(), set: jest.fn(), toJSON: jest.fn() },
|
|
10
|
+
children,
|
|
11
|
+
} ) as unknown as V1Element;
|
|
12
|
+
|
|
13
|
+
describe( 'getCompositionTargetContainer', () => {
|
|
14
|
+
it( 'should return first child when document type is elementor_component', () => {
|
|
15
|
+
// Arrange
|
|
16
|
+
const firstChild = createMockContainer( 'child-1' );
|
|
17
|
+
const documentContainer = createMockContainer( 'document', [ firstChild ] );
|
|
18
|
+
|
|
19
|
+
// Act
|
|
20
|
+
const result = getCompositionTargetContainer( documentContainer, 'elementor_component' );
|
|
21
|
+
|
|
22
|
+
// Assert
|
|
23
|
+
expect( result ).toBe( firstChild );
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
it( 'should return document container when document type is not a component', () => {
|
|
27
|
+
// Arrange
|
|
28
|
+
const firstChild = createMockContainer( 'child-1' );
|
|
29
|
+
const documentContainer = createMockContainer( 'document', [ firstChild ] );
|
|
30
|
+
|
|
31
|
+
// Act
|
|
32
|
+
const result = getCompositionTargetContainer( documentContainer, 'page' );
|
|
33
|
+
|
|
34
|
+
// Assert
|
|
35
|
+
expect( result ).toBe( documentContainer );
|
|
36
|
+
} );
|
|
37
|
+
|
|
38
|
+
it( 'should return document container when document type is undefined', () => {
|
|
39
|
+
// Arrange
|
|
40
|
+
const documentContainer = createMockContainer( 'document' );
|
|
41
|
+
|
|
42
|
+
// Act
|
|
43
|
+
const result = getCompositionTargetContainer( documentContainer, undefined );
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
46
|
+
expect( result ).toBe( documentContainer );
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
it( 'should return document container when component has no children', () => {
|
|
50
|
+
// Arrange
|
|
51
|
+
const documentContainer = createMockContainer( 'document' );
|
|
52
|
+
|
|
53
|
+
// Act
|
|
54
|
+
const result = getCompositionTargetContainer( documentContainer, 'elementor_component' );
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
57
|
+
expect( result ).toBe( documentContainer );
|
|
58
|
+
} );
|
|
59
|
+
} );
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { COMPONENT_DOCUMENT_TYPE } from '@elementor/editor-documents';
|
|
2
|
+
import { type V1Element } from '@elementor/editor-elements';
|
|
3
|
+
|
|
4
|
+
export function getCompositionTargetContainer(
|
|
5
|
+
documentContainer: V1Element,
|
|
6
|
+
documentType: string | undefined
|
|
7
|
+
): V1Element {
|
|
8
|
+
const firstChild = documentContainer.children?.[ 0 ];
|
|
9
|
+
|
|
10
|
+
if ( documentType === COMPONENT_DOCUMENT_TYPE && firstChild ) {
|
|
11
|
+
return firstChild;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return documentContainer;
|
|
15
|
+
}
|
|
@@ -118,6 +118,123 @@ describe( 'renderStyles', () => {
|
|
|
118
118
|
} );
|
|
119
119
|
} );
|
|
120
120
|
|
|
121
|
+
describe( 'breakpoint deduplication', () => {
|
|
122
|
+
it( 'should render all breakpoints when same id has multiple breakpoint variants', async () => {
|
|
123
|
+
// Arrange - simulates output from breakToBreakpoints in use-style-items.
|
|
124
|
+
const desktopStyle: RendererStyleDefinition = {
|
|
125
|
+
id: 'button-style',
|
|
126
|
+
type: 'class',
|
|
127
|
+
cssName: 'e-button',
|
|
128
|
+
label: 'Button',
|
|
129
|
+
variants: [ { meta: { breakpoint: null, state: null }, props: { 'font-size': '16px' }, custom_css: null } ],
|
|
130
|
+
};
|
|
131
|
+
const tabletStyle: RendererStyleDefinition = {
|
|
132
|
+
...desktopStyle,
|
|
133
|
+
variants: [
|
|
134
|
+
{ meta: { breakpoint: 'tablet', state: null }, props: { 'font-size': '14px' }, custom_css: null },
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
const mobileStyle: RendererStyleDefinition = {
|
|
138
|
+
...desktopStyle,
|
|
139
|
+
variants: [
|
|
140
|
+
{ meta: { breakpoint: 'mobile', state: null }, props: { 'font-size': '12px' }, custom_css: null },
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const resolve = jest.fn( ( { props } ) => props );
|
|
145
|
+
const renderStyles = createStylesRenderer( {
|
|
146
|
+
breakpoints: {
|
|
147
|
+
tablet: { width: 992, type: 'max-width' },
|
|
148
|
+
mobile: { width: 768, type: 'max-width' },
|
|
149
|
+
} as BreakpointsMap,
|
|
150
|
+
resolve,
|
|
151
|
+
} );
|
|
152
|
+
|
|
153
|
+
// Act.
|
|
154
|
+
const result = await renderStyles( { styles: [ desktopStyle, tabletStyle, mobileStyle ] } );
|
|
155
|
+
|
|
156
|
+
// Assert - all three breakpoints must be rendered (previously tablet/mobile were dropped).
|
|
157
|
+
expect( result ).toHaveLength( 3 );
|
|
158
|
+
expect( result.map( ( r ) => r.breakpoint ) ).toEqual( [ 'desktop', 'tablet', 'mobile' ] );
|
|
159
|
+
expect( result[ 0 ].value ).toContain( 'font-size:16px' );
|
|
160
|
+
expect( result[ 1 ].value ).toContain( '@media(max-width:992px)' );
|
|
161
|
+
expect( result[ 1 ].value ).toContain( 'font-size:14px' );
|
|
162
|
+
expect( result[ 2 ].value ).toContain( '@media(max-width:768px)' );
|
|
163
|
+
expect( result[ 2 ].value ).toContain( 'font-size:12px' );
|
|
164
|
+
} );
|
|
165
|
+
|
|
166
|
+
it( 'should deduplicate same id + breakpoint + state combinations', async () => {
|
|
167
|
+
// Arrange - two styles with same id, breakpoint, and state should dedupe.
|
|
168
|
+
const style1: RendererStyleDefinition = {
|
|
169
|
+
id: 'button-style',
|
|
170
|
+
type: 'class',
|
|
171
|
+
cssName: 'e-button',
|
|
172
|
+
label: 'Button',
|
|
173
|
+
variants: [
|
|
174
|
+
{ meta: { breakpoint: 'tablet', state: null }, props: { 'font-size': '14px' }, custom_css: null },
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
const style2: RendererStyleDefinition = {
|
|
178
|
+
...style1,
|
|
179
|
+
variants: [
|
|
180
|
+
{ meta: { breakpoint: 'tablet', state: null }, props: { 'font-size': '16px' }, custom_css: null },
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const resolve = jest.fn( ( { props } ) => props );
|
|
185
|
+
const renderStyles = createStylesRenderer( {
|
|
186
|
+
breakpoints: {
|
|
187
|
+
tablet: { width: 992, type: 'max-width' },
|
|
188
|
+
} as BreakpointsMap,
|
|
189
|
+
resolve,
|
|
190
|
+
} );
|
|
191
|
+
|
|
192
|
+
// Act.
|
|
193
|
+
const result = await renderStyles( { styles: [ style1, style2 ] } );
|
|
194
|
+
|
|
195
|
+
// Assert - should only render first occurrence.
|
|
196
|
+
expect( result ).toHaveLength( 1 );
|
|
197
|
+
expect( result[ 0 ].value ).toContain( 'font-size:14px' );
|
|
198
|
+
} );
|
|
199
|
+
|
|
200
|
+
it( 'should render separately when same id + breakpoint have different states', async () => {
|
|
201
|
+
// Arrange - same id and breakpoint but different states should NOT dedupe.
|
|
202
|
+
const normalStyle: RendererStyleDefinition = {
|
|
203
|
+
id: 'button-style',
|
|
204
|
+
type: 'class',
|
|
205
|
+
cssName: 'e-button',
|
|
206
|
+
label: 'Button',
|
|
207
|
+
variants: [
|
|
208
|
+
{ meta: { breakpoint: 'tablet', state: null }, props: { 'font-size': '14px' }, custom_css: null },
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
const hoverStyle: RendererStyleDefinition = {
|
|
212
|
+
...normalStyle,
|
|
213
|
+
variants: [
|
|
214
|
+
{ meta: { breakpoint: 'tablet', state: 'hover' }, props: { 'font-size': '16px' }, custom_css: null },
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const resolve = jest.fn( ( { props } ) => props );
|
|
219
|
+
const renderStyles = createStylesRenderer( {
|
|
220
|
+
breakpoints: {
|
|
221
|
+
tablet: { width: 992, type: 'max-width' },
|
|
222
|
+
} as BreakpointsMap,
|
|
223
|
+
resolve,
|
|
224
|
+
} );
|
|
225
|
+
|
|
226
|
+
// Act.
|
|
227
|
+
const result = await renderStyles( { styles: [ normalStyle, hoverStyle ] } );
|
|
228
|
+
|
|
229
|
+
// Assert - both should be rendered since states differ.
|
|
230
|
+
expect( result ).toHaveLength( 2 );
|
|
231
|
+
expect( result[ 0 ].state ).toBeNull();
|
|
232
|
+
expect( result[ 0 ].value ).toContain( 'font-size:14px' );
|
|
233
|
+
expect( result[ 1 ].state ).toBe( 'hover' );
|
|
234
|
+
expect( result[ 1 ].value ).toContain( 'font-size:16px' );
|
|
235
|
+
} );
|
|
236
|
+
} );
|
|
237
|
+
|
|
121
238
|
describe( 'custom_css rendering', () => {
|
|
122
239
|
it( 'should not render custom_css if raw is empty', async () => {
|
|
123
240
|
// Arrange.
|
|
@@ -46,14 +46,24 @@ const SELECTORS_MAP: Record< StyleDefinitionType, string > = {
|
|
|
46
46
|
class: '.',
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
const DEFAULT_BREAKPOINT = 'desktop';
|
|
50
|
+
const DEFAULT_STATE = 'normal';
|
|
51
|
+
|
|
52
|
+
function getStyleUniqueKey( style: RendererStyleDefinition ): string {
|
|
53
|
+
const breakpoint = style.variants[ 0 ]?.meta?.breakpoint ?? DEFAULT_BREAKPOINT;
|
|
54
|
+
const state = style.variants[ 0 ]?.meta?.state ?? DEFAULT_STATE;
|
|
55
|
+
return `${ style.id }-${ breakpoint }-${ state }`;
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
export function createStylesRenderer( { resolve, breakpoints, selectorPrefix = '' }: CreateStyleRendererArgs ) {
|
|
50
59
|
return async ( { styles, signal }: StyleRendererArgs ): Promise< StyleItem[] > => {
|
|
51
|
-
const
|
|
60
|
+
const seenKeys = new Set< string >();
|
|
52
61
|
const uniqueStyles = styles.filter( ( style ) => {
|
|
53
|
-
|
|
62
|
+
const key = getStyleUniqueKey( style );
|
|
63
|
+
if ( seenKeys.has( key ) ) {
|
|
54
64
|
return false;
|
|
55
65
|
}
|
|
56
|
-
|
|
66
|
+
seenKeys.add( key );
|
|
57
67
|
return true;
|
|
58
68
|
} );
|
|
59
69
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { getMediaAttachment } from '@elementor/wp-media';
|
|
2
|
+
|
|
3
|
+
import { svgSrcTransformer } from '../svg-src-transformer';
|
|
4
|
+
|
|
5
|
+
jest.mock( '@elementor/wp-media' );
|
|
6
|
+
|
|
7
|
+
const mockedGetMediaAttachment = jest.mocked( getMediaAttachment );
|
|
8
|
+
|
|
9
|
+
const SAMPLE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><path d="M0 0h100v100H0z"/></svg>';
|
|
10
|
+
|
|
11
|
+
const mockFetch = ( body: string, contentType = 'image/svg+xml', ok = true ) => {
|
|
12
|
+
global.fetch = jest.fn().mockResolvedValue( {
|
|
13
|
+
ok,
|
|
14
|
+
headers: new Headers( { 'content-type': contentType } ),
|
|
15
|
+
text: () => Promise.resolve( body ),
|
|
16
|
+
} );
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe( 'svgSrcTransformer', () => {
|
|
20
|
+
beforeEach( () => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
afterEach( () => {
|
|
25
|
+
jest.restoreAllMocks();
|
|
26
|
+
} );
|
|
27
|
+
|
|
28
|
+
it( 'fetches SVG and returns processed html with currentColor fill', async () => {
|
|
29
|
+
// Arrange.
|
|
30
|
+
mockFetch( SAMPLE_SVG );
|
|
31
|
+
|
|
32
|
+
// Act.
|
|
33
|
+
const result = await svgSrcTransformer( { id: null, url: 'https://example.com/icon.svg' }, { key: 'svg' } );
|
|
34
|
+
|
|
35
|
+
// Assert.
|
|
36
|
+
expect( result ).toEqual( {
|
|
37
|
+
html: expect.stringContaining( 'fill="currentColor"' ),
|
|
38
|
+
url: 'https://example.com/icon.svg',
|
|
39
|
+
} );
|
|
40
|
+
} );
|
|
41
|
+
|
|
42
|
+
it( 'adds inline styles to the svg element', async () => {
|
|
43
|
+
// Arrange.
|
|
44
|
+
mockFetch( SAMPLE_SVG );
|
|
45
|
+
|
|
46
|
+
// Act.
|
|
47
|
+
const result = ( await svgSrcTransformer(
|
|
48
|
+
{ id: null, url: 'https://example.com/icon.svg' },
|
|
49
|
+
{ key: 'svg' }
|
|
50
|
+
) ) as { html: string; url: string };
|
|
51
|
+
|
|
52
|
+
// Assert.
|
|
53
|
+
expect( result.html ).toContain( 'width: 100%' );
|
|
54
|
+
expect( result.html ).toContain( 'height: 100%' );
|
|
55
|
+
expect( result.html ).toContain( 'overflow: unset' );
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
it( 'returns null html when url is null', async () => {
|
|
59
|
+
// Arrange & Act.
|
|
60
|
+
const result = await svgSrcTransformer( { id: null, url: null }, { key: 'svg' } );
|
|
61
|
+
|
|
62
|
+
// Assert.
|
|
63
|
+
expect( result ).toEqual( { html: null, url: null } );
|
|
64
|
+
expect( mockedGetMediaAttachment ).not.toHaveBeenCalled();
|
|
65
|
+
} );
|
|
66
|
+
|
|
67
|
+
it( 'resolves url from attachment id then fetches SVG', async () => {
|
|
68
|
+
// Arrange.
|
|
69
|
+
mockedGetMediaAttachment.mockResolvedValue( {
|
|
70
|
+
url: 'https://example.com/resolved.svg',
|
|
71
|
+
} as never );
|
|
72
|
+
mockFetch( SAMPLE_SVG );
|
|
73
|
+
|
|
74
|
+
// Act.
|
|
75
|
+
const result = await svgSrcTransformer( { id: 42, url: null }, { key: 'svg' } );
|
|
76
|
+
|
|
77
|
+
// Assert.
|
|
78
|
+
expect( mockedGetMediaAttachment ).toHaveBeenCalledWith( { id: 42 } );
|
|
79
|
+
expect( result ).toEqual( {
|
|
80
|
+
html: expect.stringContaining( 'fill="currentColor"' ),
|
|
81
|
+
url: 'https://example.com/resolved.svg',
|
|
82
|
+
} );
|
|
83
|
+
} );
|
|
84
|
+
|
|
85
|
+
it( 'falls back to provided url when attachment lookup fails', async () => {
|
|
86
|
+
// Arrange.
|
|
87
|
+
mockedGetMediaAttachment.mockResolvedValue( null as never );
|
|
88
|
+
mockFetch( SAMPLE_SVG );
|
|
89
|
+
|
|
90
|
+
// Act.
|
|
91
|
+
const result = ( await svgSrcTransformer(
|
|
92
|
+
{ id: 99, url: 'https://example.com/fallback.svg' },
|
|
93
|
+
{ key: 'svg' }
|
|
94
|
+
) ) as { html: string; url: string };
|
|
95
|
+
|
|
96
|
+
// Assert.
|
|
97
|
+
expect( result.url ).toBe( 'https://example.com/fallback.svg' );
|
|
98
|
+
expect( result.html ).toContain( 'fill="currentColor"' );
|
|
99
|
+
} );
|
|
100
|
+
|
|
101
|
+
it( 'returns null html when id is set but attachment and url are missing', async () => {
|
|
102
|
+
// Arrange.
|
|
103
|
+
mockedGetMediaAttachment.mockResolvedValue( null as never );
|
|
104
|
+
|
|
105
|
+
// Act.
|
|
106
|
+
const result = await svgSrcTransformer( { id: 7, url: null }, { key: 'svg' } );
|
|
107
|
+
|
|
108
|
+
// Assert.
|
|
109
|
+
expect( result ).toEqual( { html: null, url: null } );
|
|
110
|
+
} );
|
|
111
|
+
|
|
112
|
+
it( 'returns null html when fetch fails', async () => {
|
|
113
|
+
// Arrange.
|
|
114
|
+
mockFetch( '', 'image/svg+xml', false );
|
|
115
|
+
|
|
116
|
+
// Act.
|
|
117
|
+
const result = await svgSrcTransformer( { id: null, url: 'https://example.com/missing.svg' }, { key: 'svg' } );
|
|
118
|
+
|
|
119
|
+
// Assert.
|
|
120
|
+
expect( result ).toEqual( {
|
|
121
|
+
html: null,
|
|
122
|
+
url: 'https://example.com/missing.svg',
|
|
123
|
+
} );
|
|
124
|
+
} );
|
|
125
|
+
|
|
126
|
+
it( 'returns null html when content is not SVG', async () => {
|
|
127
|
+
// Arrange.
|
|
128
|
+
mockFetch( '<html><body>Not SVG</body></html>', 'text/html' );
|
|
129
|
+
|
|
130
|
+
// Act.
|
|
131
|
+
const result = await svgSrcTransformer( { id: null, url: 'https://example.com/page.html' }, { key: 'svg' } );
|
|
132
|
+
|
|
133
|
+
// Assert.
|
|
134
|
+
expect( result ).toEqual( {
|
|
135
|
+
html: null,
|
|
136
|
+
url: 'https://example.com/page.html',
|
|
137
|
+
} );
|
|
138
|
+
} );
|
|
139
|
+
|
|
140
|
+
it( 'merges with existing style attribute', async () => {
|
|
141
|
+
// Arrange.
|
|
142
|
+
const svgWithStyle = '<svg xmlns="http://www.w3.org/2000/svg" style="display: block"><path d="M0 0"/></svg>';
|
|
143
|
+
mockFetch( svgWithStyle );
|
|
144
|
+
|
|
145
|
+
// Act.
|
|
146
|
+
const result = ( await svgSrcTransformer(
|
|
147
|
+
{ id: null, url: 'https://example.com/styled.svg' },
|
|
148
|
+
{ key: 'svg' }
|
|
149
|
+
) ) as { html: string; url: string };
|
|
150
|
+
|
|
151
|
+
// Assert.
|
|
152
|
+
expect( result.html ).toContain( 'display: block' );
|
|
153
|
+
expect( result.html ).toContain( 'width: 100%' );
|
|
154
|
+
} );
|
|
155
|
+
|
|
156
|
+
it( 'returns null html when response has no svg element', async () => {
|
|
157
|
+
// Arrange.
|
|
158
|
+
mockFetch( '<div>Not an SVG</div>' );
|
|
159
|
+
|
|
160
|
+
// Act.
|
|
161
|
+
const result = await svgSrcTransformer( { id: null, url: 'https://example.com/not-svg.svg' }, { key: 'svg' } );
|
|
162
|
+
|
|
163
|
+
// Assert.
|
|
164
|
+
expect( result ).toEqual( {
|
|
165
|
+
html: null,
|
|
166
|
+
url: 'https://example.com/not-svg.svg',
|
|
167
|
+
} );
|
|
168
|
+
} );
|
|
169
|
+
|
|
170
|
+
it( 'passes abort signal to fetch', async () => {
|
|
171
|
+
// Arrange.
|
|
172
|
+
mockFetch( SAMPLE_SVG );
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
|
|
175
|
+
// Act.
|
|
176
|
+
await svgSrcTransformer(
|
|
177
|
+
{ id: null, url: 'https://example.com/icon.svg' },
|
|
178
|
+
{ key: 'svg', signal: controller.signal }
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Assert.
|
|
182
|
+
expect( global.fetch ).toHaveBeenCalledWith( 'https://example.com/icon.svg', { signal: controller.signal } );
|
|
183
|
+
} );
|
|
184
|
+
} );
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify';
|
|
2
|
+
import { getMediaAttachment } from '@elementor/wp-media';
|
|
3
|
+
|
|
4
|
+
import { createTransformer } from '../create-transformer';
|
|
5
|
+
import type { TransformerOptions } from '../types';
|
|
6
|
+
|
|
7
|
+
type SvgSrc = {
|
|
8
|
+
id?: unknown;
|
|
9
|
+
url?: unknown;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const SVG_INLINE_STYLES = 'width: 100%; height: 100%; overflow: unset;';
|
|
13
|
+
|
|
14
|
+
function processSvgContent( svgText: string ): string | null {
|
|
15
|
+
const sanitized = DOMPurify.sanitize( svgText, {
|
|
16
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
17
|
+
} );
|
|
18
|
+
|
|
19
|
+
const parser = new DOMParser();
|
|
20
|
+
const doc = parser.parseFromString( sanitized, 'image/svg+xml' );
|
|
21
|
+
const svgElement = doc.querySelector( 'svg' );
|
|
22
|
+
|
|
23
|
+
if ( ! svgElement ) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
svgElement.setAttribute( 'fill', 'currentColor' );
|
|
28
|
+
|
|
29
|
+
const existingStyle = svgElement.getAttribute( 'style' ) ?? '';
|
|
30
|
+
const trimmed = existingStyle.trim();
|
|
31
|
+
const merged = trimmed ? `${ trimmed.replace( /;$/, '' ) }; ${ SVG_INLINE_STYLES }` : SVG_INLINE_STYLES;
|
|
32
|
+
svgElement.setAttribute( 'style', merged );
|
|
33
|
+
|
|
34
|
+
return svgElement.outerHTML;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fetchSvgContent( url: string, signal?: AbortSignal ): Promise< string | null > {
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetch( url, { signal } );
|
|
40
|
+
|
|
41
|
+
if ( ! response.ok ) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const contentType = response.headers.get( 'content-type' ) ?? '';
|
|
46
|
+
const isSvg = contentType.includes( 'svg' ) || contentType.includes( 'xml' ) || url.endsWith( '.svg' );
|
|
47
|
+
|
|
48
|
+
if ( ! isSvg ) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return await response.text();
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveSvgSrcId( id: unknown ): number | null {
|
|
59
|
+
if ( typeof id !== 'number' || id <= 0 ) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const svgSrcTransformer = createTransformer( async ( value: SvgSrc, { signal }: TransformerOptions ) => {
|
|
67
|
+
const id = resolveSvgSrcId( value.id );
|
|
68
|
+
const urlFromValue = typeof value.url === 'string' ? value.url : null;
|
|
69
|
+
|
|
70
|
+
let url: string | null | undefined = urlFromValue;
|
|
71
|
+
|
|
72
|
+
if ( id && ! urlFromValue ) {
|
|
73
|
+
const attachment = await getMediaAttachment( { id } );
|
|
74
|
+
url = attachment?.url ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const resolvedUrl = typeof url === 'string' ? url : null;
|
|
78
|
+
|
|
79
|
+
if ( ! resolvedUrl ) {
|
|
80
|
+
return { html: null, url: null };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const svgText = await fetchSvgContent( resolvedUrl, signal );
|
|
84
|
+
const html = svgText ? processSvgContent( svgText ) : null;
|
|
85
|
+
|
|
86
|
+
return { html, url: resolvedUrl };
|
|
87
|
+
} );
|