@elementor/editor-canvas 4.0.0-manual → 4.0.1
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 +11 -2
- package/dist/index.d.ts +11 -2
- package/dist/index.js +307 -128
- package/dist/index.mjs +273 -94
- package/package.json +19 -18
- package/src/components/__tests__/style-renderer.test.tsx +4 -0
- package/src/hooks/__tests__/use-style-items.test.ts +125 -0
- package/src/hooks/use-style-items.ts +40 -16
- package/src/index.ts +1 -1
- 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 +6 -1
- 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/schema.ts +1 -1
- 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/style-commands/__tests__/paste-style.test.ts +5 -3
- package/src/style-commands/__tests__/reset-style.test.ts +3 -3
- package/src/style-commands/paste-style.ts +7 -1
- package/src/style-commands/reset-style.ts +1 -1
- package/src/style-commands/undoable-actions/paste-element-style.ts +1 -1
- package/src/style-commands/undoable-actions/reset-element-style.ts +1 -1
- 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
- /package/src/{style-commands/utils.ts → utils/command-utils.ts} +0 -0
|
@@ -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
|
+
} );
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { sizeTransformer } from '../size-transformer';
|
|
2
|
+
|
|
3
|
+
function run( val: { size?: number; unit?: string } ) {
|
|
4
|
+
return sizeTransformer( val, { key: 'width', signal: undefined } );
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
describe( 'sizeTransformer', () => {
|
|
8
|
+
it( 'returns "auto" when unit is auto and size is null', () => {
|
|
9
|
+
expect( run( { size: undefined, unit: 'auto' } ) ).toBe( 'auto' );
|
|
10
|
+
} );
|
|
11
|
+
|
|
12
|
+
it( 'returns "auto" when unit is auto and size is undefined', () => {
|
|
13
|
+
expect( run( { unit: 'auto' } ) ).toBe( 'auto' );
|
|
14
|
+
} );
|
|
15
|
+
|
|
16
|
+
it( 'concatenates size and unit for normal units', () => {
|
|
17
|
+
expect( run( { size: 100, unit: 'px' } ) ).toBe( '100px' );
|
|
18
|
+
expect( run( { size: 50, unit: '%' } ) ).toBe( '50%' );
|
|
19
|
+
} );
|
|
20
|
+
|
|
21
|
+
it( 'returns only size for custom unit', () => {
|
|
22
|
+
expect( run( { size: 100, unit: 'custom' } ) ).toBe( 100 );
|
|
23
|
+
} );
|
|
24
|
+
} );
|
|
File without changes
|