@elementor/editor-controls 4.4.0-1145 → 4.4.0-1146

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@elementor/editor-controls",
3
3
  "description": "This package contains the controls model and utils for the Elementor editor",
4
- "version": "4.4.0-1145",
4
+ "version": "4.4.0-1146",
5
5
  "private": false,
6
6
  "author": "Elementor Team",
7
7
  "homepage": "https://elementor.com/",
@@ -40,23 +40,23 @@
40
40
  "dev": "tsup --config=../../tsup.dev.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@elementor/editor-current-user": "4.4.0-1145",
44
- "@elementor/editor-elements": "4.4.0-1145",
45
- "@elementor/editor-props": "4.4.0-1145",
46
- "@elementor/editor-responsive": "4.4.0-1145",
47
- "@elementor/editor-ui": "4.4.0-1145",
48
- "@elementor/editor-v1-adapters": "4.4.0-1145",
49
- "@elementor/env": "4.4.0-1145",
50
- "@elementor/events": "4.4.0-1145",
51
- "@elementor/http-client": "4.4.0-1145",
43
+ "@elementor/editor-current-user": "4.4.0-1146",
44
+ "@elementor/editor-elements": "4.4.0-1146",
45
+ "@elementor/editor-props": "4.4.0-1146",
46
+ "@elementor/editor-responsive": "4.4.0-1146",
47
+ "@elementor/editor-ui": "4.4.0-1146",
48
+ "@elementor/editor-v1-adapters": "4.4.0-1146",
49
+ "@elementor/env": "4.4.0-1146",
50
+ "@elementor/events": "4.4.0-1146",
51
+ "@elementor/http-client": "4.4.0-1146",
52
52
  "@elementor/icons": "~1.78.1",
53
- "@elementor/locations": "4.4.0-1145",
54
- "@elementor/query": "4.4.0-1145",
55
- "@elementor/schema": "4.4.0-1145",
56
- "@elementor/session": "4.4.0-1145",
53
+ "@elementor/locations": "4.4.0-1146",
54
+ "@elementor/query": "4.4.0-1146",
55
+ "@elementor/schema": "4.4.0-1146",
56
+ "@elementor/session": "4.4.0-1146",
57
57
  "@elementor/ui": "1.41.4",
58
- "@elementor/utils": "4.4.0-1145",
59
- "@elementor/wp-media": "4.4.0-1145",
58
+ "@elementor/utils": "4.4.0-1146",
59
+ "@elementor/wp-media": "4.4.0-1146",
60
60
  "@monaco-editor/react": "^4.7.0",
61
61
  "@tanstack/react-virtual": "^3.13.3",
62
62
  "@tiptap/extension-bold": "^3.11.1",
@@ -18,6 +18,8 @@ export type FontAwesome7Icon = FontAwesome7IconDefinition & {
18
18
  value: string;
19
19
  glyphClass?: string;
20
20
  svgMarkup?: string;
21
+ /** Markup is resolved on demand by the icon library providers, once the icon is displayed. */
22
+ lazySvg?: boolean;
21
23
  };
22
24
 
23
25
  export async function loadFontAwesome7Catalog( signal?: AbortSignal ): Promise< FontAwesome7Icon[] > {
@@ -1,7 +1,9 @@
1
1
  import * as React from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
2
3
  import { Box } from '@elementor/ui';
3
4
 
4
5
  import { type FontAwesome7Icon } from './font-awesome-7-catalog';
6
+ import { resolveProviderIconPreviewSvg } from './icon-library-providers';
5
7
  import { sanitizeIconSvgMarkup } from './sanitize-icon-svg-markup';
6
8
 
7
9
  type FontAwesomeGlyphProps = {
@@ -11,7 +13,71 @@ type FontAwesomeGlyphProps = {
11
13
  label?: string;
12
14
  };
13
15
 
14
- export const FontAwesomeGlyph = ( { icon, size, color, label }: FontAwesomeGlyphProps ) => {
16
+ export const FontAwesomeGlyph = ( props: FontAwesomeGlyphProps ) => {
17
+ const { icon } = props;
18
+
19
+ if ( icon.lazySvg && ! icon.svgMarkup ) {
20
+ return <LazyProviderGlyph { ...props } />;
21
+ }
22
+
23
+ return <ResolvedGlyph { ...props } />;
24
+ };
25
+
26
+ const LazyProviderGlyph = ( props: FontAwesomeGlyphProps ) => {
27
+ const { icon, size } = props;
28
+ const [ resolved, setResolved ] = useState< { id: string; markup: string | null } | null >( null );
29
+ const [ isVisible, setIsVisible ] = useState( typeof IntersectionObserver === 'undefined' );
30
+ const placeholderRef = useRef< HTMLSpanElement >( null );
31
+ const svgMarkup = resolved?.id === icon.id ? resolved.markup : null;
32
+
33
+ useEffect( () => {
34
+ const element = placeholderRef.current;
35
+
36
+ if ( isVisible || ! element ) {
37
+ return;
38
+ }
39
+
40
+ const observer = new IntersectionObserver( ( entries ) => {
41
+ if ( entries.some( ( entry ) => entry.isIntersecting ) ) {
42
+ setIsVisible( true );
43
+ observer.disconnect();
44
+ }
45
+ } );
46
+
47
+ observer.observe( element );
48
+
49
+ return () => observer.disconnect();
50
+ }, [ isVisible ] );
51
+
52
+ useEffect( () => {
53
+ if ( ! isVisible ) {
54
+ return;
55
+ }
56
+
57
+ const controller = new AbortController();
58
+
59
+ resolveProviderIconPreviewSvg( icon.library, icon.value, controller.signal )
60
+ .then( ( markup ) => ! controller.signal.aborted && setResolved( { id: icon.id, markup } ) )
61
+ .catch( () => {} );
62
+
63
+ return () => controller.abort();
64
+ }, [ isVisible, icon.id, icon.library, icon.value ] );
65
+
66
+ if ( ! svgMarkup ) {
67
+ return (
68
+ <Box
69
+ component="span"
70
+ ref={ placeholderRef }
71
+ aria-hidden="true"
72
+ sx={ { display: 'inline-block', width: size, height: size } }
73
+ />
74
+ );
75
+ }
76
+
77
+ return <ResolvedGlyph { ...props } icon={ { ...icon, svgMarkup } } />;
78
+ };
79
+
80
+ const ResolvedGlyph = ( { icon, size, color, label }: FontAwesomeGlyphProps ) => {
15
81
  const svgMarkup = sanitizeIconSvgMarkup( icon.svgMarkup );
16
82
 
17
83
  if ( svgMarkup ) {
@@ -43,6 +43,7 @@ const getGridMetrics = ( containerWidth: number, columnGap: number, inlinePaddin
43
43
  type IconLibraryGridItem = FontAwesome7Icon & {
44
44
  id: string;
45
45
  value: string;
46
+ iconValue?: string;
46
47
  };
47
48
 
48
49
  type IconLibraryGridProps = {
@@ -329,7 +330,7 @@ export const IconLibraryGrid = ( {
329
330
  } }
330
331
  >
331
332
  <FontAwesomeGlyph
332
- icon={ item }
333
+ icon={ { ...item, value: item.iconValue ?? item.value } }
333
334
  size={ ICON_GLYPH_SIZE }
334
335
  color="currentColor"
335
336
  />
@@ -27,6 +27,7 @@ import { IconLibraryGrid } from './icon-library-grid';
27
27
  import { useIconLibraryProviderIcons } from './icon-library-providers';
28
28
  import { IconLibraryUploadAction, IconLibraryUploadActionSlot } from './icon-library-upload-action';
29
29
  import { type IconLibraryView, IconLibraryViewToggle } from './icon-library-view-toggle';
30
+ import { mergeIconsById } from './merge-icons-by-id';
30
31
  import { SvgElementIcon } from './svg-element-icon';
31
32
  import { useFontAwesome7Catalog } from './use-font-awesome-7-catalog';
32
33
 
@@ -54,7 +55,7 @@ const CompactIconLibraryMenuList = styled( StyledMenuList )( ( { theme } ) => (
54
55
  },
55
56
  } ) );
56
57
 
57
- type IconLibraryItem = VirtualizedItem< 'item', string > & Omit< FontAwesome7Icon, 'value' >;
58
+ type IconLibraryItem = VirtualizedItem< 'item', string > & Omit< FontAwesome7Icon, 'value' > & { iconValue: string };
58
59
 
59
60
  type IconLibraryPopoverProps = {
60
61
  open: boolean;
@@ -87,13 +88,16 @@ export const IconLibraryPopover = ( {
87
88
  const view = isIconLibraryView( storedView ) ? storedView : DEFAULT_ICON_LIBRARY_VIEW;
88
89
  const { data: fontAwesomeIcons = [], isLoading: isFontAwesomeLoading } = useFontAwesome7Catalog( open );
89
90
  const { data: providerIcons = [], isLoading: isProviderLoading } = useIconLibraryProviderIcons( open );
90
- const isLoading = isFontAwesomeLoading || isProviderLoading;
91
- const icons = useMemo( () => [ ...fontAwesomeIcons, ...providerIcons ], [ providerIcons, fontAwesomeIcons ] );
91
+ const icons = useMemo(
92
+ () => mergeIconsById( fontAwesomeIcons, providerIcons ),
93
+ [ providerIcons, fontAwesomeIcons ]
94
+ );
92
95
 
93
96
  const items = useMemo(
94
97
  () => createIconLibraryItems( icons, searchValue, activeLibraries ),
95
98
  [ activeLibraries, icons, searchValue ]
96
99
  );
100
+ const isLoading = isFontAwesomeLoading || ( isProviderLoading && items.length === 0 );
97
101
  const selectedValue = useMemo(
98
102
  () => findFontAwesome7Icon( icons, selectedIconClass, selectedIconLibrary )?.id,
99
103
  [ icons, selectedIconClass, selectedIconLibrary ]
@@ -239,7 +243,7 @@ const IconLibraryContent = ( {
239
243
 
240
244
  const IconLibraryLoadingState = () => (
241
245
  <Stack alignItems="center" justifyContent="center" height="100%">
242
- <CircularProgress role="progressbar" size={ 24 } />
246
+ <CircularProgress role="progressbar" size={ 24 } aria-label={ __( 'Loading icons', 'elementor' ) } />
243
247
  </Stack>
244
248
  );
245
249
 
@@ -278,7 +282,11 @@ const IconLibraryRow = ( item: VirtualizedItem< string, string > ) => {
278
282
  flexShrink: 0,
279
283
  } }
280
284
  >
281
- <FontAwesomeGlyph icon={ icon } size={ ICON_GLYPH_SIZE } color="currentColor" />
285
+ <FontAwesomeGlyph
286
+ icon={ { ...icon, value: icon.iconValue } }
287
+ size={ ICON_GLYPH_SIZE }
288
+ color="currentColor"
289
+ />
282
290
  </Box>
283
291
  <Typography variant="caption" color="text.primary" noWrap>
284
292
  { icon.label }
@@ -324,4 +332,5 @@ const createIconLibraryItems = (
324
332
  ...icon,
325
333
  type: 'item',
326
334
  value: icon.id,
335
+ iconValue: icon.value,
327
336
  } ) );
@@ -1,4 +1,4 @@
1
- import { useState } from 'react';
1
+ import { useRef, useState } from 'react';
2
2
  import { useQuery } from '@elementor/query';
3
3
 
4
4
  import { type FontAwesome7Icon } from './font-awesome-7-catalog';
@@ -12,6 +12,11 @@ export type IconLibraryProvider = {
12
12
  id: string;
13
13
  useIcons: ( enabled: boolean ) => IconLibraryProviderState;
14
14
  resolveSvg?: ( library: string, iconValue: string, signal?: AbortSignal ) => Promise< string | null >;
15
+ /**
16
+ * Resolves markup for picker tiles and the control preview. Falls back to `resolveSvg` when omitted.
17
+ * Use it when `resolveSvg` has side effects that should only happen for icons that are actually rendered.
18
+ */
19
+ resolvePreviewSvg?: ( library: string, iconValue: string, signal?: AbortSignal ) => Promise< string | null >;
15
20
  };
16
21
 
17
22
  const PROVIDER_ICON_SVG_QUERY_KEY = 'icon-library-provider-svg';
@@ -35,20 +40,44 @@ export function useIconLibraryProviderIcons( enabled: boolean ): Required< IconL
35
40
  const [ snapshot ] = useState( () => [ ...providers.values() ] );
36
41
  const states = snapshot.map( ( provider ) => provider.useIcons( enabled ) );
37
42
 
43
+ const data = useStableConcat( states.map( ( state ) => state.data ) );
44
+
38
45
  return {
39
- data: states.flatMap( ( state ) => state.data ?? [] ),
46
+ data,
40
47
  isLoading: states.some( ( state ) => state.isLoading ),
41
48
  };
42
49
  }
43
50
 
44
- export async function resolveProviderIconSvg(
51
+ export function resolveProviderIconSvg(
52
+ library: string,
53
+ iconValue: string,
54
+ signal?: AbortSignal
55
+ ): Promise< string | null > {
56
+ return resolveFromProviders( ( provider ) => provider.resolveSvg, library, iconValue, signal );
57
+ }
58
+
59
+ export function resolveProviderIconPreviewSvg(
60
+ library: string,
61
+ iconValue: string,
62
+ signal?: AbortSignal
63
+ ): Promise< string | null > {
64
+ return resolveFromProviders(
65
+ ( provider ) => provider.resolvePreviewSvg ?? provider.resolveSvg,
66
+ library,
67
+ iconValue,
68
+ signal
69
+ );
70
+ }
71
+
72
+ async function resolveFromProviders(
73
+ pickResolver: ( provider: IconLibraryProvider ) => IconLibraryProvider[ 'resolveSvg' ],
45
74
  library: string,
46
75
  iconValue: string,
47
76
  signal?: AbortSignal
48
77
  ): Promise< string | null > {
49
78
  for ( const provider of providers.values() ) {
50
79
  try {
51
- const markup = await provider.resolveSvg?.( library, iconValue, signal );
80
+ const markup = await pickResolver( provider )?.( library, iconValue, signal );
52
81
 
53
82
  if ( markup ) {
54
83
  return markup;
@@ -63,6 +92,21 @@ export async function resolveProviderIconSvg(
63
92
  return null;
64
93
  }
65
94
 
95
+ // Keeps the merged array identity while every provider returns the same data reference, so downstream memos hold.
96
+ function useStableConcat( lists: Array< FontAwesome7Icon[] | undefined > ): FontAwesome7Icon[] {
97
+ const previous = useRef< { lists: typeof lists; merged: FontAwesome7Icon[] } | null >( null );
98
+
99
+ if ( ! previous.current || ! areSameLists( previous.current.lists, lists ) ) {
100
+ previous.current = { lists, merged: lists.flatMap( ( list ) => list ?? [] ) };
101
+ }
102
+
103
+ return previous.current.merged;
104
+ }
105
+
106
+ function areSameLists( a: Array< unknown >, b: Array< unknown > ) {
107
+ return a.length === b.length && a.every( ( item, index ) => item === b[ index ] );
108
+ }
109
+
66
110
  export function useProviderIconSvg( library: string | null, iconValue: string | null, enabled: boolean ) {
67
111
  return useQuery< string | null >( {
68
112
  queryKey: [ PROVIDER_ICON_SVG_QUERY_KEY, library, iconValue ],
@@ -0,0 +1,25 @@
1
+ type MergeableIcon = { id: string; library?: string; name?: string; aliases?: string[] };
2
+
3
+ const getNames = ( icon: MergeableIcon ) => [ icon.name, ...( icon.aliases ?? [] ) ].filter( Boolean );
4
+
5
+ /**
6
+ * Providers may name an icon by one of its aliases (Pro lists `home` where Free lists `house`),
7
+ * so an icon already present under any of its names in the same library is not added again.
8
+ * @param primary
9
+ * @param secondary
10
+ */
11
+ export function mergeIconsById< T extends MergeableIcon >( primary: T[], secondary: T[] ): T[] {
12
+ const ids = new Set( primary.map( ( icon ) => icon.id ) );
13
+ const names = new Set(
14
+ primary.flatMap( ( icon ) => getNames( icon ).map( ( name ) => `${ icon.library }:${ name }` ) )
15
+ );
16
+
17
+ return [
18
+ ...primary,
19
+ ...secondary.filter(
20
+ ( icon ) =>
21
+ ! ids.has( icon.id ) &&
22
+ ! getNames( icon ).some( ( name ) => names.has( `${ icon.library }:${ name }` ) )
23
+ ),
24
+ ];
25
+ }
@@ -1,10 +1,12 @@
1
1
  import * as React from 'react';
2
+ import { useMemo } from 'react';
2
3
  import { Box, CardMedia, CircularProgress } from '@elementor/ui';
3
4
  import { __ } from '@wordpress/i18n';
4
5
 
5
6
  import { findFontAwesome7Icon, type FontAwesome7Icon } from './icon-library/font-awesome-7-catalog';
6
7
  import { FontAwesomeGlyph } from './icon-library/font-awesome-glyph';
7
8
  import { useIconLibraryProviderIcons, useProviderIconSvg } from './icon-library/icon-library-providers';
9
+ import { mergeIconsById } from './icon-library/merge-icons-by-id';
8
10
  import { useFontAwesome7Catalog } from './icon-library/use-font-awesome-7-catalog';
9
11
 
10
12
  const ICON_PREVIEW_SIZE = 50;
@@ -21,25 +23,35 @@ export const SvgMediaPreview = ( { isFetching, src, iconClassName, iconLibrary }
21
23
  const shouldLoadIconCatalog = Boolean( iconClassName && iconLibrary );
22
24
  const { data: fontAwesomeIcons = [], isLoading: isFontAwesomeLoading } =
23
25
  useFontAwesome7Catalog( shouldLoadIconCatalog );
24
- const { data: providerIcons = [], isLoading: isProviderLoading } =
25
- useIconLibraryProviderIcons( shouldLoadIconCatalog );
26
- const icons = [ ...fontAwesomeIcons, ...providerIcons ];
26
+ const isFontAwesomeLibrary = Boolean( iconLibrary?.startsWith( 'fa-' ) );
27
+ const { data: providerIcons = [], isLoading: isProviderLoading } = useIconLibraryProviderIcons(
28
+ shouldLoadIconCatalog && ! isFontAwesomeLibrary
29
+ );
30
+ const icons = useMemo(
31
+ () => mergeIconsById( fontAwesomeIcons, providerIcons ),
32
+ [ fontAwesomeIcons, providerIcons ]
33
+ );
27
34
  const selectedIcon = findFontAwesome7Icon( icons, iconClassName, iconLibrary );
28
- const isLoadingCatalog = shouldLoadIconCatalog && ( isFontAwesomeLoading || isProviderLoading );
35
+ const isLoadingCatalog =
36
+ shouldLoadIconCatalog && ( isFontAwesomeLoading || ( ! isFontAwesomeLibrary && isProviderLoading ) );
29
37
  const { data: savedIconSvg, isLoading: isSavedIconLoading } = useProviderIconSvg(
30
38
  iconLibrary,
31
39
  iconClassName,
32
- shouldLoadIconCatalog && ! isLoadingCatalog && ! selectedIcon
40
+ shouldLoadIconCatalog && ! isLoadingCatalog && ! selectedIcon && ! isFontAwesomeLibrary
33
41
  );
34
42
 
35
43
  if ( isFetching || isLoadingCatalog || isSavedIconLoading ) {
36
- return <CircularProgress role="progressbar" />;
44
+ return <CircularProgress role="progressbar" aria-label={ __( 'Loading icon', 'elementor' ) } />;
37
45
  }
38
46
 
39
47
  if ( selectedIcon ) {
40
48
  return <IconPreview icon={ selectedIcon } />;
41
49
  }
42
50
 
51
+ if ( shouldLoadIconCatalog && iconClassName && iconLibrary && isFontAwesomeLibrary ) {
52
+ return <IconPreview icon={ createLazyIcon( iconLibrary, iconClassName ) } />;
53
+ }
54
+
43
55
  if ( savedIconSvg ) {
44
56
  return <IconPreview icon={ toSvgMarkupIcon( savedIconSvg, iconLibrary, iconClassName ) } />;
45
57
  }
@@ -51,6 +63,19 @@ export const SvgMediaPreview = ( { isFetching, src, iconClassName, iconLibrary }
51
63
  return <SvgPreview src={ src } />;
52
64
  };
53
65
 
66
+ const createLazyIcon = ( library: string, value: string ): FontAwesome7Icon => ( {
67
+ id: `${ library }:${ value }`,
68
+ name: value,
69
+ label: value,
70
+ library,
71
+ value,
72
+ aliases: [],
73
+ width: ICON_PREVIEW_SIZE,
74
+ height: ICON_PREVIEW_SIZE,
75
+ paths: [],
76
+ lazySvg: true,
77
+ } );
78
+
54
79
  const toSvgMarkupIcon = ( svgMarkup: string, library: string | null, value: string | null ): FontAwesome7Icon => ( {
55
80
  id: `${ library }:${ value }`,
56
81
  label: value ?? '',
@@ -65,7 +90,7 @@ const toSvgMarkupIcon = ( svgMarkup: string, library: string | null, value: stri
65
90
  } );
66
91
 
67
92
  const IconPreview = ( { icon }: { icon: FontAwesome7Icon } ) => (
68
- <Box sx={ { color: ICON_PREVIEW_COLOR } }>
93
+ <Box sx={ { color: ICON_PREVIEW_COLOR, minWidth: ICON_PREVIEW_SIZE, minHeight: ICON_PREVIEW_SIZE } }>
69
94
  <FontAwesomeGlyph
70
95
  icon={ icon }
71
96
  size={ ICON_PREVIEW_SIZE }
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export { PopoverContent } from './components/popover-content';
39
39
  export { enqueueFont } from './controls/font-family-control/enqueue-font';
40
40
  export {
41
41
  registerIconLibraryProvider,
42
+ resolveProviderIconPreviewSvg,
42
43
  resolveProviderIconSvg,
43
44
  unregisterIconLibraryProvider,
44
45
  type IconLibraryProvider,