@elementor/editor-global-classes 0.20.5 → 0.21.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.
@@ -1,237 +1,110 @@
1
1
  import * as React from 'react';
2
- import { useRef } from 'react';
2
+ import { useEffect, useMemo } from 'react';
3
3
  import { type StyleDefinitionID } from '@elementor/editor-styles';
4
- import { validateStyleLabel } from '@elementor/editor-styles-repository';
5
- import { EditableField, EllipsisWithTooltip, MenuListItem, useEditable, WarningInfotip } from '@elementor/editor-ui';
6
- import { DotsVerticalIcon } from '@elementor/icons';
7
4
  import { __useDispatch as useDispatch } from '@elementor/store';
8
- import {
9
- bindMenu,
10
- bindTrigger,
11
- Box,
12
- IconButton,
13
- List,
14
- ListItemButton,
15
- type ListItemButtonProps,
16
- Menu,
17
- Stack,
18
- styled,
19
- type Theme,
20
- Tooltip,
21
- Typography,
22
- type TypographyProps,
23
- usePopupState,
24
- } from '@elementor/ui';
5
+ import { List, Stack, styled, Typography, type TypographyProps } from '@elementor/ui';
25
6
  import { __ } from '@wordpress/i18n';
26
7
 
27
8
  import { useClassesOrder } from '../../hooks/use-classes-order';
28
9
  import { useOrderedClasses } from '../../hooks/use-ordered-classes';
29
10
  import { slice } from '../../store';
30
- import { DeleteConfirmationProvider, useDeleteConfirmation } from './delete-confirmation-dialog';
11
+ import { ClassItem } from './class-item';
12
+ import { CssClassNotFound } from './class-manager-class-not-found';
13
+ import { DeleteConfirmationProvider } from './delete-confirmation-dialog';
31
14
  import { FlippedColorSwatchIcon } from './flipped-color-swatch-icon';
32
- import { SortableItem, SortableProvider, SortableTrigger, type SortableTriggerProps } from './sortable';
15
+ import { SortableItem, SortableProvider } from './sortable';
33
16
 
34
- export const GlobalClassesList = ( { disabled }: { disabled?: boolean } ) => {
17
+ type GlobalClassesListProps = {
18
+ disabled?: boolean;
19
+ searchValue: string;
20
+ onSearch: ( searchValue: string ) => void;
21
+ };
22
+
23
+ export const GlobalClassesList = ( { disabled, searchValue, onSearch }: GlobalClassesListProps ) => {
35
24
  const cssClasses = useOrderedClasses();
36
25
  const dispatch = useDispatch();
37
26
 
38
27
  const [ classesOrder, reorderClasses ] = useReorder();
39
28
 
29
+ const lowercaseLabels = useMemo(
30
+ () =>
31
+ cssClasses.map( ( cssClass ) => ( {
32
+ ...cssClass,
33
+ lowerLabel: cssClass.label.toLowerCase(),
34
+ } ) ),
35
+ [ cssClasses ]
36
+ );
37
+
38
+ const filteredClasses = useMemo( () => {
39
+ return searchValue.length > 1
40
+ ? lowercaseLabels.filter( ( cssClass ) =>
41
+ cssClass.lowerLabel.toLowerCase().includes( searchValue.toLowerCase() )
42
+ )
43
+ : cssClasses;
44
+ }, [ searchValue, cssClasses, lowercaseLabels ] );
45
+
46
+ useEffect( () => {
47
+ const handler = ( event: KeyboardEvent ) => {
48
+ if ( event.key === 'z' && ( event.ctrlKey || event.metaKey ) ) {
49
+ event.stopImmediatePropagation();
50
+ event.preventDefault();
51
+ if ( event.shiftKey ) {
52
+ dispatch( slice.actions.redo() );
53
+ return;
54
+ }
55
+ dispatch( slice.actions.undo() );
56
+ }
57
+ };
58
+ window.addEventListener( 'keydown', handler, {
59
+ capture: true,
60
+ } );
61
+ return () => window.removeEventListener( 'keydown', handler );
62
+ }, [ dispatch ] );
63
+
40
64
  if ( ! cssClasses?.length ) {
41
65
  return <EmptyState />;
42
66
  }
43
67
 
44
68
  return (
45
69
  <DeleteConfirmationProvider>
46
- <List sx={ { display: 'flex', flexDirection: 'column', gap: 0.5 } }>
47
- <SortableProvider value={ classesOrder } onChange={ reorderClasses }>
48
- { cssClasses?.map( ( { id, label } ) => {
49
- const renameClass = ( newLabel: string ) => {
50
- dispatch(
51
- slice.actions.update( {
52
- style: {
53
- id,
54
- label: newLabel,
55
- },
56
- } )
70
+ { filteredClasses.length <= 0 && searchValue.length > 1 ? (
71
+ <CssClassNotFound onClear={ () => onSearch( '' ) } searchValue={ searchValue } />
72
+ ) : (
73
+ <List sx={ { display: 'flex', flexDirection: 'column', gap: 0.5 } }>
74
+ <SortableProvider value={ classesOrder } onChange={ reorderClasses }>
75
+ { filteredClasses?.map( ( { id, label } ) => {
76
+ return (
77
+ <SortableItem key={ id } id={ id }>
78
+ { ( { isDragged, isDragPlaceholder, triggerProps, triggerStyle } ) => (
79
+ <ClassItem
80
+ isSearchActive={ searchValue.length < 2 }
81
+ id={ id }
82
+ label={ label }
83
+ renameClass={ ( newLabel: string ) => {
84
+ dispatch(
85
+ slice.actions.update( {
86
+ style: {
87
+ id,
88
+ label: newLabel,
89
+ },
90
+ } )
91
+ );
92
+ } }
93
+ selected={ isDragged }
94
+ disabled={ disabled || isDragPlaceholder }
95
+ sortableTriggerProps={ { ...triggerProps, style: triggerStyle } }
96
+ />
97
+ ) }
98
+ </SortableItem>
57
99
  );
58
- };
59
-
60
- return (
61
- <SortableItem key={ id } id={ id }>
62
- { ( { isDragged, isDragPlaceholder, triggerProps, triggerStyle } ) => (
63
- <ClassItem
64
- id={ id }
65
- label={ label }
66
- renameClass={ renameClass }
67
- selected={ isDragged }
68
- disabled={ disabled || isDragPlaceholder }
69
- sortableTriggerProps={ { ...triggerProps, style: triggerStyle } }
70
- />
71
- ) }
72
- </SortableItem>
73
- );
74
- } ) }
75
- </SortableProvider>
76
- </List>
100
+ } ) }
101
+ </SortableProvider>
102
+ </List>
103
+ ) }
77
104
  </DeleteConfirmationProvider>
78
105
  );
79
106
  };
80
107
 
81
- const useReorder = () => {
82
- const dispatch = useDispatch();
83
- const order = useClassesOrder();
84
-
85
- const reorder = ( newIds: StyleDefinitionID[] ) => {
86
- dispatch( slice.actions.setOrder( newIds ) );
87
- };
88
-
89
- return [ order, reorder ] as const;
90
- };
91
-
92
- type ClassItemProps = React.PropsWithChildren< {
93
- id: string;
94
- label: string;
95
- renameClass: ( newLabel: string ) => void;
96
- selected?: boolean;
97
- disabled?: boolean;
98
- sortableTriggerProps: SortableTriggerProps;
99
- } >;
100
-
101
- const ClassItem = ( { id, label, renameClass, selected, disabled, sortableTriggerProps }: ClassItemProps ) => {
102
- const itemRef = useRef< HTMLElement >( null );
103
-
104
- const {
105
- ref: editableRef,
106
- openEditMode,
107
- isEditing,
108
- error,
109
- getProps: getEditableProps,
110
- } = useEditable( {
111
- value: label,
112
- onSubmit: renameClass,
113
- validation: validateLabel,
114
- } );
115
-
116
- const { openDialog } = useDeleteConfirmation();
117
-
118
- const popupState = usePopupState( {
119
- variant: 'popover',
120
- disableAutoFocus: true,
121
- } );
122
-
123
- const isSelected = ( selected || popupState.isOpen ) && ! disabled;
124
-
125
- return (
126
- <>
127
- <Stack p={ 0 }>
128
- <WarningInfotip
129
- open={ Boolean( error ) }
130
- text={ error ?? '' }
131
- placement="bottom"
132
- width={ itemRef.current?.getBoundingClientRect().width }
133
- offset={ [ 0, -15 ] }
134
- >
135
- <StyledListItemButton
136
- ref={ itemRef }
137
- dense
138
- disableGutters
139
- showActions={ isSelected || isEditing }
140
- shape="rounded"
141
- onDoubleClick={ openEditMode }
142
- selected={ isSelected }
143
- disabled={ disabled }
144
- focusVisibleClassName="visible-class-item"
145
- >
146
- <SortableTrigger { ...sortableTriggerProps } />
147
- <Indicator isActive={ isEditing } isError={ !! error }>
148
- { isEditing ? (
149
- <EditableField
150
- ref={ editableRef }
151
- as={ Typography }
152
- variant="caption"
153
- { ...getEditableProps() }
154
- />
155
- ) : (
156
- <EllipsisWithTooltip title={ label } as={ Typography } variant="caption" />
157
- ) }
158
- </Indicator>
159
- <Tooltip
160
- placement="top"
161
- className={ 'class-item-more-actions' }
162
- title={ __( 'More actions', 'elementor' ) }
163
- >
164
- <IconButton size="tiny" { ...bindTrigger( popupState ) } aria-label="More actions">
165
- <DotsVerticalIcon fontSize="tiny" />
166
- </IconButton>
167
- </Tooltip>
168
- </StyledListItemButton>
169
- </WarningInfotip>
170
- </Stack>
171
- <Menu
172
- { ...bindMenu( popupState ) }
173
- anchorOrigin={ {
174
- vertical: 'bottom',
175
- horizontal: 'right',
176
- } }
177
- transformOrigin={ {
178
- vertical: 'top',
179
- horizontal: 'right',
180
- } }
181
- >
182
- <MenuListItem
183
- sx={ { minWidth: '160px' } }
184
- onClick={ () => {
185
- popupState.close();
186
- openEditMode();
187
- } }
188
- >
189
- <Typography variant="caption" sx={ { color: 'text.primary' } }>
190
- { __( 'Rename', 'elementor' ) }
191
- </Typography>
192
- </MenuListItem>
193
- <MenuListItem
194
- onClick={ () => {
195
- popupState.close();
196
- openDialog( { id, label } );
197
- } }
198
- >
199
- <Typography variant="caption" sx={ { color: 'error.light' } }>
200
- { __( 'Delete', 'elementor' ) }
201
- </Typography>
202
- </MenuListItem>
203
- </Menu>
204
- </>
205
- );
206
- };
207
-
208
- // Custom styles for sortable list item, until the component is available in the UI package.
209
- const StyledListItemButton = styled( ListItemButton, {
210
- shouldForwardProp: ( prop: string ) => ! [ 'showActions' ].includes( prop ),
211
- } )< ListItemButtonProps & { showActions: boolean } >(
212
- ( { showActions } ) => `
213
- min-height: 36px;
214
-
215
- &.visible-class-item {
216
- box-shadow: none !important;
217
- }
218
-
219
- .class-item-more-actions, .class-item-sortable-trigger {
220
- visibility: ${ showActions ? 'visible' : 'hidden' };
221
- }
222
-
223
- .class-item-sortable-trigger {
224
- visibility: ${ showActions ? 'visible' : 'hidden' };
225
- }
226
-
227
- &:hover&:not(:disabled) {
228
- .class-item-more-actions, .class-item-sortable-trigger {
229
- visibility: visible;
230
- }
231
- }
232
- `
233
- );
234
-
235
108
  const EmptyState = () => (
236
109
  <Stack alignItems="center" gap={ 1.5 } pt={ 10 } px={ 0.5 } maxWidth="260px" margin="auto">
237
110
  <FlippedColorSwatchIcon fontSize="large" />
@@ -254,37 +127,13 @@ const StyledHeader = styled( Typography )< TypographyProps >( ( { theme, variant
254
127
  },
255
128
  } ) );
256
129
 
257
- const Indicator = styled( Box, {
258
- shouldForwardProp: ( prop: string ) => ! [ 'isActive', 'isError' ].includes( prop ),
259
- } )< { isActive: boolean; isError: boolean } >( ( { theme, isActive, isError } ) => ( {
260
- display: 'flex',
261
- width: '100%',
262
- flexGrow: 1,
263
- borderRadius: theme.spacing( 0.5 ),
264
- border: getIndicatorBorder( { isActive, isError, theme } ),
265
- padding: `0 ${ theme.spacing( 1 ) }`,
266
- marginLeft: isActive ? theme.spacing( 1 ) : 0,
267
- minWidth: 0,
268
- } ) );
269
-
270
- const getIndicatorBorder = ( { isActive, isError, theme }: { isActive: boolean; isError: boolean; theme: Theme } ) => {
271
- if ( isError ) {
272
- return `2px solid ${ theme.palette.error.main }`;
273
- }
274
-
275
- if ( isActive ) {
276
- return `2px solid ${ theme.palette.secondary.main }`;
277
- }
278
-
279
- return 'none';
280
- };
281
-
282
- const validateLabel = ( newLabel: string ) => {
283
- const result = validateStyleLabel( newLabel, 'rename' );
130
+ const useReorder = () => {
131
+ const dispatch = useDispatch();
132
+ const order = useClassesOrder();
284
133
 
285
- if ( result.isValid ) {
286
- return null;
287
- }
134
+ const reorder = ( newIds: StyleDefinitionID[] ) => {
135
+ dispatch( slice.actions.setOrder( newIds ) );
136
+ };
288
137
 
289
- return result.errorMessage;
138
+ return [ order, reorder ] as const;
290
139
  };
@@ -42,13 +42,14 @@ type Action = {
42
42
 
43
43
  type ConfirmationDialogActionsProps = {
44
44
  actions: {
45
- cancel: Action;
45
+ cancel?: Action;
46
46
  confirm: Action;
47
+ discard?: Action;
47
48
  };
48
49
  };
49
50
  const SaveChangesDialogActions = ( { actions }: ConfirmationDialogActionsProps ) => {
50
51
  const [ isConfirming, setIsConfirming ] = useState( false );
51
- const { cancel, confirm } = actions;
52
+ const { cancel, confirm, discard } = actions;
52
53
 
53
54
  const onConfirm = async () => {
54
55
  setIsConfirming( true );
@@ -57,9 +58,16 @@ const SaveChangesDialogActions = ( { actions }: ConfirmationDialogActionsProps )
57
58
  };
58
59
  return (
59
60
  <DialogActions>
60
- <Button variant="text" color="secondary" onClick={ cancel.action }>
61
- { cancel.label }
62
- </Button>
61
+ { cancel && (
62
+ <Button variant="text" color="secondary" onClick={ cancel.action }>
63
+ { cancel.label }
64
+ </Button>
65
+ ) }
66
+ { discard && (
67
+ <Button variant="text" color="secondary" onClick={ discard.action }>
68
+ { discard.label }
69
+ </Button>
70
+ ) }
63
71
  <Button variant="contained" color="secondary" onClick={ onConfirm } loading={ isConfirming }>
64
72
  { confirm.label }
65
73
  </Button>
@@ -14,8 +14,10 @@ import { selectClass, selectGlobalClasses, selectOrderedClasses, slice, type Sta
14
14
 
15
15
  const MAX_CLASSES = 50;
16
16
 
17
+ export const GLOBAL_CLASSES_PROVIDER_KEY = 'global-classes';
18
+
17
19
  export const globalClassesStylesProvider = createStylesProvider( {
18
- key: 'global-classes',
20
+ key: GLOBAL_CLASSES_PROVIDER_KEY,
19
21
  priority: 30,
20
22
  limit: MAX_CLASSES,
21
23
  labels: {
package/src/init.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { injectIntoLogic } from '@elementor/editor';
2
- import { injectIntoClassSelectorActions } from '@elementor/editor-editing-panel';
2
+ import { injectIntoClassSelectorActions, registerStyleProviderToColors } from '@elementor/editor-editing-panel';
3
3
  import { __registerPanel as registerPanel } from '@elementor/editor-panels';
4
4
  import { stylesRepository } from '@elementor/editor-styles-repository';
5
5
  import { __privateListenTo as listenTo, v1ReadyEvent } from '@elementor/editor-v1-adapters';
@@ -8,7 +8,7 @@ import { __registerSlice as registerSlice } from '@elementor/store';
8
8
  import { ClassManagerButton } from './components/class-manager/class-manager-button';
9
9
  import { panel } from './components/class-manager/class-manager-panel';
10
10
  import { PopulateStore } from './components/populate-store';
11
- import { globalClassesStylesProvider } from './global-classes-styles-provider';
11
+ import { GLOBAL_CLASSES_PROVIDER_KEY, globalClassesStylesProvider } from './global-classes-styles-provider';
12
12
  import { slice } from './store';
13
13
  import { syncWithDocumentSave } from './sync-with-document-save';
14
14
 
@@ -28,6 +28,11 @@ export function init() {
28
28
  component: ClassManagerButton,
29
29
  } );
30
30
 
31
+ registerStyleProviderToColors( GLOBAL_CLASSES_PROVIDER_KEY, {
32
+ name: 'global',
33
+ getThemeColor: ( theme ) => theme.palette.global.dark,
34
+ } );
35
+
31
36
  listenTo( v1ReadyEvent(), () => {
32
37
  syncWithDocumentSave();
33
38
  } );
package/src/store.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
 
16
16
  import type { ApiContext } from './api';
17
17
  import { GlobalClassNotFoundError } from './errors';
18
+ import { SnapshotHistory } from './utils/snapshot-history';
18
19
 
19
20
  export type GlobalClasses = {
20
21
  items: Record< StyleDefinitionID, StyleDefinition >;
@@ -30,6 +31,8 @@ type GlobalClassesState = {
30
31
  isDirty: boolean;
31
32
  };
32
33
 
34
+ const localHistory = SnapshotHistory.get< GlobalClasses >( 'global-classes' );
35
+
33
36
  const initialState: GlobalClassesState = {
34
37
  data: { items: {}, order: [] },
35
38
  initialData: {
@@ -65,6 +68,7 @@ export const slice = createSlice( {
65
68
  },
66
69
 
67
70
  add( state, { payload }: PayloadAction< StyleDefinition > ) {
71
+ localHistory.next( state.data );
68
72
  state.data.items[ payload.id ] = payload;
69
73
  state.data.order.unshift( payload.id );
70
74
 
@@ -72,6 +76,7 @@ export const slice = createSlice( {
72
76
  },
73
77
 
74
78
  delete( state, { payload }: PayloadAction< StyleDefinitionID > ) {
79
+ localHistory.next( state.data );
75
80
  state.data.items = Object.fromEntries(
76
81
  Object.entries( state.data.items ).filter( ( [ id ] ) => id !== payload )
77
82
  );
@@ -82,12 +87,14 @@ export const slice = createSlice( {
82
87
  },
83
88
 
84
89
  setOrder( state, { payload }: PayloadAction< StyleDefinitionID[] > ) {
90
+ localHistory.next( state.data );
85
91
  state.data.order = payload;
86
92
 
87
93
  state.isDirty = true;
88
94
  },
89
95
 
90
96
  update( state, { payload }: PayloadAction< { style: UpdateActionPayload } > ) {
97
+ localHistory.next( state.data );
91
98
  const style = state.data.items[ payload.style.id ];
92
99
 
93
100
  const mergedData = {
@@ -111,6 +118,7 @@ export const slice = createSlice( {
111
118
  if ( ! style ) {
112
119
  throw new GlobalClassNotFoundError( { context: { styleId: payload.id } } );
113
120
  }
121
+ localHistory.next( state.data );
114
122
 
115
123
  const variant = getVariantByMeta( style, payload.meta );
116
124
 
@@ -130,6 +138,7 @@ export const slice = createSlice( {
130
138
 
131
139
  reset( state, { payload: { context } }: PayloadAction< { context: ApiContext } > ) {
132
140
  if ( context === 'frontend' ) {
141
+ localHistory.reset();
133
142
  state.initialData.frontend = state.data;
134
143
 
135
144
  state.isDirty = false;
@@ -137,6 +146,36 @@ export const slice = createSlice( {
137
146
 
138
147
  state.initialData.preview = state.data;
139
148
  },
149
+
150
+ undo( state ) {
151
+ if ( localHistory.isLast() ) {
152
+ localHistory.next( state.data ); // store current before undo
153
+ }
154
+ const data = localHistory.prev();
155
+ if ( data ) {
156
+ state.data = data;
157
+ state.isDirty = true;
158
+ } else {
159
+ state.data = state.initialData.preview;
160
+ }
161
+ },
162
+
163
+ resetToInitialState( state, { payload: { context } }: PayloadAction< { context: ApiContext } > ) {
164
+ localHistory.reset();
165
+ state.data = state.initialData[ context ];
166
+ state.isDirty = false;
167
+ },
168
+
169
+ redo( state ) {
170
+ const data = localHistory.next();
171
+ if ( localHistory.isLast() ) {
172
+ localHistory.prev();
173
+ }
174
+ if ( data ) {
175
+ state.data = data;
176
+ state.isDirty = true;
177
+ }
178
+ },
140
179
  },
141
180
  } );
142
181
 
@@ -0,0 +1,73 @@
1
+ type Link< T > = {
2
+ prev: Link< T > | null;
3
+ next: Link< T > | null;
4
+ value: T;
5
+ };
6
+
7
+ function createLink< T >( { value, next, prev }: { value: T; prev?: Link< T >; next?: Link< T > } ): Link< T > {
8
+ return {
9
+ value,
10
+ prev: prev || null,
11
+ next: next || null,
12
+ };
13
+ }
14
+
15
+ export class SnapshotHistory< T > {
16
+ private static registry: Record< string, SnapshotHistory< unknown > > = {};
17
+
18
+ public static get< K >( namespace: string ): SnapshotHistory< K > {
19
+ if ( ! SnapshotHistory.registry[ namespace ] ) {
20
+ SnapshotHistory.registry[ namespace ] = new SnapshotHistory( namespace );
21
+ }
22
+ return SnapshotHistory.registry[ namespace ] as SnapshotHistory< K >;
23
+ }
24
+
25
+ private first: Link< T > | null = null;
26
+ private current: Link< T > | null = null;
27
+
28
+ private constructor( public readonly namespace: string ) {}
29
+
30
+ private transform( item: T ): T {
31
+ return JSON.parse( JSON.stringify( item ) );
32
+ }
33
+
34
+ public reset(): void {
35
+ this.first = this.current = null;
36
+ }
37
+
38
+ public prev(): T | null {
39
+ if ( ! this.current || this.current === this.first ) {
40
+ return null;
41
+ }
42
+ this.current = this.current.prev;
43
+ return this.current?.value || null;
44
+ }
45
+
46
+ public isLast(): boolean {
47
+ return ! this.current || ! this.current.next;
48
+ }
49
+
50
+ public next( value?: T ): T | null {
51
+ if ( value ) {
52
+ if ( ! this.current ) {
53
+ this.first = createLink( { value: this.transform( value ) } );
54
+ this.current = this.first;
55
+ return this.current.value;
56
+ }
57
+ const nextLink = createLink( {
58
+ value: this.transform( value ),
59
+ prev: this.current,
60
+ } );
61
+ this.current.next = nextLink;
62
+ this.current = nextLink;
63
+ return this.current.value;
64
+ }
65
+
66
+ // No value skip to next without setting any
67
+ if ( ! this.current || ! this.current.next ) {
68
+ return null;
69
+ }
70
+ this.current = this.current.next;
71
+ return this.current.value;
72
+ }
73
+ }