-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
base: trunk
Are you sure you want to change the base?
Changes from 6 commits
6219a6d
1e8ef55
65f3754
77f7638
0bed01d
48bfe65
65d147a
c6a32f9
5a9e2d0
27b5e64
463fa95
9bd3378
5cecd18
c262896
8ced871
ee98b6e
1461989
8172039
7cb4c01
20a00e3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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' ); | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 @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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the ping 👍
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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
@aaronrobertshaw could you show me how is this done? I haven't found how to access the fontFamilies path mentioned before.
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.
@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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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?
The styles prop received by the 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.
Again, it's possible that I'm wrong There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the 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 🤞 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the insights. |
||||
|
||||
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; |
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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"... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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? ( There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
'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; | ||
} |
There was a problem hiding this comment.
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?