@elementor/editor-global-classes 4.1.0-manual → 4.2.0-839

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elementor/editor-global-classes",
3
- "version": "4.1.0-manual",
3
+ "version": "4.2.0-839",
4
4
  "private": false,
5
5
  "author": "Elementor Team",
6
6
  "homepage": "https://elementor.com/",
@@ -39,28 +39,29 @@
39
39
  "dev": "tsup --config=../../tsup.dev.ts"
40
40
  },
41
41
  "dependencies": {
42
- "@elementor/editor": "4.1.0-manual",
43
- "@elementor/editor-current-user": "4.1.0-manual",
44
- "@elementor/editor-documents": "4.1.0-manual",
45
- "@elementor/editor-editing-panel": "4.1.0-manual",
46
- "@elementor/editor-mcp": "4.1.0-manual",
47
- "@elementor/editor-panels": "4.1.0-manual",
48
- "@elementor/editor-props": "4.1.0-manual",
49
- "@elementor/editor-variables": "4.1.0-manual",
50
- "@elementor/editor-styles": "4.1.0-manual",
51
- "@elementor/editor-canvas": "4.1.0-manual",
52
- "@elementor/editor-styles-repository": "4.1.0-manual",
53
- "@elementor/editor-ui": "4.1.0-manual",
54
- "@elementor/editor-v1-adapters": "4.1.0-manual",
55
- "@elementor/http-client": "4.1.0-manual",
56
- "@elementor/icons": "^1.68.0",
57
- "@elementor/query": "4.1.0-manual",
58
- "@elementor/schema": "4.1.0-manual",
59
- "@elementor/store": "4.1.0-manual",
42
+ "@elementor/editor": "4.2.0-839",
43
+ "@elementor/editor-current-user": "4.2.0-839",
44
+ "@elementor/editor-documents": "4.2.0-839",
45
+ "@elementor/editor-editing-panel": "4.2.0-839",
46
+ "@elementor/editor-mcp": "4.2.0-839",
47
+ "@elementor/editor-panels": "4.2.0-839",
48
+ "@elementor/editor-props": "4.2.0-839",
49
+ "@elementor/editor-variables": "4.2.0-839",
50
+ "@elementor/editor-styles": "4.2.0-839",
51
+ "@elementor/editor-canvas": "4.2.0-839",
52
+ "@elementor/editor-styles-repository": "4.2.0-839",
53
+ "@elementor/editor-ui": "4.2.0-839",
54
+ "@elementor/editor-v1-adapters": "4.2.0-839",
55
+ "@elementor/http-client": "4.2.0-839",
56
+ "@elementor/icons": "~1.75.1",
57
+ "@elementor/query": "4.2.0-839",
58
+ "@elementor/schema": "4.2.0-839",
59
+ "@elementor/store": "4.2.0-839",
60
60
  "@elementor/ui": "1.37.5",
61
- "@elementor/utils": "4.1.0-manual",
61
+ "@elementor/utils": "4.2.0-839",
62
+ "@tanstack/react-virtual": "^3.13.24",
62
63
  "@wordpress/i18n": "^5.13.0",
63
- "@elementor/events": "4.1.0-manual"
64
+ "@elementor/events": "4.2.0-839"
64
65
  },
65
66
  "peerDependencies": {
66
67
  "react": "^18.3.1",
package/src/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type StyleDefinition, type StyleDefinitionID, type StyleDefinitionsMap } from '@elementor/editor-styles';
1
+ import { type StyleDefinition, type StyleDefinitionID } from '@elementor/editor-styles';
2
2
  import { type HttpResponse, httpService } from '@elementor/http-client';
3
3
 
4
4
  import { type CssClassUsage } from './components/css-class-usage/types';
@@ -7,13 +7,24 @@ import { type GlobalClasses } from './store';
7
7
  const RESOURCE_URL = '/global-classes';
8
8
  const BASE_URL = 'elementor/v1';
9
9
  const RESOURCE_USAGE_URL = `${ RESOURCE_URL }/usage`;
10
+ const RESOURCE_POST_URL = `${ RESOURCE_URL }/post`;
11
+ const RESOURCE_STYLES_URL = `${ RESOURCE_URL }/styles`;
10
12
 
11
13
  type GlobalClassesUsageResponse = HttpResponse< CssClassUsage >;
12
14
 
13
- export type GlobalClassesGetAllResponse = HttpResponse<
14
- StyleDefinitionsMap,
15
+ export type GlobalClassIndexEntry = {
16
+ id: StyleDefinitionID;
17
+ label: string;
18
+ };
19
+
20
+ export type GlobalClassesIndexHttpResponse = HttpResponse< GlobalClassIndexEntry[], Record< string, never > >;
21
+
22
+ export type StyleDefinitionsNullableMap = Record< StyleDefinitionID, StyleDefinition | null >;
23
+
24
+ export type GlobalClassesStylesHttpResponse = HttpResponse<
25
+ StyleDefinitionsNullableMap,
15
26
  {
16
- order: StyleDefinition[ 'id' ][];
27
+ order: StyleDefinitionID[];
17
28
  }
18
29
  >;
19
30
 
@@ -27,27 +38,33 @@ type UpdatePayload = GlobalClasses & {
27
38
 
28
39
  export type ApiContext = 'preview' | 'frontend';
29
40
 
41
+ function saveGlobalClasses( context: ApiContext, payload: UpdatePayload ) {
42
+ return httpService().put( `${ BASE_URL }${ RESOURCE_URL }`, payload, {
43
+ params: { context },
44
+ } );
45
+ }
46
+
30
47
  export const apiClient = {
31
48
  usage: () => httpService().get< GlobalClassesUsageResponse >( `${ BASE_URL }${ RESOURCE_USAGE_URL }` ),
32
49
 
33
50
  all: ( context: ApiContext = 'preview' ) =>
34
- httpService().get< GlobalClassesGetAllResponse >( `${ BASE_URL }${ RESOURCE_URL }`, {
51
+ httpService().get< GlobalClassesIndexHttpResponse >( `${ BASE_URL }${ RESOURCE_URL }`, {
35
52
  params: { context },
36
53
  } ),
37
54
 
38
- publish: ( payload: UpdatePayload ) =>
39
- httpService().put( 'elementor/v1' + RESOURCE_URL, payload, {
40
- params: {
41
- context: 'frontend' satisfies ApiContext,
42
- },
55
+ getStylesForPost: ( postId: number, context: ApiContext = 'preview' ) =>
56
+ httpService().get< GlobalClassesStylesHttpResponse >( `${ BASE_URL }${ RESOURCE_POST_URL }`, {
57
+ params: { context, post_id: postId },
43
58
  } ),
44
59
 
45
- saveDraft: ( payload: UpdatePayload ) =>
46
- httpService().put( 'elementor/v1' + RESOURCE_URL, payload, {
47
- params: {
48
- context: 'preview' satisfies ApiContext,
49
- },
60
+ getStylesByIds: ( ids: StyleDefinitionID[], context: ApiContext = 'preview' ) =>
61
+ httpService().get< GlobalClassesStylesHttpResponse >( `${ BASE_URL }${ RESOURCE_STYLES_URL }`, {
62
+ params: { context, ids: ids.join( ',' ) },
50
63
  } ),
64
+
65
+ publish: ( payload: UpdatePayload ) => saveGlobalClasses( 'frontend', payload ),
66
+
67
+ saveDraft: ( payload: UpdatePayload ) => saveGlobalClasses( 'preview', payload ),
51
68
  };
52
69
 
53
70
  export const API_ERROR_CODES = {
@@ -5,6 +5,7 @@ import {
5
5
  } from '@elementor/editor-documents';
6
6
  import { useUserStylesCapability } from '@elementor/editor-styles-repository';
7
7
  import { SaveChangesDialog, useDialog } from '@elementor/editor-ui';
8
+ import { isExperimentActive } from '@elementor/editor-v1-adapters';
8
9
  import { IconButton, Tooltip } from '@elementor/ui';
9
10
  import { __ } from '@wordpress/i18n';
10
11
 
@@ -36,18 +37,27 @@ export const ClassManagerButton = () => {
36
37
  return null;
37
38
  }
38
39
 
40
+ const toggleClassesManagerPanel = () => {
41
+ if ( isExperimentActive( 'e_editor_design_system_panel' ) ) {
42
+ window.dispatchEvent(
43
+ new CustomEvent( 'elementor/toggle-design-system', {
44
+ detail: { tab: 'classes' as const },
45
+ } )
46
+ );
47
+ } else {
48
+ openPanel();
49
+ }
50
+ };
51
+
39
52
  const handleOpenPanel = () => {
40
53
  if ( document?.isDirty ) {
41
54
  openSaveChangesDialog();
42
55
  return;
43
56
  }
44
57
 
45
- openPanel();
58
+ toggleClassesManagerPanel();
59
+
46
60
  trackGlobalClassesButton();
47
- trackGlobalClasses( {
48
- event: 'classManagerOpened',
49
- source: 'style-panel',
50
- } );
51
61
  prefetchClassesUsage();
52
62
  };
53
63
 
@@ -80,7 +90,7 @@ export const ClassManagerButton = () => {
80
90
  action: async () => {
81
91
  await saveDocument();
82
92
  closeSaveChangesDialog();
83
- openPanel();
93
+ toggleClassesManagerPanel();
84
94
  trackGlobalClassesButton();
85
95
  prefetchClassesUsage();
86
96
  },
@@ -1,7 +1,7 @@
1
1
  import * as React from 'react';
2
- import { useCallback, useEffect, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
3
  import { useSuppressedMessage } from '@elementor/editor-current-user';
4
- import { getCurrentDocument, getV1DocumentsManager, setDocumentModifiedStatus } from '@elementor/editor-documents';
4
+ import { reloadCurrentDocument, setDocumentModifiedStatus } from '@elementor/editor-documents';
5
5
  import {
6
6
  __createPanel as createPanel,
7
7
  Panel,
@@ -11,7 +11,7 @@ import {
11
11
  PanelHeaderTitle,
12
12
  } from '@elementor/editor-panels';
13
13
  import { ConfirmationDialog, SaveChangesDialog, ThemeProvider, useDialog } from '@elementor/editor-ui';
14
- import { __privateRunCommand as runCommand, changeEditMode } from '@elementor/editor-v1-adapters';
14
+ import { changeEditMode } from '@elementor/editor-v1-adapters';
15
15
  import { XIcon } from '@elementor/icons';
16
16
  import { useMutation } from '@elementor/query';
17
17
  import { __dispatch as dispatch } from '@elementor/store';
@@ -56,23 +56,25 @@ type StopSyncConfirmationDialogProps = {
56
56
 
57
57
  const id = 'global-classes-manager';
58
58
 
59
- const reloadDocument = () => {
60
- const currentDocument = getCurrentDocument();
61
- const documentsManager = getV1DocumentsManager();
59
+ export type ClassManagerPanelEmbeddedProps = {
60
+ onRequestClose: () => void | Promise< void >;
61
+ onExposeCloseAttempt?: ( attemptClose: ( () => void ) | null ) => void;
62
+ };
62
63
 
63
- documentsManager.invalidateCache();
64
+ export function ClassManagerPanelEmbedded( { onRequestClose, onExposeCloseAttempt }: ClassManagerPanelEmbeddedProps ) {
65
+ return (
66
+ <ClassManagerPanelRoot
67
+ embedded
68
+ onRequestClose={ onRequestClose }
69
+ onExposeCloseAttempt={ onExposeCloseAttempt }
70
+ />
71
+ );
72
+ }
64
73
 
65
- return runCommand( 'editor/documents/switch', {
66
- id: currentDocument?.id,
67
- shouldScroll: false,
68
- shouldNavigateToDefaultRoute: false,
69
- } );
70
- };
74
+ export function ClassManagerPanel() {
75
+ return <ClassManagerPanelRoot />;
76
+ }
71
77
 
72
- // We need to disable the app-bar buttons, and the elements overlays when opening the classes manager panel.
73
- // The buttons and overlays are enabled only in edit mode, so we're creating a custom new edit mode that
74
- // will force them to be disabled. We can't use the `preview` edit mode in this case since it'll force
75
- // the panel to be closed.
76
78
  export const { panel, usePanelActions } = createPanel( {
77
79
  id,
78
80
  component: ClassManagerPanel,
@@ -84,19 +86,35 @@ export const { panel, usePanelActions } = createPanel( {
84
86
  },
85
87
  onClose: async () => {
86
88
  changeEditMode( 'edit' );
87
- await reloadDocument();
89
+ await reloadCurrentDocument();
88
90
  unblockPanelInteractions();
89
91
  },
90
92
  isOpenPreviousElement: true,
91
93
  } );
92
94
 
93
- export function ClassManagerPanel() {
95
+ type ClassManagerPanelRootProps = {
96
+ embedded?: boolean;
97
+ onRequestClose?: () => void | Promise< void >;
98
+ onExposeCloseAttempt?: ( attemptClose: ( () => void ) | null ) => void;
99
+ };
100
+
101
+ function ClassManagerPanelRoot( {
102
+ embedded = false,
103
+ onRequestClose,
104
+ onExposeCloseAttempt,
105
+ }: ClassManagerPanelRootProps = {} ) {
94
106
  const isDirty = useDirtyState();
95
- const { close: closePanel } = usePanelActions();
107
+ const { close: closeStandalonePanel } = usePanelActions();
108
+ const closePanel = useMemo(
109
+ () => ( embedded ? onRequestClose ?? ( async () => {} ) : closeStandalonePanel ),
110
+ [ embedded, onRequestClose, closeStandalonePanel ]
111
+ );
112
+
96
113
  const { open: openSaveChangesDialog, close: closeSaveChangesDialog, isOpen: isSaveChangesDialogOpen } = useDialog();
97
114
  const [ stopSyncConfirmation, setStopSyncConfirmation ] = useState< string | null >( null );
98
115
  const [ startSyncConfirmation, setStartSyncConfirmation ] = useState< string | null >( null );
99
116
  const [ isStopSyncSuppressed ] = useSuppressedMessage( STOP_SYNC_MESSAGE_KEY );
117
+ const [ scrollElement, setScrollElement ] = useState< HTMLElement | null >( null );
100
118
 
101
119
  const { mutateAsync: publish, isPending: isPublishing } = usePublish();
102
120
 
@@ -105,6 +123,37 @@ export function ClassManagerPanel() {
105
123
  closeSaveChangesDialog();
106
124
  };
107
125
 
126
+ const handleClosePanel = useCallback( () => {
127
+ if ( isDirty ) {
128
+ openSaveChangesDialog();
129
+ return;
130
+ }
131
+
132
+ void closePanel();
133
+ }, [ isDirty, openSaveChangesDialog, closePanel ] );
134
+
135
+ useEffect( () => {
136
+ if ( ! embedded || ! onExposeCloseAttempt ) {
137
+ return;
138
+ }
139
+
140
+ onExposeCloseAttempt( () => handleClosePanel() );
141
+
142
+ return () => onExposeCloseAttempt( null );
143
+ }, [ embedded, onExposeCloseAttempt, handleClosePanel ] );
144
+
145
+ useEffect( () => {
146
+ if ( ! embedded ) {
147
+ return;
148
+ }
149
+
150
+ blockPanelInteractions();
151
+
152
+ return () => {
153
+ unblockPanelInteractions();
154
+ };
155
+ }, [ embedded ] );
156
+
108
157
  const handleStopSync = useCallback( ( classId: string ) => {
109
158
  dispatch(
110
159
  slice.actions.update( {
@@ -144,83 +193,56 @@ export function ClassManagerPanel() {
144
193
 
145
194
  usePreventUnload();
146
195
 
147
- return (
148
- <ThemeProvider>
149
- <ErrorBoundary fallback={ <ErrorBoundaryFallback /> }>
150
- <Panel>
151
- <SearchAndFilterProvider>
152
- <PanelHeader>
153
- <Stack p={ 1 } pl={ 2 } width="100%" direction="row" alignItems="center">
154
- <Stack width="100%" direction="row" gap={ 1 }>
155
- <PanelHeaderTitle sx={ { display: 'flex', alignItems: 'center', gap: 0.5 } }>
156
- <FlippedColorSwatchIcon fontSize="inherit" />
157
- { __( 'Class Manager', 'elementor' ) }
158
- </PanelHeaderTitle>
159
- <TotalCssClassCounter />
160
- </Stack>
161
- <CloseButton
162
- sx={ { marginLeft: 'auto' } }
163
- disabled={ isPublishing }
164
- onClose={ () => {
165
- if ( isDirty ) {
166
- openSaveChangesDialog();
167
- return;
168
- }
169
-
170
- closePanel();
171
- } }
172
- />
173
- </Stack>
174
- </PanelHeader>
175
- <PanelBody
176
- sx={ {
177
- display: 'flex',
178
- flexDirection: 'column',
179
- height: '100%',
180
- } }
181
- >
182
- <Box px={ 2 } pb={ 1 }>
183
- <Stack direction="row" justifyContent="spaceBetween" gap={ 0.5 } sx={ { pb: 0.5 } }>
184
- <Box sx={ { flexGrow: 1 } }>
185
- <ClassManagerSearch />
186
- </Box>
187
- <CssClassFilter />
188
- </Stack>
189
- <ActiveFilters />
190
- </Box>
191
- <Divider />
192
- <Box
193
- px={ 2 }
194
- sx={ {
195
- flexGrow: 1,
196
- overflowY: 'auto',
197
- } }
198
- >
199
- <GlobalClassesList
200
- disabled={ isPublishing }
201
- onStopSyncRequest={ handleStopSyncRequest }
202
- onStartSyncRequest={ ( classId ) => setStartSyncConfirmation( classId ) }
203
- />
204
- </Box>
205
- </PanelBody>
206
-
207
- <PanelFooter>
208
- <Button
209
- fullWidth
210
- size="small"
211
- color="global"
212
- variant="contained"
213
- onClick={ publish }
214
- disabled={ ! isDirty }
215
- loading={ isPublishing }
216
- >
217
- { __( 'Save changes', 'elementor' ) }
218
- </Button>
219
- </PanelFooter>
220
- </SearchAndFilterProvider>
221
- </Panel>
222
- </ErrorBoundary>
223
- <ClassManagerIntroduction />
196
+ const searchFiltersBlock = (
197
+ <Box px={ 2 } pb={ 1 }>
198
+ <Stack direction="row" alignItems="center" justifyContent="space-between" gap={ 0.5 } sx={ { pb: 0.5 } }>
199
+ <Box sx={ embedded ? { flexGrow: 1, minWidth: 0 } : { flexGrow: 1 } }>
200
+ <ClassManagerSearch />
201
+ </Box>
202
+ <CssClassFilter />
203
+ { embedded && <TotalCssClassCounter /> }
204
+ </Stack>
205
+ <ActiveFilters />
206
+ </Box>
207
+ );
208
+
209
+ const listArea = (
210
+ <Box
211
+ ref={ setScrollElement }
212
+ px={ 2 }
213
+ sx={ {
214
+ flexGrow: 1,
215
+ overflowY: 'auto',
216
+ ...( embedded ? { minHeight: 0 } : {} ),
217
+ } }
218
+ >
219
+ <GlobalClassesList
220
+ disabled={ isPublishing }
221
+ scrollElement={ scrollElement }
222
+ onStopSyncRequest={ handleStopSyncRequest }
223
+ onStartSyncRequest={ ( classId ) => setStartSyncConfirmation( classId ) }
224
+ />
225
+ </Box>
226
+ );
227
+
228
+ const saveFooter = (
229
+ <PanelFooter>
230
+ <Button
231
+ fullWidth
232
+ size="small"
233
+ color="global"
234
+ variant="contained"
235
+ onClick={ publish }
236
+ disabled={ ! isDirty }
237
+ loading={ isPublishing }
238
+ >
239
+ { __( 'Save changes', 'elementor' ) }
240
+ </Button>
241
+ </PanelFooter>
242
+ );
243
+
244
+ const dialogs = (
245
+ <>
224
246
  { startSyncConfirmation && (
225
247
  <StartSyncToV3Modal
226
248
  externalOpen
@@ -264,19 +286,93 @@ export function ClassManagerPanel() {
264
286
  action: async () => {
265
287
  await publish();
266
288
  closeSaveChangesDialog();
267
- closePanel();
289
+ void closePanel();
268
290
  },
269
291
  },
270
292
  } }
271
293
  />
272
294
  </SaveChangesDialog>
273
295
  ) }
274
- </ThemeProvider>
296
+ </>
297
+ );
298
+
299
+ const classManagerLayout = embedded ? (
300
+ <Stack
301
+ direction="column"
302
+ sx={ {
303
+ height: '100%',
304
+ width: '100%',
305
+ flex: 1,
306
+ minHeight: 0,
307
+ overflow: 'hidden',
308
+ } }
309
+ >
310
+ { searchFiltersBlock }
311
+ <Divider />
312
+ { listArea }
313
+ { saveFooter }
314
+ </Stack>
315
+ ) : (
316
+ <Panel>
317
+ <PanelHeader>
318
+ <Stack p={ 1 } pl={ 2 } width="100%" direction="row" alignItems="center">
319
+ <Stack width="100%" direction="row" gap={ 1 }>
320
+ <PanelHeaderTitle sx={ { display: 'flex', alignItems: 'center', gap: 0.5 } }>
321
+ <FlippedColorSwatchIcon fontSize="inherit" />
322
+ { __( 'Class Manager', 'elementor' ) }
323
+ </PanelHeaderTitle>
324
+ <TotalCssClassCounter />
325
+ </Stack>
326
+ <ClassPanelCloseButton
327
+ disabled={ isPublishing }
328
+ onClose={ () => {
329
+ if ( isDirty ) {
330
+ openSaveChangesDialog();
331
+ return;
332
+ }
333
+
334
+ void closeStandalonePanel();
335
+ } }
336
+ />
337
+ </Stack>
338
+ </PanelHeader>
339
+ <PanelBody
340
+ sx={ {
341
+ display: 'flex',
342
+ flexDirection: 'column',
343
+ height: '100%',
344
+ } }
345
+ >
346
+ { searchFiltersBlock }
347
+ <Divider />
348
+ { listArea }
349
+ </PanelBody>
350
+ { saveFooter }
351
+ </Panel>
275
352
  );
353
+
354
+ const core = (
355
+ <>
356
+ <ErrorBoundary fallback={ <ErrorBoundaryFallback /> }>
357
+ <SearchAndFilterProvider>{ classManagerLayout }</SearchAndFilterProvider>
358
+ </ErrorBoundary>
359
+ <ClassManagerIntroduction />
360
+ { dialogs }
361
+ </>
362
+ );
363
+
364
+ return embedded ? core : <ThemeProvider>{ core }</ThemeProvider>;
276
365
  }
277
366
 
278
- const CloseButton = ( { onClose, ...props }: IconButtonProps & { onClose: () => void } ) => (
279
- <IconButton size="small" color="secondary" onClick={ onClose } aria-label="Close" { ...props }>
367
+ const ClassPanelCloseButton = ( { onClose, sx, ...props }: IconButtonProps & { onClose: () => void } ) => (
368
+ <IconButton
369
+ size="small"
370
+ color="secondary"
371
+ onClick={ onClose }
372
+ aria-label="Close"
373
+ sx={ { marginLeft: 'auto', ...sx } }
374
+ { ...props }
375
+ >
280
376
  <XIcon fontSize="small" />
281
377
  </IconButton>
282
378
  );
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
3
3
  import { type StyleDefinition, type StyleDefinitionID } from '@elementor/editor-styles';
4
4
  import { __useDispatch as useDispatch } from '@elementor/store';
5
5
  import { List, Stack, styled, Typography, type TypographyProps } from '@elementor/ui';
6
+ import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual';
6
7
  import { __ } from '@wordpress/i18n';
7
8
 
8
9
  import { useClassesOrder } from '../../hooks/use-classes-order';
@@ -17,13 +18,22 @@ import { FlippedColorSwatchIcon } from './flipped-color-swatch-icon';
17
18
  import { getNotFoundType, NotFound } from './not-found';
18
19
  import { SortableItem, SortableProvider } from './sortable';
19
20
 
21
+ const ROW_HEIGHT = 40;
22
+ const OVERSCAN = 6;
23
+
20
24
  type GlobalClassesListProps = {
21
25
  disabled?: boolean;
26
+ scrollElement?: HTMLElement | null;
22
27
  onStopSyncRequest?: ( id: string ) => void;
23
28
  onStartSyncRequest?: ( id: string ) => void;
24
29
  };
25
30
 
26
- export const GlobalClassesList = ( { disabled, onStopSyncRequest, onStartSyncRequest }: GlobalClassesListProps ) => {
31
+ export const GlobalClassesList = ( {
32
+ disabled,
33
+ scrollElement,
34
+ onStopSyncRequest,
35
+ onStartSyncRequest,
36
+ }: GlobalClassesListProps ) => {
27
37
  const {
28
38
  search: { debouncedValue: searchValue },
29
39
  } = useSearchAndFilters();
@@ -35,6 +45,27 @@ export const GlobalClassesList = ( { disabled, onStopSyncRequest, onStartSyncReq
35
45
  const [ classesOrder, reorderClasses ] = useReorder( draggedItemId, setDraggedItemId, draggedItemLabel ?? '' );
36
46
  const filteredCssClasses = useFilteredCssClasses();
37
47
 
48
+ const virtualizer = useVirtualizer( {
49
+ count: filteredCssClasses.length,
50
+ getScrollElement: () => scrollElement ?? null,
51
+ estimateSize: () => ROW_HEIGHT,
52
+ overscan: OVERSCAN,
53
+ getItemKey: ( index ) => filteredCssClasses[ index ].id,
54
+ // Keep the actively dragged row mounted even when scrolled out of view.
55
+ // SortableItem unregisters its render on unmount, which would make the
56
+ // DragOverlay clone disappear mid-drag.
57
+ rangeExtractor: ( range ) => {
58
+ const indices = new Set( defaultRangeExtractor( range ) );
59
+ if ( draggedItemId ) {
60
+ const draggedItemIndex = filteredCssClasses.findIndex( ( cssClass ) => cssClass.id === draggedItemId );
61
+ if ( draggedItemIndex >= 0 ) {
62
+ indices.add( draggedItemIndex );
63
+ }
64
+ }
65
+ return [ ...indices ].sort( ( a, b ) => a - b );
66
+ },
67
+ } );
68
+
38
69
  useEffect( () => {
39
70
  const handler = ( event: KeyboardEvent ) => {
40
71
  if ( event.key === 'z' && ( event.ctrlKey || event.metaKey ) ) {
@@ -69,19 +100,36 @@ export const GlobalClassesList = ( { disabled, onStopSyncRequest, onStartSyncReq
69
100
 
70
101
  return (
71
102
  <DeleteConfirmationProvider>
72
- <List sx={ { display: 'flex', flexDirection: 'column', gap: 0.5 } }>
103
+ <List
104
+ sx={ {
105
+ position: 'relative',
106
+ display: 'block',
107
+ height: virtualizer.getTotalSize(),
108
+ padding: 0,
109
+ } }
110
+ >
73
111
  <SortableProvider
74
112
  value={ classesOrder }
75
113
  onChange={ reorderClasses }
114
+ onDragStart={ ( event ) => setDraggedItemId( event.active.id as StyleDefinitionID ) }
115
+ onDragEnd={ () => setDraggedItemId( null ) }
116
+ onDragCancel={ () => setDraggedItemId( null ) }
76
117
  disableDragOverlay={ ! allowSorting }
77
118
  >
78
- { filteredCssClasses?.map( ( cssClass ) => (
79
- <SortableItem key={ cssClass.id } id={ cssClass.id }>
80
- { ( { isDragged, isDragPlaceholder, triggerProps, triggerStyle } ) => {
81
- if ( isDragged && ! draggedItemId ) {
82
- setDraggedItemId( cssClass.id );
83
- }
84
- return (
119
+ { virtualizer.getVirtualItems().map( ( virtualRow ) => {
120
+ const cssClass = filteredCssClasses[ virtualRow.index ];
121
+ return (
122
+ <SortableItem
123
+ key={ virtualRow.key }
124
+ id={ cssClass.id }
125
+ style={ {
126
+ position: 'absolute',
127
+ top: virtualRow.start,
128
+ left: 0,
129
+ width: '100%',
130
+ } }
131
+ >
132
+ { ( { isDragged, isDragPlaceholder, triggerProps, triggerStyle } ) => (
85
133
  <ClassItem
86
134
  id={ cssClass.id }
87
135
  label={ cssClass.label }
@@ -127,10 +175,10 @@ export const GlobalClassesList = ( { disabled, onStopSyncRequest, onStartSyncReq
127
175
  }
128
176
  } }
129
177
  />
130
- );
131
- } }
132
- </SortableItem>
133
- ) ) }
178
+ ) }
179
+ </SortableItem>
180
+ );
181
+ } ) }
134
182
  </SortableProvider>
135
183
  </List>
136
184
  </DeleteConfirmationProvider>
@@ -176,7 +224,7 @@ const useReorder = (
176
224
  classId: draggedItemId,
177
225
  classTitle: draggedItemLabel,
178
226
  } );
179
- setDraggedItemId( null ); // Reset after tracking
227
+ setDraggedItemId( null );
180
228
  }
181
229
  };
182
230