-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
CustomGradientPicker: Support CSS variables #63915
base: trunk
Are you sure you want to change the base?
Changes from all commits
37f3316
936e45b
fb93be7
5020599
7522d57
bb415c0
1c13a35
016d6da
e790b7d
ecca96e
82731bc
ae5cb30
1a6a4e8
fb65c17
61ed853
011a760
872b0b3
3709b37
e839ab6
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,201 @@ | ||
/** | ||
* WordPress dependencies | ||
*/ | ||
import { useEffect, useRef } from '@wordpress/element'; | ||
|
||
/** | ||
* Internal dependencies | ||
*/ | ||
import { VisuallyHidden } from '../visually-hidden'; | ||
|
||
type CSSVariableReplacerProps = { | ||
cssString?: string | null; | ||
/** | ||
* Callback to be called when CSS variables in the given string | ||
* have been replaced with their computed values. | ||
* | ||
* Should be memoized to avoid unnecessary reflows. | ||
*/ | ||
onChange: ( args: { | ||
replacedCssString: string; | ||
computedVariables: ComputedCSSVariables; | ||
} ) => void; | ||
Comment on lines
+19
to
+22
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. For completeness, it would probably be a good idea to also pass back the original string |
||
}; | ||
type ComputedCSSVariables = Record< string, string >; | ||
type VarFunction = { | ||
/** Start index of match. */ | ||
start: number; | ||
/** End index of match. */ | ||
end: number; | ||
/** Matched string, e.g. `var( --foo, #000 )`. */ | ||
raw: string; | ||
/** CSS variable name, e.g. `--foo`. */ | ||
value: string; | ||
/** Second argument of the `var()`, which could be a literal or another `var()`. */ | ||
fallback?: string; | ||
}; | ||
|
||
/** | ||
* Find the index of the matching closing parenthesis for a given opening parenthesis in a string. | ||
*/ | ||
function findMatchingParenthesis( str: string, startPos: number ) { | ||
let stack = 0; | ||
|
||
for ( let i = startPos; i < str.length; i++ ) { | ||
if ( str[ i ] === '(' ) { | ||
stack++; | ||
} else if ( str[ i ] === ')' ) { | ||
stack--; | ||
if ( stack === 0 ) { | ||
return i; | ||
} | ||
} | ||
} | ||
|
||
throw new Error( 'No matching closing parenthesis found.' ); | ||
} | ||
|
||
/** | ||
* Find all `var()` functions in a CSS string. | ||
*/ | ||
export function findVarFunctionsInString( str: string ) { | ||
const regex = /(?<=\bvar)\(/g; | ||
const matches: VarFunction[] = []; | ||
|
||
let openingParen; | ||
while ( ( openingParen = regex.exec( str ) ) !== null ) { | ||
const closingParen = findMatchingParenthesis( str, openingParen.index ); | ||
const [ start, end ] = [ | ||
openingParen.index - 'var'.length, | ||
closingParen + 1, | ||
]; | ||
const raw = str.slice( start, end ); | ||
const value = raw.match( /--[\w-]+/ )?.[ 0 ]; | ||
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. Should this regex also be declared above so we don't redeclare it every time? |
||
|
||
if ( ! value ) { | ||
throw new Error( 'No CSS variable found in var() function.' ); | ||
} | ||
|
||
matches.push( { | ||
start, | ||
end, | ||
raw, | ||
value, | ||
fallback: raw.match( /,(.+)\)/ )?.[ 1 ].trim(), | ||
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. Same about this regex and the rest of the regexes in here. In addition to the small performance benefit, it can actually make the code more readable if regexes are declared as constants and referenced by name. |
||
} ); | ||
|
||
regex.lastIndex = closingParen + 1; // resume next regex search after this closing parenthesis | ||
} | ||
|
||
return matches; | ||
} | ||
|
||
/** | ||
* Get the computed CSS variables for a given element. | ||
*/ | ||
function getComputedCSSVariables( | ||
propertyStrings: string[], | ||
element: HTMLDivElement | ||
) { | ||
return propertyStrings.reduce( ( acc, propertyString ) => { | ||
acc[ propertyString ] = window | ||
.getComputedStyle( element ) | ||
.getPropertyValue( propertyString ); | ||
return acc; | ||
}, {} as ComputedCSSVariables ); | ||
} | ||
|
||
/** | ||
* Replace all `var()` functions in a CSS string with their computed values. | ||
*/ | ||
export function replaceCSSVariablesInString( | ||
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. Have you considered introducing a caching layer here? There seem to be a bunch of regex operations here, and with large CSS strings, caching could potentially have a real, measurable impact. Or is the effect dependency management of |
||
str: string, | ||
computedVariables: ComputedCSSVariables | ||
): string { | ||
const varFunctions = findVarFunctionsInString( str ); | ||
|
||
let result = ''; | ||
let lastIndex = 0; | ||
|
||
varFunctions.forEach( ( { start, end, value, fallback } ) => { | ||
const replacement = | ||
computedVariables[ value ] || | ||
replaceCSSVariablesInString( fallback ?? '', computedVariables ); | ||
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. If |
||
|
||
if ( ! replacement ) { | ||
throw new Error( `No value found for CSS variable ${ value }.` ); | ||
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. If this function is part of the public API, throwing an error can be a bit invasive. I see we're throwing it here to catch it in the |
||
} | ||
|
||
result += str.slice( lastIndex, start ) + replacement; | ||
lastIndex = end; | ||
} ); | ||
|
||
result += str.slice( lastIndex ); | ||
|
||
return result; | ||
} | ||
|
||
/** | ||
* Find all CSS variable names (e.g. `--foo`) in a string. | ||
*/ | ||
export function getCSSVariablesInString( str: string ) { | ||
return str.match( /(?<=\bvar\(\s*)--[\w-]+/g ) ?? []; | ||
} | ||
|
||
/** | ||
* A component that replaces CSS variables in a given CSS string with their computed values. | ||
* The result is passed to the `onChange` callback. | ||
* | ||
* ```jsx | ||
* function MyComponent() { | ||
* const onChange = useCallback( | ||
* ( { replacedCssString } ) => console.log( replacedCssString ), | ||
* [] | ||
* ); | ||
* return ( | ||
* <CSSVariableReplacer | ||
* cssString="var(--text-color, red)" | ||
* onChange={ onChange } | ||
* /> | ||
* ); | ||
* } | ||
* ``` | ||
*/ | ||
export function CSSVariableReplacer( { | ||
cssString, | ||
onChange, | ||
}: CSSVariableReplacerProps ) { | ||
const ref = useRef< HTMLDivElement >( null ); | ||
|
||
useEffect( () => { | ||
if ( cssString && ref.current ) { | ||
const computedVariables = getComputedCSSVariables( | ||
getCSSVariablesInString( cssString ), | ||
ref.current | ||
); | ||
|
||
let replacedCssString = cssString; | ||
|
||
try { | ||
replacedCssString = replaceCSSVariablesInString( | ||
cssString, | ||
computedVariables | ||
); | ||
} catch ( error ) { | ||
// eslint-disable-next-line no-console | ||
console.warn( | ||
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 use the |
||
'wp.components.CSSVariableReplacer failed to parse the CSS string with error', | ||
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. Should we add the CSS string in the error message? |
||
error | ||
); | ||
} | ||
|
||
onChange( { replacedCssString, computedVariables } ); | ||
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 case of an error, should we still fire |
||
} | ||
}, [ cssString, onChange ] ); | ||
|
||
return ( | ||
<VisuallyHidden> | ||
<div ref={ ref } /> | ||
</VisuallyHidden> | ||
Comment on lines
+197
to
+199
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. Nitty, but do we need the extra |
||
); | ||
} |
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.
If I understand correctly, this fallback gradient will only be shown for a very small time, until the
onChange
callback of theCSSVariableReplacer
component fires with the parsed gradient.Therefore, there shouldn't be any changes to how the gradient falls back to the DEFAULT_GRADIENT in the
getGradientAstWithDefault
function.