@elementor/editor-canvas 3.33.0-294 → 3.33.0-295

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,245 @@
1
+ import * as React from 'react';
2
+ import { renderWithTheme } from 'test-utils';
3
+ import { getContainer, updateElementSettings, useElementSetting } from '@elementor/editor-elements';
4
+ import { htmlPropTypeUtil } from '@elementor/editor-props';
5
+ import { debounce } from '@elementor/utils';
6
+ import { act, screen } from '@testing-library/react';
7
+
8
+ import { useFloatingOnElement } from '../../hooks/use-floating-on-element';
9
+ import { getInlineEditablePropertyName } from '../../utils/inline-editing-utils';
10
+ import { InlineEditorOverlay } from '../inline-editor-overlay';
11
+
12
+ jest.mock( '@elementor/editor-elements' );
13
+ jest.mock( '@elementor/editor-props' );
14
+ jest.mock( '@elementor/utils' );
15
+ jest.mock( '@elementor/editor-controls', () => ( {
16
+ InlineEditor: jest.fn( ( { value, setValue } ) => (
17
+ <div aria-label="inline editor container">
18
+ <input aria-label="inline editor" value={ value } onChange={ ( e ) => setValue( e.target.value ) } />
19
+ </div>
20
+ ) ),
21
+ } ) );
22
+ jest.mock( '../../hooks/use-floating-on-element' );
23
+ jest.mock( '../../utils/inline-editing-utils' );
24
+
25
+ describe( '<InlineEditorOverlay />', () => {
26
+ const mockElement = document.createElement( 'div' );
27
+ const mockId = 'test-element-id';
28
+ const mockPropertyName = 'title';
29
+ const mockValue = '<p>Test content</p>';
30
+
31
+ beforeEach( () => {
32
+ jest.mocked( useFloatingOnElement ).mockReturnValue( {
33
+ floating: {
34
+ setRef: jest.fn(),
35
+ ref: { current: null },
36
+ styles: { position: 'absolute', top: 0, left: 0 },
37
+ },
38
+ isVisible: true,
39
+ context: {} as never,
40
+ } );
41
+
42
+ jest.mocked( getContainer ).mockReturnValue( {
43
+ model: {
44
+ get: jest.fn().mockReturnValue( 'e-heading' ),
45
+ },
46
+ } as unknown as ReturnType< typeof getContainer > );
47
+
48
+ jest.mocked( getInlineEditablePropertyName ).mockReturnValue( mockPropertyName );
49
+
50
+ jest.mocked( useElementSetting ).mockReturnValue( {
51
+ $$type: 'html',
52
+ value: mockValue,
53
+ } );
54
+
55
+ jest.mocked( htmlPropTypeUtil.extract ).mockReturnValue( mockValue );
56
+
57
+ jest.mocked( htmlPropTypeUtil.create ).mockImplementation( ( value ) => ( {
58
+ $$type: 'html',
59
+ value: typeof value === 'function' ? value( null ) : value,
60
+ } ) );
61
+
62
+ const mockDebouncedFn = jest.fn( ( fn: ( ...args: unknown[] ) => void ) => {
63
+ const debouncedFn = ( ...args: unknown[] ) => fn( ...args );
64
+ (
65
+ debouncedFn as typeof debouncedFn & {
66
+ cancel: jest.Mock;
67
+ flush: jest.Mock;
68
+ pending: jest.Mock;
69
+ }
70
+ ).cancel = jest.fn();
71
+ (
72
+ debouncedFn as typeof debouncedFn & {
73
+ cancel: jest.Mock;
74
+ flush: jest.Mock;
75
+ pending: jest.Mock;
76
+ }
77
+ ).flush = jest.fn( ( ...args: unknown[] ) => fn( ...args ) );
78
+ (
79
+ debouncedFn as typeof debouncedFn & {
80
+ cancel: jest.Mock;
81
+ flush: jest.Mock;
82
+ pending: jest.Mock;
83
+ }
84
+ ).pending = jest.fn().mockReturnValue( false );
85
+ return debouncedFn;
86
+ } );
87
+ jest.mocked( debounce ).mockImplementation( mockDebouncedFn as unknown as typeof debounce );
88
+ } );
89
+
90
+ afterEach( () => {
91
+ jest.clearAllMocks();
92
+ } );
93
+
94
+ it( 'should render InlineEditor when visible', () => {
95
+ renderWithTheme( <InlineEditorOverlay element={ mockElement } isSelected={ true } id={ mockId } /> );
96
+
97
+ const input = screen.getByRole( 'textbox', { name: 'inline editor' } );
98
+ expect( input ).toBeInTheDocument();
99
+ expect( input ).toHaveValue( mockValue );
100
+ } );
101
+
102
+ it( 'should not render when not visible', () => {
103
+ jest.mocked( useFloatingOnElement ).mockReturnValue( {
104
+ floating: {
105
+ setRef: jest.fn(),
106
+ ref: { current: null },
107
+ styles: {},
108
+ },
109
+ isVisible: false,
110
+ context: {} as never,
111
+ } );
112
+
113
+ renderWithTheme( <InlineEditorOverlay element={ mockElement } isSelected={ false } id={ mockId } /> );
114
+
115
+ expect( screen.queryByRole( 'textbox', { name: 'inline editor' } ) ).not.toBeInTheDocument();
116
+ } );
117
+
118
+ it( 'should get container and property name on mount', () => {
119
+ renderWithTheme( <InlineEditorOverlay element={ mockElement } isSelected={ true } id={ mockId } /> );
120
+
121
+ expect( getContainer ).toHaveBeenCalledWith( mockId );
122
+ expect( getInlineEditablePropertyName ).toHaveBeenCalled();
123
+ } );
124
+
125
+ it( 'should extract value from contentProp using htmlPropTypeUtil', () => {
126
+ renderWithTheme( <InlineEditorOverlay element={ mockElement } isSelected={ true } id={ mockId } /> );
127
+
128
+ expect( htmlPropTypeUtil.extract ).toHaveBeenCalledWith( {
129
+ $$type: 'html',
130
+ value: mockValue,
131
+ } );
132
+ } );
133
+
134
+ it( 'should call updateElementSettings when value changes', async () => {
135
+ const newValue = '<p>New content</p>';
136
+ const mockDebounceFn = jest.fn();
137
+ const debouncedFn = jest.fn( ( fn: ( ...args: unknown[] ) => void ) => {
138
+ mockDebounceFn.mockImplementation( fn );
139
+ return mockDebounceFn;
140
+ } );
141
+ jest.mocked( debounce ).mockImplementation( debouncedFn as unknown as typeof debounce );
142
+
143
+ renderWithTheme( <InlineEditorOverlay element={ mockElement } isSelected={ true } id={ mockId } /> );
144
+
145
+ const input = screen.getByRole( 'textbox', { name: 'inline editor' } ) as HTMLInputElement;
146
+
147
+ await act( async () => {
148
+ input.dispatchEvent( new Event( 'change', { bubbles: true } ) );
149
+ Object.defineProperty( input, 'value', { value: newValue, writable: true } );
150
+ } );
151
+
152
+ await act( async () => {
153
+ mockDebounceFn( newValue );
154
+ } );
155
+
156
+ expect( updateElementSettings ).toHaveBeenCalledWith( {
157
+ id: mockId,
158
+ props: {
159
+ [ mockPropertyName ]: {
160
+ $$type: 'html',
161
+ value: newValue,
162
+ },
163
+ },
164
+ withHistory: true,
165
+ } );
166
+ } );
167
+
168
+ it( 'should save &nbsp; when content is empty', async () => {
169
+ const emptyValue = '';
170
+ const mockDebounceFn = jest.fn();
171
+ const debouncedFn = jest.fn( ( fn: ( ...args: unknown[] ) => void ) => {
172
+ mockDebounceFn.mockImplementation( fn );
173
+ return mockDebounceFn;
174
+ } );
175
+ jest.mocked( debounce ).mockImplementation( debouncedFn as unknown as typeof debounce );
176
+
177
+ renderWithTheme( <InlineEditorOverlay element={ mockElement } isSelected={ true } id={ mockId } /> );
178
+
179
+ await act( async () => {
180
+ mockDebounceFn( emptyValue );
181
+ } );
182
+
183
+ expect( updateElementSettings ).toHaveBeenCalledWith( {
184
+ id: mockId,
185
+ props: {
186
+ [ mockPropertyName ]: {
187
+ $$type: 'html',
188
+ value: '&nbsp;',
189
+ },
190
+ },
191
+ withHistory: true,
192
+ } );
193
+ } );
194
+
195
+ it( 'should update and display new value after editing', async () => {
196
+ const initialValue = '<p>Initial content</p>';
197
+ const newValue = '<p>Updated content</p>';
198
+
199
+ jest.mocked( useElementSetting ).mockReturnValue( {
200
+ $$type: 'html',
201
+ value: initialValue,
202
+ } );
203
+ jest.mocked( htmlPropTypeUtil.extract ).mockReturnValue( initialValue );
204
+
205
+ const mockDebounceFn = jest.fn();
206
+ const debouncedFn = jest.fn( ( fn: ( ...args: unknown[] ) => void ) => {
207
+ mockDebounceFn.mockImplementation( fn );
208
+ return mockDebounceFn;
209
+ } );
210
+ jest.mocked( debounce ).mockImplementation( debouncedFn as unknown as typeof debounce );
211
+
212
+ const { rerender } = renderWithTheme(
213
+ <InlineEditorOverlay element={ mockElement } isSelected={ true } id={ mockId } />
214
+ );
215
+
216
+ const input = screen.getByRole( 'textbox', { name: 'inline editor' } ) as HTMLInputElement;
217
+ expect( input ).toHaveValue( initialValue );
218
+
219
+ await act( async () => {
220
+ mockDebounceFn( newValue );
221
+ } );
222
+
223
+ expect( updateElementSettings ).toHaveBeenCalledWith( {
224
+ id: mockId,
225
+ props: {
226
+ [ mockPropertyName ]: {
227
+ $$type: 'html',
228
+ value: newValue,
229
+ },
230
+ },
231
+ withHistory: true,
232
+ } );
233
+
234
+ jest.mocked( useElementSetting ).mockReturnValue( {
235
+ $$type: 'html',
236
+ value: newValue,
237
+ } );
238
+ jest.mocked( htmlPropTypeUtil.extract ).mockReturnValue( newValue );
239
+
240
+ rerender( <InlineEditorOverlay element={ mockElement } isSelected={ true } id={ mockId } /> );
241
+
242
+ const updatedInput = screen.getByRole( 'textbox', { name: 'inline editor' } ) as HTMLInputElement;
243
+ expect( updatedInput ).toHaveValue( newValue );
244
+ } );
245
+ } );
@@ -3,11 +3,29 @@ import { getElements, useSelectedElement } from '@elementor/editor-elements';
3
3
  import {
4
4
  __privateUseIsRouteActive as useIsRouteActive,
5
5
  __privateUseListenTo as useListenTo,
6
+ isExperimentActive,
6
7
  useEditMode,
7
8
  windowEvent,
8
9
  } from '@elementor/editor-v1-adapters';
9
10
 
10
- import { ElementOverlay } from './element-overlay';
11
+ import type { ElementOverlayConfig } from '../types/element-overlay';
12
+ import { hasInlineEditableProperty } from '../utils/inline-editing-utils';
13
+ import { InlineEditorOverlay } from './inline-editor-overlay';
14
+ import { OutlineOverlay } from './outline-overlay';
15
+
16
+ const ELEMENTS_DATA_ATTR = 'atomic';
17
+
18
+ const overlayRegistry: ElementOverlayConfig[] = [
19
+ {
20
+ component: OutlineOverlay,
21
+ shouldRender: () => true,
22
+ },
23
+ {
24
+ component: InlineEditorOverlay,
25
+ shouldRender: ( { id, isSelected } ) =>
26
+ isSelected && hasInlineEditableProperty( id ) && isExperimentActive( 'v4-inline-text-editing' ),
27
+ },
28
+ ];
11
29
 
12
30
  export function ElementsOverlays() {
13
31
  const selected = useSelectedElement();
@@ -16,18 +34,23 @@ export function ElementsOverlays() {
16
34
 
17
35
  const isEditMode = currentEditMode === 'edit';
18
36
  const isKitRouteActive = useIsRouteActive( 'panel/global' );
19
-
20
37
  const isActive = isEditMode && ! isKitRouteActive;
21
38
 
22
- return (
23
- isActive &&
24
- elements.map( ( [ id, element ] ) => (
25
- <ElementOverlay key={ id } id={ id } element={ element } isSelected={ selected.element?.id === id } />
26
- ) )
27
- );
28
- }
39
+ if ( ! isActive ) {
40
+ return null;
41
+ }
29
42
 
30
- const ELEMENTS_DATA_ATTR = 'atomic';
43
+ return elements.map( ( [ id, element ] ) => {
44
+ const isSelected = selected.element?.id === id;
45
+
46
+ return overlayRegistry.map(
47
+ ( { shouldRender, component: Overlay }, index ) =>
48
+ shouldRender( { id, element, isSelected } ) && (
49
+ <Overlay key={ `${ id }-${ index }` } id={ id } element={ element } isSelected={ isSelected } />
50
+ )
51
+ );
52
+ } );
53
+ }
31
54
 
32
55
  type IdElementTuple = [ string, HTMLElement ];
33
56
 
@@ -0,0 +1,79 @@
1
+ import * as React from 'react';
2
+ import { InlineEditor } from '@elementor/editor-controls';
3
+ import { getContainer, updateElementSettings, useElementSetting } from '@elementor/editor-elements';
4
+ import { htmlPropTypeUtil } from '@elementor/editor-props';
5
+ import { Box } from '@elementor/ui';
6
+ import { debounce } from '@elementor/utils';
7
+ import { FloatingPortal } from '@floating-ui/react';
8
+
9
+ import { useFloatingOnElement } from '../hooks/use-floating-on-element';
10
+ import type { ElementOverlayProps } from '../types/element-overlay';
11
+ import { getInlineEditablePropertyName } from '../utils/inline-editing-utils';
12
+ import { CANVAS_WRAPPER_ID } from './outline-overlay';
13
+
14
+ const OVERLAY_Z_INDEX = 1000;
15
+ const DEBOUNCE_DELAY = 100;
16
+
17
+ export const InlineEditorOverlay = ( { element, isSelected, id }: ElementOverlayProps ): React.ReactElement | null => {
18
+ const { floating, isVisible } = useFloatingOnElement( { element, isSelected } );
19
+
20
+ const propertyName = React.useMemo( () => {
21
+ const container = getContainer( id );
22
+ return getInlineEditablePropertyName( container );
23
+ }, [ id ] );
24
+
25
+ const contentProp = useElementSetting( id, propertyName );
26
+ const value = React.useMemo( () => htmlPropTypeUtil.extract( contentProp ) || '', [ contentProp ] );
27
+
28
+ const debouncedUpdateRef = React.useRef< ReturnType< typeof debounce > | null >( null );
29
+ const lastValueRef = React.useRef< string >( '' );
30
+
31
+ React.useEffect( () => {
32
+ debouncedUpdateRef.current = debounce( ( newValue: string ) => {
33
+ const textContent = newValue.replace( /<[^>]*>/g, '' ).trim();
34
+ const valueToSave = textContent === '' ? '&nbsp;' : newValue;
35
+
36
+ updateElementSettings( {
37
+ id,
38
+ props: {
39
+ [ propertyName ]: htmlPropTypeUtil.create( valueToSave ),
40
+ },
41
+ withHistory: true,
42
+ } );
43
+ }, DEBOUNCE_DELAY );
44
+
45
+ return () => {
46
+ debouncedUpdateRef.current?.cancel?.();
47
+ };
48
+ }, [ id, propertyName ] );
49
+
50
+ const handleValueChange = React.useCallback( ( newValue: string ) => {
51
+ lastValueRef.current = newValue;
52
+ debouncedUpdateRef.current?.( newValue );
53
+ }, [] );
54
+
55
+ React.useEffect( () => {
56
+ if ( ! isVisible && debouncedUpdateRef.current?.pending?.() ) {
57
+ debouncedUpdateRef.current.flush( lastValueRef.current );
58
+ }
59
+ }, [ isVisible ] );
60
+
61
+ if ( ! isVisible ) {
62
+ return null;
63
+ }
64
+
65
+ return (
66
+ <FloatingPortal id={ CANVAS_WRAPPER_ID }>
67
+ <Box
68
+ ref={ floating.setRef }
69
+ style={ {
70
+ ...floating.styles,
71
+ zIndex: OVERLAY_Z_INDEX,
72
+ pointerEvents: 'auto',
73
+ } }
74
+ >
75
+ <InlineEditor value={ value } setValue={ handleValueChange } />
76
+ </Box>
77
+ </FloatingPortal>
78
+ );
79
+ };
@@ -5,13 +5,11 @@ import { FloatingPortal, useHover, useInteractions } from '@floating-ui/react';
5
5
  import { useBindReactPropsToElement } from '../hooks/use-bind-react-props-to-element';
6
6
  import { useFloatingOnElement } from '../hooks/use-floating-on-element';
7
7
  import { useHasOverlapping } from '../hooks/use-has-overlapping';
8
+ import type { ElementOverlayProps } from '../types/element-overlay';
8
9
 
9
10
  export const CANVAS_WRAPPER_ID = 'elementor-preview-responsive-wrapper';
10
11
 
11
- type Props = {
12
- element: HTMLElement;
13
- isSelected: boolean;
14
- id: string;
12
+ type Props = ElementOverlayProps & {
15
13
  isSmallerOffset?: boolean;
16
14
  };
17
15
 
@@ -23,7 +21,7 @@ const OverlayBox = styled( Box, {
23
21
  pointerEvents: 'none',
24
22
  } ) );
25
23
 
26
- export function ElementOverlay( { element, isSelected, id }: Props ) {
24
+ export const OutlineOverlay = ( { element, isSelected, id }: Props ): React.ReactElement | false => {
27
25
  const { context, floating, isVisible } = useFloatingOnElement( { element, isSelected } );
28
26
  const { getFloatingProps, getReferenceProps } = useInteractions( [ useHover( context ) ] );
29
27
  const hasOverlapping = useHasOverlapping();
@@ -47,4 +45,4 @@ export function ElementOverlay( { element, isSelected, id }: Props ) {
47
45
  </FloatingPortal>
48
46
  )
49
47
  );
50
- }
48
+ };
@@ -0,0 +1,18 @@
1
+ import type * as React from 'react';
2
+
3
+ export type ElementOverlayProps = {
4
+ element: HTMLElement;
5
+ id: string;
6
+ isSelected: boolean;
7
+ };
8
+
9
+ export type OverlayFilterArgs = {
10
+ id: string;
11
+ element: HTMLElement;
12
+ isSelected: boolean;
13
+ };
14
+
15
+ export type ElementOverlayConfig = {
16
+ component: React.ComponentType< ElementOverlayProps >;
17
+ shouldRender: ( args: OverlayFilterArgs ) => boolean;
18
+ };
@@ -0,0 +1,43 @@
1
+ import { getContainer, getElementType, type V1Element } from '@elementor/editor-elements';
2
+
3
+ const WIDGET_PROPERTY_MAP: Record< string, string > = {
4
+ 'e-heading': 'title',
5
+ 'e-paragraph': 'paragraph',
6
+ };
7
+
8
+ const getHtmlPropertyName = ( container: V1Element | null ): string => {
9
+ const widgetType = container?.model?.get( 'widgetType' ) ?? container?.model?.get( 'elType' );
10
+
11
+ if ( ! widgetType ) {
12
+ return '';
13
+ }
14
+
15
+ if ( WIDGET_PROPERTY_MAP[ widgetType ] ) {
16
+ return WIDGET_PROPERTY_MAP[ widgetType ];
17
+ }
18
+
19
+ const propsSchema = getElementType( widgetType )?.propsSchema;
20
+
21
+ if ( ! propsSchema ) {
22
+ return '';
23
+ }
24
+
25
+ const entry = Object.entries( propsSchema ).find( ( [ , propType ] ) => propType.key === 'html' );
26
+
27
+ return entry?.[ 0 ] ?? '';
28
+ };
29
+
30
+ export const hasInlineEditableProperty = ( containerId: string ): boolean => {
31
+ const container = getContainer( containerId );
32
+ const widgetType = container?.model?.get( 'widgetType' ) ?? container?.model?.get( 'elType' );
33
+
34
+ if ( ! widgetType ) {
35
+ return false;
36
+ }
37
+
38
+ return widgetType in WIDGET_PROPERTY_MAP;
39
+ };
40
+
41
+ export const getInlineEditablePropertyName = ( container: V1Element | null ): string => {
42
+ return getHtmlPropertyName( container );
43
+ };