@elementor/editor-canvas 4.0.0 → 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 +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +180 -50
- package/dist/index.mjs +181 -51
- package/package.json +19 -18
- package/src/components/__tests__/style-renderer.test.tsx +4 -0
- package/src/hooks/__tests__/use-style-items.test.ts +68 -0
- package/src/hooks/use-style-items.ts +38 -14
- package/src/init-settings-transformers.ts +2 -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 +3 -0
- package/src/transformers/shared/__tests__/svg-src-transformer.test.ts +184 -0
- package/src/transformers/shared/svg-src-transformer.ts +87 -0
|
@@ -9,6 +9,7 @@ import { queryTransformer } from './transformers/settings/query-transformer';
|
|
|
9
9
|
import { imageSrcTransformer } from './transformers/shared/image-src-transformer';
|
|
10
10
|
import { imageTransformer } from './transformers/shared/image-transformer';
|
|
11
11
|
import { plainTransformer } from './transformers/shared/plain-transformer';
|
|
12
|
+
import { svgSrcTransformer } from './transformers/shared/svg-src-transformer';
|
|
12
13
|
import { videoSrcTransformer } from './transformers/shared/video-src-transformer';
|
|
13
14
|
|
|
14
15
|
export function initSettingsTransformers() {
|
|
@@ -18,6 +19,7 @@ export function initSettingsTransformers() {
|
|
|
18
19
|
.register( 'query', queryTransformer )
|
|
19
20
|
.register( 'image', imageTransformer )
|
|
20
21
|
.register( 'image-src', imageSrcTransformer )
|
|
22
|
+
.register( 'svg-src', svgSrcTransformer )
|
|
21
23
|
.register( 'video-src', videoSrcTransformer )
|
|
22
24
|
.register( 'attributes', attributesTransformer )
|
|
23
25
|
.register( 'date-time', dateTimeTransformer )
|
|
@@ -26,6 +26,8 @@ export class ReplacementBase implements ReplacementBaseInterface {
|
|
|
26
26
|
protected type: ReplacementSettings[ 'type' ];
|
|
27
27
|
protected id: ReplacementSettings[ 'id' ];
|
|
28
28
|
protected refreshView: ReplacementSettings[ 'refreshView' ];
|
|
29
|
+
protected reactRoot: ReplacementSettings[ 'reactRoot' ];
|
|
30
|
+
protected reactContainer: ReplacementSettings[ 'reactContainer' ];
|
|
29
31
|
|
|
30
32
|
constructor( settings: ReplacementSettings ) {
|
|
31
33
|
this.getSetting = settings.getSetting;
|
|
@@ -34,6 +36,8 @@ export class ReplacementBase implements ReplacementBaseInterface {
|
|
|
34
36
|
this.type = settings.type;
|
|
35
37
|
this.id = settings.id;
|
|
36
38
|
this.refreshView = settings.refreshView;
|
|
39
|
+
this.reactRoot = settings.reactRoot;
|
|
40
|
+
this.reactContainer = settings.reactContainer;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
static getTypes(): string[] | null {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { useEffect, useLayoutEffect, useState } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useLayoutEffect, useState } from 'react';
|
|
3
3
|
import { InlineEditor, InlineEditorToolbar } from '@elementor/editor-controls';
|
|
4
4
|
import { Box, ThemeProvider } from '@elementor/ui';
|
|
5
5
|
import { autoUpdate, flip, FloatingPortal, useFloating } from '@floating-ui/react';
|
|
@@ -9,68 +9,83 @@ import {
|
|
|
9
9
|
type Editor,
|
|
10
10
|
getInlineEditorElement,
|
|
11
11
|
horizontalShifterMiddleware as horizontalShifter,
|
|
12
|
-
removeToolbarAnchor,
|
|
13
12
|
useOnClickOutsideIframe,
|
|
14
13
|
useRenderToolbar,
|
|
15
14
|
} from './inline-editing-utils';
|
|
16
15
|
|
|
17
|
-
const EDITOR_WRAPPER_SELECTOR = 'inline-editor-wrapper';
|
|
18
|
-
|
|
19
16
|
export const CanvasInlineEditor = ( {
|
|
20
17
|
elementClasses,
|
|
21
18
|
initialValue,
|
|
22
19
|
expectedTag,
|
|
23
20
|
rootElement,
|
|
21
|
+
contentElement,
|
|
24
22
|
id,
|
|
25
23
|
setValue,
|
|
26
|
-
|
|
24
|
+
requestDestroy,
|
|
27
25
|
}: {
|
|
28
26
|
elementClasses: string;
|
|
29
27
|
initialValue: string | null;
|
|
30
28
|
expectedTag: string | null;
|
|
31
29
|
rootElement: HTMLElement;
|
|
30
|
+
contentElement: HTMLElement;
|
|
32
31
|
id: string;
|
|
33
32
|
setValue: ( value: string | null ) => void;
|
|
34
|
-
|
|
33
|
+
requestDestroy: () => void;
|
|
35
34
|
} ) => {
|
|
35
|
+
const [ active, setActive ] = useState( true );
|
|
36
36
|
const [ editor, setEditor ] = useState< Editor | null >( null );
|
|
37
|
-
const { onSelectionEnd, anchor: toolbarAnchor } = useRenderToolbar( rootElement.ownerDocument, id );
|
|
37
|
+
const { onSelectionEnd, anchor: toolbarAnchor, clearAnchor } = useRenderToolbar( rootElement.ownerDocument, id );
|
|
38
|
+
|
|
39
|
+
useEffect( () => {
|
|
40
|
+
if ( ! active ) {
|
|
41
|
+
clearAnchor();
|
|
42
|
+
requestDestroy();
|
|
43
|
+
}
|
|
44
|
+
}, [ active, clearAnchor, requestDestroy ] );
|
|
45
|
+
|
|
46
|
+
const dismiss = useCallback( () => {
|
|
47
|
+
setEditor( null );
|
|
48
|
+
setActive( false );
|
|
49
|
+
}, [] );
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
removeToolbarAnchor( rootElement.ownerDocument, id );
|
|
51
|
+
useOnClickOutsideIframe( dismiss );
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
useEffect( () => {
|
|
54
|
+
const ownerDocument = contentElement.ownerDocument;
|
|
55
|
+
|
|
56
|
+
const handleClickAway = ( event: MouseEvent ) => {
|
|
57
|
+
if ( contentElement.contains( event.target as Node ) ) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
44
60
|
|
|
45
|
-
|
|
61
|
+
dismiss();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
ownerDocument.addEventListener( 'mousedown', handleClickAway );
|
|
65
|
+
|
|
66
|
+
return () => ownerDocument.removeEventListener( 'mousedown', handleClickAway );
|
|
67
|
+
}, [ contentElement, dismiss ] );
|
|
68
|
+
|
|
69
|
+
if ( ! active ) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
46
72
|
|
|
47
73
|
return (
|
|
48
74
|
<ThemeProvider>
|
|
49
75
|
<InlineEditingOverlay expectedTag={ expectedTag } rootElement={ rootElement } id={ id } />
|
|
50
|
-
<style>
|
|
51
|
-
{ `
|
|
52
|
-
.ProseMirror > * {
|
|
53
|
-
height: 100%;
|
|
54
|
-
}
|
|
55
|
-
.${ EDITOR_WRAPPER_SELECTOR } .ProseMirror > button[contenteditable="true"] {
|
|
56
|
-
height: auto;
|
|
57
|
-
cursor: text;
|
|
58
|
-
}
|
|
59
|
-
` }
|
|
60
|
-
</style>
|
|
61
76
|
<InlineEditor
|
|
62
77
|
onEditorCreate={ setEditor }
|
|
78
|
+
mountElement={ contentElement }
|
|
63
79
|
editorProps={ {
|
|
64
80
|
attributes: {
|
|
65
|
-
style: 'outline: none;
|
|
81
|
+
style: 'outline: none; display: inherit; justify-content: inherit; align-items: inherit; flex-direction: inherit; text-align: inherit;',
|
|
66
82
|
},
|
|
67
83
|
} }
|
|
68
84
|
elementClasses={ elementClasses }
|
|
69
85
|
value={ initialValue }
|
|
70
86
|
setValue={ setValue }
|
|
71
|
-
onBlur={
|
|
87
|
+
onBlur={ dismiss }
|
|
72
88
|
autofocus
|
|
73
|
-
expectedTag={ expectedTag }
|
|
74
89
|
onSelectionEnd={ onSelectionEnd }
|
|
75
90
|
/>
|
|
76
91
|
{ toolbarAnchor && editor && <InlineEditingToolbar anchor={ toolbarAnchor } editor={ editor } id={ id } /> }
|
|
@@ -114,7 +129,14 @@ const InlineEditingToolbar = ( { anchor, editor, id }: { anchor: HTMLElement; ed
|
|
|
114
129
|
|
|
115
130
|
return (
|
|
116
131
|
<FloatingPortal id={ CANVAS_WRAPPER_ID }>
|
|
117
|
-
<Box
|
|
132
|
+
<Box
|
|
133
|
+
ref={ refs.setFloating }
|
|
134
|
+
role="presentation"
|
|
135
|
+
style={ {
|
|
136
|
+
...floatingStyles,
|
|
137
|
+
pointerEvents: 'none',
|
|
138
|
+
} }
|
|
139
|
+
>
|
|
118
140
|
<InlineEditorToolbar editor={ editor } elementId={ id } />
|
|
119
141
|
</Box>
|
|
120
142
|
</FloatingPortal>
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { createRoot, type Root } from 'react-dom/client';
|
|
3
2
|
import { getContainer, getElementLabel, getElementType } from '@elementor/editor-elements';
|
|
4
3
|
import {
|
|
5
4
|
htmlV3PropTypeUtil,
|
|
@@ -25,8 +24,8 @@ type TagPropType = PropType< 'tag' > & {
|
|
|
25
24
|
const HISTORY_DEBOUNCE_WAIT = 800;
|
|
26
25
|
|
|
27
26
|
export default class InlineEditingReplacement extends ReplacementBase {
|
|
28
|
-
private inlineEditorRoot: Root | null = null;
|
|
29
27
|
private handlerAttached = false;
|
|
28
|
+
private editing = false;
|
|
30
29
|
|
|
31
30
|
getReplacementKey() {
|
|
32
31
|
return 'inline-editing';
|
|
@@ -37,7 +36,7 @@ export default class InlineEditingReplacement extends ReplacementBase {
|
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
isEditingModeActive() {
|
|
40
|
-
return
|
|
39
|
+
return this.editing;
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
shouldRenderReplacement() {
|
|
@@ -91,8 +90,8 @@ export default class InlineEditingReplacement extends ReplacementBase {
|
|
|
91
90
|
resetInlineEditorRoot() {
|
|
92
91
|
this.element.removeEventListener( 'click', this.handleRenderInlineEditor );
|
|
93
92
|
this.handlerAttached = false;
|
|
94
|
-
this.
|
|
95
|
-
this.
|
|
93
|
+
this.reactRoot.render( null );
|
|
94
|
+
this.editing = false;
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
unmountInlineEditor() {
|
|
@@ -239,22 +238,29 @@ export default class InlineEditingReplacement extends ReplacementBase {
|
|
|
239
238
|
this.resetInlineEditorRoot();
|
|
240
239
|
}
|
|
241
240
|
|
|
242
|
-
const
|
|
241
|
+
const contentElement = this.element.children?.[ 0 ] as HTMLElement | undefined;
|
|
242
|
+
|
|
243
|
+
if ( ! contentElement ) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const elementClasses = contentElement.classList.toString();
|
|
243
248
|
const propValue = this.getExtractedContentValue();
|
|
244
249
|
const expectedTag = this.getExpectedTag();
|
|
245
250
|
|
|
246
|
-
|
|
251
|
+
contentElement.innerHTML = '';
|
|
252
|
+
this.editing = true;
|
|
247
253
|
|
|
248
|
-
this.
|
|
249
|
-
this.inlineEditorRoot.render(
|
|
254
|
+
this.reactRoot.render(
|
|
250
255
|
<CanvasInlineEditor
|
|
251
256
|
elementClasses={ elementClasses }
|
|
252
257
|
initialValue={ propValue }
|
|
253
258
|
expectedTag={ expectedTag }
|
|
254
259
|
rootElement={ this.element }
|
|
260
|
+
contentElement={ contentElement }
|
|
255
261
|
id={ this.id }
|
|
256
262
|
setValue={ this.setContentValue.bind( this ) }
|
|
257
|
-
|
|
263
|
+
requestDestroy={ this.unmountInlineEditor.bind( this ) }
|
|
258
264
|
/>
|
|
259
265
|
);
|
|
260
266
|
}
|
|
@@ -43,6 +43,7 @@ export const INLINE_EDITING_PROPERTY_PER_TYPE: Record< string, string > = {
|
|
|
43
43
|
'e-form-label': 'text',
|
|
44
44
|
'e-heading': 'title',
|
|
45
45
|
'e-paragraph': 'paragraph',
|
|
46
|
+
'e-form-submit-button': 'text',
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
export const legacyWindow = window as unknown as LegacyWindow;
|
|
@@ -79,6 +80,12 @@ export const useOnClickOutsideIframe = ( handleUnmount: () => void ) => {
|
|
|
79
80
|
export const useRenderToolbar = ( ownerDocument: Document, id: string ) => {
|
|
80
81
|
const [ anchor, setAnchor ] = useState< HTMLElement | null >( null );
|
|
81
82
|
|
|
83
|
+
useEffect( () => {
|
|
84
|
+
if ( ! anchor ) {
|
|
85
|
+
removeToolbarAnchor( ownerDocument, id );
|
|
86
|
+
}
|
|
87
|
+
}, [ anchor, ownerDocument, id ] );
|
|
88
|
+
|
|
82
89
|
const onSelectionEnd = ( view: EditorView ) => {
|
|
83
90
|
const hasSelection = ! view.state.selection.empty;
|
|
84
91
|
|
|
@@ -91,7 +98,11 @@ export const useRenderToolbar = ( ownerDocument: Document, id: string ) => {
|
|
|
91
98
|
}
|
|
92
99
|
};
|
|
93
100
|
|
|
94
|
-
|
|
101
|
+
const clearAnchor = useCallback( () => {
|
|
102
|
+
setAnchor( null );
|
|
103
|
+
}, [] );
|
|
104
|
+
|
|
105
|
+
return { onSelectionEnd, anchor, clearAnchor };
|
|
95
106
|
};
|
|
96
107
|
|
|
97
108
|
const createAnchorBasedOnSelection = ( ownerDocument: Document, id: string ): HTMLElement | null => {
|
|
@@ -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
|
|
|
@@ -241,4 +242,6 @@ export type ReplacementSettings = {
|
|
|
241
242
|
id: string;
|
|
242
243
|
element: HTMLElement;
|
|
243
244
|
refreshView: () => void;
|
|
245
|
+
reactRoot: Root;
|
|
246
|
+
reactContainer: HTMLElement;
|
|
244
247
|
};
|
|
@@ -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
|
+
} );
|