Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a client-side font face resolver in the editor #65019

Open
wants to merge 20 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6219a6d
add editor font face resolver component
matiasbenedetto Sep 3, 2024
1e8ef55
remove code not needed
matiasbenedetto Sep 4, 2024
65f3754
default value when font families aren't defined
matiasbenedetto Sep 5, 2024
77f7638
comments formatting
matiasbenedetto Sep 9, 2024
0bed01d
use callback for loadFontFaceAsset
matiasbenedetto Sep 9, 2024
48bfe65
improve syntax
matiasbenedetto Sep 9, 2024
65d147a
Merge branch 'trunk' into add/editor-font-face-resolver
matiasbenedetto Sep 11, 2024
c6a32f9
Move EditorFontsResolver inside EditorStyles, use fontFamilies data f…
matiasbenedetto Sep 13, 2024
5a9e2d0
use a ref to reference the current document
matiasbenedetto Sep 13, 2024
27b5e64
currentTheme default to empty object
matiasbenedetto Sep 16, 2024
463fa95
revert changes on useDarkThemeBodyClassName, refactor useEditorFontsR…
matiasbenedetto Sep 18, 2024
9bd3378
revert not needed change
matiasbenedetto Sep 18, 2024
5cecd18
Merge branch 'trunk' into add/editor-font-face-resolver
matiasbenedetto Sep 20, 2024
c262896
Merge branch 'trunk' into add/editor-font-face-resolver
matiasbenedetto Sep 20, 2024
8ced871
try adding currentTheme to the editor settings
matiasbenedetto Sep 20, 2024
ee98b6e
add theme fonts uris
matiasbenedetto Sep 26, 2024
1461989
use _links in the font resolver
matiasbenedetto Sep 27, 2024
8172039
Merge branch 'trunk' into add/editor-font-face-resolver
matiasbenedetto Sep 30, 2024
7cb4c01
Fix PHP linting
getdave Oct 7, 2024
20a00e3
Use correct alias
getdave Oct 7, 2024
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
3 changes: 3 additions & 0 deletions packages/block-editor/src/components/block-canvas/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import WritingFlow from '../writing-flow';
import { useMouseMoveTypingReset } from '../observe-typing';
import { useBlockSelectionClearer } from '../block-selection-clearer';
import { useBlockCommands } from '../use-block-commands';
import EditorFontsResolver from '../editor-fonts-resolver';

// EditorStyles is a memoized component, so avoid passing a new
// object reference on each render.
Expand Down Expand Up @@ -43,6 +44,7 @@ export function ExperimentalBlockCanvas( {
__unstableContentRef={ localRef }
style={ { height, display: 'flex' } }
>
<EditorFontsResolver />
Copy link
Member

Choose a reason for hiding this comment

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

@youknowriad or @ellatrix since you've edited this file before, could you please sense-check how the fonts resolver is being added here?

<EditorStyles
styles={ styles }
scope=":where(.editor-styles-wrapper)"
Expand Down Expand Up @@ -77,6 +79,7 @@ export function ExperimentalBlockCanvas( {
} }
name="editor-canvas"
>
<EditorFontsResolver />
<EditorStyles styles={ styles } />
{ children }
</Iframe>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* WordPress dependencies
*/
import { useState, useEffect, useMemo, useCallback } from '@wordpress/element';
import { useSelect } from '@wordpress/data';

/**
* Internal dependencies
*/
import { useGlobalSetting } from '../global-styles/hooks';
import { getDisplaySrcFromFontFace, loadFontFaceInBrowser } from './utils';

function EditorFontsResolver() {
const [ loadedFontUrls, setLoadedFontUrls ] = useState( new Set() );

const { currentTheme } = useSelect( ( select ) => {
return {
currentTheme:
// Disable Reason: Using 'core' as string to avoid circular dependency importing from @wordpress/core-data.
// eslint-disable-next-line @wordpress/data-no-store-string-literals
select( 'core' ).getCurrentTheme(),
};
}, [] );

// Get the fonts from merged theme.json settings.fontFamilies.
const [ fontFamilies = [] ] = useGlobalSetting( 'typography.fontFamilies' );
Copy link
Contributor

Choose a reason for hiding this comment

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

I know it's hard to figure that out on your own but If I'm not wrong useGlobalSetting doesn't work consistently in all editors. In fact, we should probably avoid using it in the "block-editor" package entirely because it relies on the presence of a provider that may or may not exist (it doesn't exist in the post editor for instance).

@aaronrobertshaw added a recently a new block editor setting that allows us to access global styles in all editors if I'm not wrong and we should probably be using that.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the ping 👍

a new block editor setting that allows us to access global styles in all editors

This was specifically related to the styles data from Global Styles as the settings values were already included within the block editor settings.

The Global Styles settings and styles weren't merged as there wasn't consensus on what the shape of those settings should be while maintaining BC. That led to using a symbol as a private key for the Global Styles style data.

Here's the PR adding the style data to the block editor settings: #61556.

Without looking into the code I'm not sure whether the Global Styles settings under __experimentalFeatures in the editor settings are kept in sync with unsaved Global Styles changes in the site editor. It could be possible that aspect needs addressing or perhaps in the short term this could fallback to the editor settings if the Global Styles context isn't available (in post editor).

Copy link
Contributor

@youknowriad youknowriad Sep 11, 2024

Choose a reason for hiding this comment

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

Good point, I mistakenly thought this is about the styles.

This PR reminds of the "styles" prop that we pass to the block editor to render the current theme styles. I could suggest a new "type" within the same "styles" prop to load fonts (like we do for svg for instance) but first. I would like to understand more.

What fonts are we trying to resolve here. I'm guessing the fonts that are used in the theme styles right? In which case, I feel like "resolving them" or "rendering them" should be done in the EditorStyles component. It's a component that is already used to "resolve or render" SVGs and CSS, it's already rendered in both iframe and non-iframe, so I'm starting to think that it's probably the right place for this change? (Introduce a new "type" of style and render it within that component)

Please correct me if my argumentation or abstraction seem too far off

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure how to access global styles settings in a component like EditorStyles without using useGlobalSetting. For this particular implementation I'm interested in getting the 'client side latest version' of settings.typography.fontFamilies.

as the settings values were already included within the block editor settings

@aaronrobertshaw could you show me how is this done? I haven't found how to access the fontFamilies path mentioned before.

What fonts are we trying to resolve here. I'm guessing the fonts that are used in the theme styles right?

Yes. This PR was motivated by that, but implementing this would make us able to remove the manual loading of fonts when a font is installed using the font library, too, so we could simplify a little bit that code.

I feel like "resolving them" or "rendering them" should be done in the EditorStyles component.

@youknowriad I'm not sure about that, EditorStyles gets styles and not settings. At least, as it is today, that component is not getting the data needed to implement the desired functionality.

Copy link
Contributor

@youknowriad youknowriad Sep 11, 2024

Choose a reason for hiding this comment

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

I'm not sure about that, EditorStyles gets styles and not settings. At least, as it is today, that component is not getting the data needed to implement the desired functionality.

EditorStyles renders the CSS variables for the color presets for instance, renders the SVG filters used by Duotone. Isn't it the same for fonts here? We're resolving the fonts that are used in the CSS.

It is possible that I'm misunderstanding the issue that this PR solves though and if it's the case please clarify? I'm assuming that resolving the fonts is done in the frontend somehow right? How is it done today?

I'm not sure how to access global styles settings in a component like EditorStyles without using useGlobalSetting. For this particular implementation I'm interested in getting the 'client side latest version' of settings.typography.fontFamilies.

The styles prop received by the EditorStyles component can contain things like that

const styles = [
	{
		css: `some css`,
	},
	{
                __unstableType: 'svgs'
                assets: `some sig element`
	},
	{
                __unstableType: 'presets'
                assets: `css with CSS variables generated from the presets`
	},
];

This represents all what's necessary for the "styles" to be rendered properly and to work properly.
Would it be out of the question to consider "fonts" at the same level as these and just add

{
              __unstableType: 'fonts'
              fonts: `whatever is needed to resolve the fonts`
},

Again, it's possible that I'm wrong

Copy link
Contributor

Choose a reason for hiding this comment

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

Btw, right now that "styles" prop come from the block editor settings array that is passed from the backend to the frontend during the initialization of the editor. So custom CSS files are resolved there for instance and added to that array of styles

In the site editor, since we have the ability to update global styles, that "styles" setting (the one that comes from backend to server) is also updated on the fly as we make changes to the global styles Here

styles: [ ...nonGlobalStyles, ...styles ],

Copy link
Contributor

Choose a reason for hiding this comment

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

could you show me how is this done? I haven't found how to access the fontFamilies path mentioned before.

In the block-editor store, under settings.__experimentalFeatures.typography.fontFamilies, you should find the data you're chasing in the post editor.

One example of accessing theme.json settings via the block editor store can be found in the layout block support. Another example (that might change soon) is the block style variations block support which prefers the GlobalStyleContext data, if available, falling back to the block editor store's data.

Hope that helps a little 🤞

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the insights.
In this commit I removed the use useGlobalSetting and replaced it by the use data from block-editor store and move the call to the font faces resolved inside the EditorStyles component: c6a32f9


const fontFaces = useMemo( () => {
return Object.values( fontFamilies )
.flat()
.map( ( family ) => family.fontFace )
.filter( Boolean )
.flat();
}, [ fontFamilies ] );

const loadFontFaceAsset = useCallback(
async ( fontFace ) => {
if ( ! fontFace.src ) {
return;
}

const src = getDisplaySrcFromFontFace(
fontFace.src,
currentTheme?.stylesheet_uri
);

if ( ! src || loadedFontUrls.has( src ) ) {
return;
}

loadFontFaceInBrowser( fontFace, src );
setLoadedFontUrls( ( prevUrls ) => new Set( prevUrls ).add( src ) );
},
[ currentTheme, loadedFontUrls ]
);

useEffect( () => {
fontFaces.forEach( loadFontFaceAsset );
}, [ fontFaces, loadFontFaceAsset ] );

return null;
}

export default EditorFontsResolver;
106 changes: 106 additions & 0 deletions packages/block-editor/src/components/editor-fonts-resolver/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Format the font face name to use in the font-family property of a font face.
*
* The input can be a string with the font face name or a string with multiple font face names separated by commas.
* It removes the leading and trailing quotes from the font face name.
*
* @param {string} input - The font face name.
* @return {string} The formatted font face name.
*
* Example:
* formatFontFaceName("Open Sans") => "Open Sans"
* formatFontFaceName("'Open Sans', sans-serif") => "Open Sans"
* formatFontFaceName(", 'Open Sans', 'Helvetica Neue', sans-serif") => "Open Sans"
*/
function formatFontFaceName( input ) {
if ( ! input ) {
return '';
}

let output = input.trim();
if ( output.includes( ',' ) ) {
output = output
.split( ',' )
// Finds the first item that is not an empty string.
.find( ( item ) => item.trim() !== '' )
.trim();
}
// Removes leading and trailing quotes.
output = output.replace( /^["']|["']$/g, '' );

// Firefox needs the font name to be wrapped in double quotes meanwhile other browsers don't.
if ( window.navigator.userAgent.toLowerCase().includes( 'firefox' ) ) {
output = `"${ output }"`;
}
return output;
}

/*
* Loads the font face from a URL and adds it to the browser.
* It also adds it to the iframe document.
*/
export async function loadFontFaceInBrowser( fontFace, source, addTo = 'all' ) {
if ( typeof source !== 'string' ) {
return;
}
const dataSource = `url(${ source })`;
const newFont = new window.FontFace(
formatFontFaceName( fontFace.fontFamily ),
dataSource,
{
style: fontFace.fontStyle,
weight: fontFace.fontWeight,
}
);

const loadedFace = await newFont.load();

if ( addTo === 'document' || addTo === 'all' ) {
document.fonts.add( loadedFace );
}

if ( addTo === 'iframe' || addTo === 'all' ) {
const iframeDocument = document.querySelector(
Copy link
Contributor

Choose a reason for hiding this comment

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

Did we consider moving this logic elsewhere. I feel we should avoid using DOM APIs like that, it's really not clear which "editor-canvas" this is targeting. I can see a world where there are two editors iframes on the same page.

If fonts need to be loaded in the iframe, can we instead move the logic to load fonts into the Iframe component instead?

We can probably use a memoized selector to fetch the fonts from the global styles config we have in the global styles config key of the block editor settings and load the fonts when they change. I believe we may have logic that get close to this for "styles"...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If fonts need to be loaded in the iframe, can we instead move the logic to load fonts into the Iframe component instead?

Fonts should be added in both the main document and the iframe's document. If we move that logic to the iframe document, could we add those to the main one with the same code? Should we repeat the font loading in both places? What happen when the editor is not rendering the iframe? (shouldIframe is false in a few places across the codebase)

Copy link
Contributor

Choose a reason for hiding this comment

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

I think in most places for block themes (the ones that supports fonts), we're using the iframe. There are still some case where we are not though: In the post editor, and only if v<3 blocks are registered or no metaboxes and only in Core (Gutenberg plugin forces iframe for the post editor I think in block themes).

We are working on removing these edge cases but yeah in the meantime we can probably have a reusable hook or something and I think BlockCanvas is where the switching happens between iframe or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this commit I removed this hardcoded references to the document and 'iframe' references. Now it's using a ref to reference the document following the useDarkThemeBodyClassName example: 5a9e2d0

'iframe[name="editor-canvas"]'
).contentDocument;
iframeDocument.fonts.add( loadedFace );
}
}

function isUrlEncoded( url ) {
if ( typeof url !== 'string' ) {
return false;
}
return url !== decodeURIComponent( url );
}

/*
* Retrieves the display source from a font face src.
*
* @param {string|string[]} fontSrc - The font face src.
* @param {string} baseUrl - The base URL to resolve the src.
* @return {string|undefined} The display source or undefined if the input is invalid.
*/
export function getDisplaySrcFromFontFace( fontSrc, baseUrl ) {
if ( ! fontSrc ) {
return;
}

let src;
if ( Array.isArray( fontSrc ) ) {
src = fontSrc[ 0 ];
} else {
src = fontSrc;
}

if ( ! isUrlEncoded( src ) ) {
src = encodeURI( src );
}

// If baseUrl is provided, use it to resolve the src.
if ( src.startsWith( 'file:.' ) ) {
src = baseUrl + '/' + src.replace( 'file:./', '' );
}

return src;
}
Loading