Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/global-styles-ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### Enhancements

- Add a `showBlockStateControls` prop to `GlobalStylesUI`, defaulting to `true`, which hides the state controls for blocks when set to `false` ([#80956](https://github.com/WordPress/gutenberg/pull/80956)).
- Font Library: Add a skeleton loader for the font preview images ([#81047](https://github.com/WordPress/gutenberg/pull/81047)).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to be repositioned into the Unreleased section before merge.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks as always! 🙏


## 1.19.0 (2026-07-29)

Expand Down
83 changes: 66 additions & 17 deletions packages/global-styles-ui/src/font-library/font-demo.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { __experimentalText as WCText } from '@wordpress/components';
import clsx from 'clsx';
import { useContext, useEffect, useState, useRef } from '@wordpress/element';
import type { FontFamily, FontFace } from '@wordpress/core-data';
import { Text, Skeleton } from '@wordpress/ui';
import { FontLibraryContext } from './context';
import {
getFacePreviewStyle,
getFamilyPreviewStyle,
} from './utils/preview-styles';
import type { FontDemoProps } from './types';

// Previews already loaded this session, so reopening the library doesn't flash
// a placeholder for images the browser already has.
const loadedPreviews = new Set< string >();

function getPreviewUrl( fontFace: FontFace ): string | undefined {
if ( fontFace.preview ) {
return fontFace.preview;
Expand Down Expand Up @@ -47,19 +52,29 @@ function getDisplayFontFace( font: FontFamily | FontFace ): FontFace {

function FontDemo( { font, text }: FontDemoProps ) {
const ref = useRef< HTMLDivElement >( null );
const imgRef = useRef< HTMLImageElement >( null );

const fontFace = getDisplayFontFace( font );
const style = getFamilyPreviewStyle( font );
text = text || ( 'name' in font ? font.name : '' );
const customPreviewUrl = font.preview;

const previewUrl = customPreviewUrl ?? getPreviewUrl( fontFace );
const isPreviewImage = Boolean(
previewUrl && /\.(png|jpg|jpeg|gif|svg)$/i.test( previewUrl )
);

const [ isIntersecting, setIsIntersecting ] = useState< boolean >( false );
const [ isAssetLoaded, setIsAssetLoaded ] = useState< boolean >( false );
const [ isAssetLoaded, setIsAssetLoaded ] = useState< boolean >(
() => !! previewUrl && loadedPreviews.has( previewUrl )
);
const { loadFontFaceAsset } = useContext( FontLibraryContext );

const previewUrl = customPreviewUrl ?? getPreviewUrl( fontFace );
const isPreviewImage =
previewUrl && previewUrl.match( /\.(png|jpg|jpeg|gif|svg)$/i );
// The previews scale with the label, so estimate ~12px per character.
const estimatedImageWidth = Math.min(
Math.max( text.length * 12, 48 ),
300
);

const faceStyles = getFacePreviewStyle( fontFace );
const textDemoStyle = {
Expand All @@ -71,19 +86,33 @@ function FontDemo( { font, text }: FontDemoProps ) {
};

useEffect( () => {
// Only font files need the viewport check. Image previews are lazy
// loaded by the browser, and every row in the collection has one.
if ( isPreviewImage ) {
return;
}
const observer = new window.IntersectionObserver( ( [ entry ] ) => {
setIsIntersecting( entry.isIntersecting );
}, {} );
if ( ref.current ) {
observer.observe( ref.current );
}
return () => observer.disconnect();
}, [ ref ] );
}, [ isPreviewImage ] );

// Re-check on preview change, since an already complete image won't fire
// onLoad and the component is reused across fonts.
useEffect( () => {
setIsAssetLoaded(
( !! previewUrl && loadedPreviews.has( previewUrl ) ) ||
!! imgRef.current?.complete
);
}, [ previewUrl ] );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this current state management, isAssetLoaded remains true during the first render after previewUrl changes. Would that result in a brief flash of the skeleton disappearing? Maybe worth tweaking the state management to prevent this, if the complexity is roughly the same and the flash is indeed noticeable. No strong opinion though, I wouldn't want to overcomplicate the code if the flash is rare or barely noticeable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! Actually while checking this, I ended up seeing that it could be simpler than what I initially proposed. If we track which url was loaded we won't have the extra render. That way we could also get rid of the effect and the ref.

Updated 1b01cab


useEffect( () => {
const loadAsset = async () => {
if ( isIntersecting ) {
if ( ! isPreviewImage && fontFace.src ) {
if ( isIntersecting && ! isPreviewImage ) {
if ( fontFace.src ) {
await loadFontFaceAsset( fontFace );
}
setIsAssetLoaded( true );
Expand All @@ -93,21 +122,41 @@ function FontDemo( { font, text }: FontDemoProps ) {
}, [ fontFace, isIntersecting, loadFontFaceAsset, isPreviewImage ] );

return (
<div ref={ ref }>
<div ref={ ref } className="font-library__font-demo">
{ isPreviewImage ? (
<img
src={ previewUrl }
loading="lazy"
alt={ text }
className="font-library__font-variant_demo-image"
/>
<>
{ ! isAssetLoaded && (
<Skeleton
className="font-library__font-variant-demo-skeleton"
style={ { width: estimatedImageWidth } }
/>
) }
<img
ref={ imgRef }
src={ previewUrl }
loading="lazy"
alt={ text }
onLoad={ () => {
if ( previewUrl ) {
loadedPreviews.add( previewUrl );
}
setIsAssetLoaded( true );
} }
// Also on failure, otherwise skeleton will pulse forever.
onError={ () => setIsAssetLoaded( true ) }
className={ clsx(
'font-library__font-variant_demo-image',
{ 'is-loading': ! isAssetLoaded }
) }
/>
</>
) : (
<WCText
<Text

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker, but unclear to me whether a Text component is necessary here when it seems like basically all relevant styles are passed in as custom.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, not really necessary

Although I found a bug with variable fonts while trying to migrate. They declare weights as ranges, and we pass that straight to the font-weight property. That's valid in @font-face but not in font-weight, so the browser ignores it and the preview inherits the button weight (600)

If we move off Text to a span that problem will become evident. I'll work on the migration + bug fix as a follow-up so that we keep this for the skeleton loader.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #81748

style={ textDemoStyle }
className="font-library__font-variant_demo-text"
>
{ text }
</WCText>
</Text>
) }
</div>
);
Expand Down
25 changes: 25 additions & 0 deletions packages/global-styles-ui/src/font-library/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ $footer-height: 90px;
margin-bottom: 0;
}

.font-library__font-demo {
// Anchors the image while it's out of flow during loading.
position: relative;
}

.font-library__font-card {
box-sizing: border-box;
border: variables.$border-width solid colors.$gray-300;
Expand Down Expand Up @@ -148,6 +153,26 @@ $footer-height: 90px;
display: block;
height: variables.$grid-unit-30;
width: auto;

@media not (prefers-reduced-motion) {
transition:
opacity var(--wpds-motion-duration-lg)
var(--wpds-motion-easing-subtle);
}

// Hidden while loading, but still rendered, since `display: none`
// would stop the lazy image from loading.
&.is-loading {
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
}

.font-library__font-variant-demo-skeleton {
height: variables.$grid-unit-30;
border-radius: var(--wpds-border-radius-sm);
}

.font-library__font-variant_demo-text {
Expand Down
5 changes: 0 additions & 5 deletions tools/eslint/suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1568,11 +1568,6 @@
"count": 6
}
},
"packages/global-styles-ui/src/font-library/font-demo.tsx": {
"@wordpress/use-recommended-components": {
"count": 1
}
},
"packages/global-styles-ui/src/font-library/google-fonts-confirm-dialog.tsx": {
"@wordpress/use-recommended-components": {
"count": 5
Expand Down
Loading