@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.
- package/CHANGELOG.md +61 -0
- package/dist/index.js +440 -161
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +427 -140
- package/dist/index.mjs.map +1 -1
- package/package.json +14 -13
- package/src/components/class-manager/class-item.tsx +238 -0
- package/src/components/class-manager/class-manager-class-not-found.tsx +56 -0
- package/src/components/class-manager/class-manager-panel.tsx +72 -9
- package/src/components/class-manager/class-manager-search.tsx +33 -0
- package/src/components/class-manager/global-classes-list.tsx +88 -239
- package/src/components/class-manager/save-changes-dialog.tsx +13 -5
- package/src/global-classes-styles-provider.ts +3 -1
- package/src/init.ts +7 -2
- package/src/store.ts +39 -0
- package/src/utils/snapshot-history.ts +73 -0
|
@@ -1,237 +1,110 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
15
|
+
import { SortableItem, SortableProvider } from './sortable';
|
|
33
16
|
|
|
34
|
-
|
|
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
|
-
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
134
|
+
const reorder = ( newIds: StyleDefinitionID[] ) => {
|
|
135
|
+
dispatch( slice.actions.setOrder( newIds ) );
|
|
136
|
+
};
|
|
288
137
|
|
|
289
|
-
return
|
|
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
|
|
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
|
-
|
|
61
|
-
{ cancel.
|
|
62
|
-
|
|
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:
|
|
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
|
+
}
|