-
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 16 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 |
---|---|---|
|
@@ -831,6 +831,50 @@ | |
return $variations; | ||
} | ||
|
||
/** | ||
* Resolves relative paths in theme.json typography to theme absolute paths | ||
* and returns them in an array that can be embedded | ||
* as the value of `_link` object in REST API responses. | ||
* | ||
* @since 6.6.0 | ||
* | ||
* @param | ||
* @return array An array of resolved paths. | ||
*/ | ||
private static function get_resolved_fonts_theme_uris( $theme_json_data ) { | ||
$resolved_theme_uris = array(); | ||
|
||
if ( !empty( $theme_json_data['settings']['typography']['fontFamilies'] ) ) { | ||
|
||
$font_families = ( $theme_json_data['settings']['typography']['fontFamilies']['theme'] ?? array() ) | ||
+ ( $theme_json_data['settings']['typography']['fontFamilies']['custom'] ?? array() ) | ||
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. Was the union operator See: https://stitcher.io/blog/array-merge-vs+ Unit test coverage would help test that. |
||
+ ( $theme_json_data['settings']['typography']['fontFamilies']['default'] ?? array() ); | ||
|
||
foreach ( $font_families as $font_family ) { | ||
if ( !empty( $font_family['fontFace'] ) ) { | ||
foreach ( $font_family['fontFace'] as $font_face ) { | ||
if ( !empty( $font_face['src'] ) ) { | ||
$sources = is_string( $font_face['src'] ) | ||
? array( $font_face['src'] ) | ||
: $font_face['src']; | ||
foreach ( $sources as $source ) { | ||
if ( str_starts_with( $source, 'file:' ) ) { | ||
$resolved_theme_uris[] = array( | ||
'name' => $source, | ||
'href' => sanitize_url( get_theme_file_uri( str_replace( 'file:./', '', $source ) ) ), | ||
'target' => "typography.fontFamilies.{$font_family['slug']}.fontFace.src", | ||
); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
return $resolved_theme_uris; | ||
} | ||
|
||
|
||
/** | ||
* Resolves relative paths in theme.json styles to theme absolute paths | ||
|
@@ -852,6 +896,12 @@ | |
|
||
$theme_json_data = $theme_json->get_raw_data(); | ||
|
||
// Add font URIs. | ||
$resolved_theme_uris = array_merge( | ||
$resolved_theme_uris, | ||
static::get_resolved_fonts_theme_uris( $theme_json_data ) | ||
); | ||
|
||
// Using the same file convention when registering web fonts. See: WP_Font_Face_Resolver:: to_theme_file_uri. | ||
$placeholder = 'file:./'; | ||
|
||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,71 @@ | ||||||
/** | ||||||
* WordPress dependencies | ||||||
*/ | ||||||
import { useState, useMemo, useCallback } from '@wordpress/element'; | ||||||
import { useSelect } from '@wordpress/data'; | ||||||
|
||||||
/** | ||||||
* Internal dependencies | ||||||
*/ | ||||||
import { getDisplaySrcFromFontFace, loadFontFaceInBrowser } from './utils'; | ||||||
import { store as editorStore } from '../../store'; | ||||||
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. Let's use
Suggested change
|
||||||
|
||||||
function useEditorFontsResolver() { | ||||||
const [ loadedFontUrls, setLoadedFontUrls ] = useState( new Set() ); | ||||||
|
||||||
const { currentTheme = {}, fontFamilies = [] } = useSelect( ( select ) => { | ||||||
return { | ||||||
currentTheme: | ||||||
select( editorStore ).getSettings()?.__experimentalFeatures | ||||||
?.currentTheme, | ||||||
fontFamilies: | ||||||
select( editorStore ).getSettings()?.__experimentalFeatures | ||||||
?.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. Opportunity to call const { __experimentalFeatures } =
select( blockEditorStore ).getSettings();
return {
currentTheme: __experimentalFeatures?.currentTheme,
fontFamilies: __experimentalFeatures?.typography?.fontFamilies,
}; |
||||||
}, [] ); | ||||||
|
||||||
const fontFaces = useMemo( () => { | ||||||
return Object.values( fontFamilies ) | ||||||
.flat() | ||||||
.map( ( family ) => family.fontFace ) | ||||||
.filter( Boolean ) | ||||||
.flat(); | ||||||
}, [ fontFamilies ] ); | ||||||
|
||||||
const loadFontFaceAsset = useCallback( | ||||||
async ( fontFace, ownerDocument ) => { | ||||||
if ( ! fontFace.src ) { | ||||||
return; | ||||||
} | ||||||
|
||||||
const src = getDisplaySrcFromFontFace( | ||||||
fontFace.src, | ||||||
currentTheme?.stylesheet_uri | ||||||
); | ||||||
|
||||||
if ( ! src || loadedFontUrls.has( src ) ) { | ||||||
return; | ||||||
} | ||||||
|
||||||
loadFontFaceInBrowser( fontFace, src, ownerDocument ); | ||||||
setLoadedFontUrls( ( prevUrls ) => new Set( prevUrls ).add( src ) ); | ||||||
}, | ||||||
[ currentTheme, loadedFontUrls ] | ||||||
); | ||||||
|
||||||
return useCallback( | ||||||
( node ) => { | ||||||
if ( ! node ) { | ||||||
return; | ||||||
} | ||||||
|
||||||
const { ownerDocument } = node; | ||||||
fontFaces.forEach( ( fontFace ) => | ||||||
loadFontFaceAsset( fontFace, ownerDocument ) | ||||||
); | ||||||
}, | ||||||
[ fontFaces, loadFontFaceAsset ] | ||||||
); | ||||||
} | ||||||
|
||||||
export default useEditorFontsResolver; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
/* | ||
* 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(); | ||
} | ||
Comment on lines
+21
to
+27
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. Can we describe why we need to do this? I can see what but at a casual glance I'm not sure as to why. |
||
// 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 }"`; | ||
} | ||
Comment on lines
+31
to
+34
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. What happens if we just let all browsers have the wrapped version? |
||
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, documentRef ) { | ||
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(); | ||
|
||
// Add the font to the ref document. | ||
documentRef.fonts.add( loadedFace ); | ||
|
||
// Add the font to the window document. | ||
if ( documentRef !== window.document ) { | ||
window.document.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.
I think this could be part of
get_resolved_theme_uris
- it's not that much code, and it could reuse$placeholder
etc.Example diff