diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 456a2f986d8985..adb2910f1a1120 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -45,6 +45,7 @@ - `Modal`: Keep an accessible name on the dialog when `title` is combined with `__experimentalHideHeader`. The heading that `aria-labelledby` referenced is not rendered in that case, so the title is now applied as the dialog's `aria-label` instead ([#82645](https://github.com/WordPress/gutenberg/pull/82645)). - `ColorPicker`: Make the HSL and RGB input labels translatable, and name the Hue and Alpha inputs after their color format ([#70214](https://github.com/WordPress/gutenberg/pull/70214)). - `CustomGradientPicker`: Use the bundled `gradient-parser` types, preserve gradient angles when editing, and keep unsupported gradient syntax on the existing fallback path ([#84582](https://github.com/WordPress/gutenberg/pull/84582)). +- `CustomGradientPicker`: Keep the color interpolation method of a gradient, such as `in oklch`, when editing it, instead of replacing the gradient with the default one ([#84383](https://github.com/WordPress/gutenberg/pull/84383)). - `BaseControl`: Remove the unsupported `as` prop from the component types and documentation ([#70064](https://github.com/WordPress/gutenberg/pull/70064)). ### Internal diff --git a/packages/components/src/custom-gradient-picker/constants.ts b/packages/components/src/custom-gradient-picker/constants.ts index 19d28a0bd734bc..9c6997e058d4f3 100644 --- a/packages/components/src/custom-gradient-picker/constants.ts +++ b/packages/components/src/custom-gradient-picker/constants.ts @@ -30,3 +30,35 @@ export const DIRECTIONAL_ORIENTATION_ANGLE_MAP = { 'top left': 315, 'left top': 315, }; + +// The color spaces and hue interpolation methods that a CSS +// `` accepts, e.g. `in oklch longer hue`. +// See https://www.w3.org/TR/css-color-4/#interpolation-space. +export const RECTANGULAR_INTERPOLATION_COLOR_SPACES = [ + 'srgb', + 'srgb-linear', + 'display-p3', + 'display-p3-linear', + 'a98-rgb', + 'prophoto-rgb', + 'rec2020', + 'lab', + 'oklab', + 'xyz', + 'xyz-d50', + 'xyz-d65', +]; + +export const POLAR_INTERPOLATION_COLOR_SPACES = [ + 'hsl', + 'hwb', + 'lch', + 'oklch', +]; + +export const HUE_INTERPOLATION_METHODS = [ + 'shorter', + 'longer', + 'increasing', + 'decreasing', +]; diff --git a/packages/components/src/custom-gradient-picker/serializer.ts b/packages/components/src/custom-gradient-picker/serializer.ts index 255727556eb5ef..a1551da534a3b4 100644 --- a/packages/components/src/custom-gradient-picker/serializer.ts +++ b/packages/components/src/custom-gradient-picker/serializer.ts @@ -1,4 +1,5 @@ import type gradientParser from 'gradient-parser'; +import type { GradientAST } from './types'; export function serializeGradientColor( { type, @@ -63,10 +64,18 @@ export function serializeGradientOrientation( export function serializeGradient( { type, + colorInterpolation, orientation, colorStops, -}: gradientParser.GradientNode ) { - const serializedOrientation = serializeGradientOrientation( orientation ); +}: GradientAST ) { + // The color interpolation method shares the first argument with the + // orientation, e.g. `linear-gradient(in oklch 160deg, …)`. + const serializedOrientation = [ + colorInterpolation, + serializeGradientOrientation( orientation ), + ] + .filter( Boolean ) + .join( ' ' ); const serializedColorStops = colorStops .sort( ( colorStop1, colorStop2 ) => { const getNumericStopValue = ( diff --git a/packages/components/src/custom-gradient-picker/test/index.browser.test.tsx b/packages/components/src/custom-gradient-picker/test/index.browser.test.tsx index 240748ad8987e4..e820033063e81f 100644 --- a/packages/components/src/custom-gradient-picker/test/index.browser.test.tsx +++ b/packages/components/src/custom-gradient-picker/test/index.browser.test.tsx @@ -163,6 +163,109 @@ describe( 'CustomGradientPicker', () => { expect( lastCall ).not.toContain( 'deg' ); } ); } ); + + describe( 'with a color interpolation method', () => { + // The gradient from the issue. It has more color stops than the default + // gradient the picker falls back to, so the assertions below can only + // pass when this is the gradient being edited. + const INTERPOLATED_GRADIENT = + 'linear-gradient(in oklch 160deg,#009ED4 0%,#003366 45%,#452955 65%,#F08119 95%,#FFCE00 100%)'; + + it( 'shows the color stops and angle of a gradient that has an interpolation method', async () => { + await render( + + ); + + expect( + screen + .getAllByRole( 'button', { + name: /Gradient control point/, + } ) + .map( ( point ) => point.getAttribute( 'aria-label' ) ) + ).toEqual( [ + 'Gradient control point at position 0% with color code #009ED4.', + 'Gradient control point at position 45% with color code #003366.', + 'Gradient control point at position 65% with color code #452955.', + 'Gradient control point at position 95% with color code #F08119.', + 'Gradient control point at position 100% with color code #FFCE00.', + ] ); + expect( + screen.getByRole( 'spinbutton', { name: 'Angle' } ) + ).toHaveValue( 160 ); + } ); + + it( 'keeps the interpolation method when the angle changes', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + await render( + + ); + + screen.getByRole( 'spinbutton', { name: 'Angle' } ).focus(); + await user.keyboard( '[ArrowUp]' ); + + const lastCall = + onChange.mock.calls[ onChange.mock.calls.length - 1 ][ 0 ]; + expect( lastCall ).toBe( + 'linear-gradient(in oklch 161deg,#009ED4 0%,#003366 45%,#452955 65%,#F08119 95%,#FFCE00 100%)' + ); + } ); + + it( 'keeps the interpolation method when switching to a radial gradient', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + await render( + + ); + + await user.selectOptions( + screen.getByRole( 'combobox', { name: /type/i } ), + 'radial-gradient' + ); + + const lastCall = + onChange.mock.calls[ onChange.mock.calls.length - 1 ][ 0 ]; + expect( lastCall ).toBe( + 'radial-gradient(in oklch,#009ED4 0%,#003366 45%,#452955 65%,#F08119 95%,#FFCE00 100%)' + ); + } ); + + it( 'keeps the interpolation method when a color stop moves', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + await render( + + ); + + const [ firstPoint ] = screen.getAllByRole( 'button', { + name: /Gradient control point/, + } ); + firstPoint.focus(); + await user.keyboard( '[ArrowRight]' ); + + // Moving a color stop rewrites every color as `rgb()`. + const lastCall = + onChange.mock.calls[ onChange.mock.calls.length - 1 ][ 0 ]; + expect( lastCall ).toBe( + `linear-gradient(in oklch 160deg,rgb(0,158,212) ${ KEYBOARD_CONTROL_POINT_VARIATION }%,rgb(0,51,102) 45%,rgb(69,41,85) 65%,rgb(240,129,25) 95%,rgb(255,206,0) 100%)` + ); + } ); + } ); } ); describe( 'CustomGradientBar', () => { diff --git a/packages/components/src/custom-gradient-picker/test/serializer.ts b/packages/components/src/custom-gradient-picker/test/serializer.ts index ef578192dea67f..9bf615c002afb2 100644 --- a/packages/components/src/custom-gradient-picker/test/serializer.ts +++ b/packages/components/src/custom-gradient-picker/test/serializer.ts @@ -204,4 +204,36 @@ describe( 'It should serialize a gradient', () => { 'linear-gradient(0deg,rgba(1,2,3,0.5) 0%,rgba(10,20,30,0.5) 20%,rgba(255,1,1,0.9) 40%,rgba(1,2,3,0.5) 100%)' ); } ); + + test( 'serializeGradient writes the interpolation method before the angle', () => { + const colorStops = [ + { + type: 'hex' as const, + value: '000', + length: { type: '%' as const, value: '0' }, + }, + { + type: 'hex' as const, + value: 'fff', + length: { type: '%' as const, value: '100' }, + }, + ]; + + expect( + serializeGradient( { + type: 'linear-gradient', + colorInterpolation: 'in oklch', + orientation: { type: 'angular', value: '160', unit: 'deg' }, + colorStops, + } ) + ).toBe( 'linear-gradient(in oklch 160deg,#000 0%,#fff 100%)' ); + + expect( + serializeGradient( { + type: 'radial-gradient', + colorInterpolation: 'in oklab', + colorStops, + } ) + ).toBe( 'radial-gradient(in oklab,#000 0%,#fff 100%)' ); + } ); } ); diff --git a/packages/components/src/custom-gradient-picker/test/utils.ts b/packages/components/src/custom-gradient-picker/test/utils.ts index 03a7a87ed6bcb1..8a017a6497d686 100644 --- a/packages/components/src/custom-gradient-picker/test/utils.ts +++ b/packages/components/src/custom-gradient-picker/test/utils.ts @@ -68,3 +68,128 @@ describe( 'CustomGradientPicker gradient parsing', () => { expect( result.gradientAST ).toEqual( fallback.gradientAST ); } ); } ); + +describe( 'getGradientAstWithDefault', () => { + describe( 'with a color interpolation method', () => { + test( 'reads an interpolation method placed before the angle', () => { + const { gradientAST, hasGradient } = getGradientAstWithDefault( + 'linear-gradient(in oklch 160deg, #009ED4 0%, #FFCE00 100%)' + ); + + expect( hasGradient ).toBe( true ); + expect( gradientAST.colorInterpolation ).toBe( 'in oklch' ); + expect( gradientAST.orientation ).toEqual( { + type: 'angular', + value: '160', + unit: 'deg', + } ); + expect( serializeGradient( gradientAST ) ).toBe( + 'linear-gradient(in oklch 160deg,#009ED4 0%,#FFCE00 100%)' + ); + } ); + + test( 'reads an interpolation method placed after the direction', () => { + const { gradientAST, hasGradient } = getGradientAstWithDefault( + 'linear-gradient(to right in oklab, #009ED4 0%, #FFCE00 100%)' + ); + + expect( hasGradient ).toBe( true ); + expect( gradientAST.colorInterpolation ).toBe( 'in oklab' ); + expect( gradientAST.orientation ).toEqual( { + type: 'angular', + value: '90', + unit: 'deg', + } ); + } ); + + test( 'keeps the hue interpolation method of a polar color space', () => { + const { gradientAST, hasGradient } = getGradientAstWithDefault( + 'linear-gradient(in oklch longer hue 160deg, #009ED4 0%, #FFCE00 100%)' + ); + + expect( hasGradient ).toBe( true ); + expect( gradientAST.colorInterpolation ).toBe( + 'in oklch longer hue' + ); + } ); + + // The keywords below are copied from the CSS Color 4 grammar for + // ``, `` and + // ``, so that a keyword missing from the + // picker's own lists is caught. + // See https://www.w3.org/TR/css-color-4/#interpolation-space. + test.each( [ + 'srgb', + 'srgb-linear', + 'display-p3', + 'display-p3-linear', + 'a98-rgb', + 'prophoto-rgb', + 'rec2020', + 'lab', + 'oklab', + 'xyz', + 'xyz-d50', + 'xyz-d65', + 'hsl', + 'hwb', + 'lch', + 'oklch', + ] )( 'reads the %s color space', ( colorSpace ) => { + const { gradientAST, hasGradient } = getGradientAstWithDefault( + `linear-gradient(in ${ colorSpace } 160deg, #009ED4 0%, #FFCE00 100%)` + ); + + expect( hasGradient ).toBe( true ); + expect( gradientAST.colorInterpolation ).toBe( + `in ${ colorSpace }` + ); + } ); + + test.each( [ 'shorter', 'longer', 'increasing', 'decreasing' ] )( + 'reads the %s hue interpolation method', + ( method ) => { + const { gradientAST, hasGradient } = getGradientAstWithDefault( + `linear-gradient(in oklch ${ method } hue 160deg, #009ED4 0%, #FFCE00 100%)` + ); + + expect( hasGradient ).toBe( true ); + expect( gradientAST.colorInterpolation ).toBe( + `in oklch ${ method } hue` + ); + } + ); + + test( 'falls back to the default gradient for an unknown color space', () => { + const { gradientAST, hasGradient } = getGradientAstWithDefault( + 'linear-gradient(in not-a-space 160deg, #009ED4 0%, #FFCE00 100%)' + ); + + expect( console ).toHaveWarned(); + expect( hasGradient ).toBe( false ); + expect( gradientAST.colorInterpolation ).toBeUndefined(); + } ); + + test( 'reads an interpolation method that is the whole first argument', () => { + const { gradientAST, hasGradient } = getGradientAstWithDefault( + 'linear-gradient(in oklch, #009ED4 0%, #FFCE00 100%)' + ); + + expect( hasGradient ).toBe( true ); + expect( gradientAST.colorInterpolation ).toBe( 'in oklch' ); + expect( serializeGradient( gradientAST ) ).toBe( + 'linear-gradient(in oklch,#009ED4 0%,#FFCE00 100%)' + ); + } ); + + test( 'falls back to the default gradient for a hue interpolation method on a non-hue color space', () => { + const { gradientAST, hasGradient } = getGradientAstWithDefault( + 'linear-gradient(in oklab longer hue 160deg, #009ED4 0%, #FFCE00 100%)' + ); + + expect( console ).toHaveWarned(); + expect( hasGradient ).toBe( false ); + expect( gradientAST.colorInterpolation ).toBeUndefined(); + } ); + } ); +} ); diff --git a/packages/components/src/custom-gradient-picker/types.ts b/packages/components/src/custom-gradient-picker/types.ts index 03add2a860b36d..149158c26bf505 100644 --- a/packages/components/src/custom-gradient-picker/types.ts +++ b/packages/components/src/custom-gradient-picker/types.ts @@ -37,14 +37,22 @@ export type CustomGradientPickerProps = { __experimentalIsRenderedInSidebar?: boolean; }; +/** + * `gradient-parser` can't read the color interpolation method of a gradient + * (e.g. `in oklch`), so the picker keeps it next to the parsed AST. + */ +type WithColorInterpolation = { colorInterpolation?: string }; + +export type GradientAST = gradientParser.GradientNode & WithColorInterpolation; + export type GradientAnglePickerProps = { - gradientAST: gradientParser.LinearGradientNode; + gradientAST: gradientParser.LinearGradientNode & WithColorInterpolation; hasGradient: boolean; onChange: ( gradient: string ) => void; }; export type GradientTypePickerProps = { - gradientAST: gradientParser.GradientNode; + gradientAST: GradientAST; hasGradient: boolean; onChange: ( gradient: string ) => void; }; diff --git a/packages/components/src/custom-gradient-picker/utils.ts b/packages/components/src/custom-gradient-picker/utils.ts index de301cac24afe2..b9d3ab4e18d0c0 100644 --- a/packages/components/src/custom-gradient-picker/utils.ts +++ b/packages/components/src/custom-gradient-picker/utils.ts @@ -5,9 +5,12 @@ import { DEFAULT_GRADIENT, HORIZONTAL_GRADIENT_ORIENTATION, DIRECTIONAL_ORIENTATION_ANGLE_MAP, + HUE_INTERPOLATION_METHODS, + POLAR_INTERPOLATION_COLOR_SPACES, + RECTANGULAR_INTERPOLATION_COLOR_SPACES, } from './constants'; import { serializeGradient } from './serializer'; -import type { ControlPoint } from './types'; +import type { ControlPoint, GradientAST } from './types'; extend( [ namesPlugin ] ); @@ -32,16 +35,122 @@ function hasUnsupportedLength( item: gradientParser.ColorStop ) { return item.length === undefined || item.length.type !== '%'; } +/** + * Reads the first argument of a gradient, the one that holds the orientation + * and the color interpolation method, as a list of space separated tokens. + * + * @param gradient CSS gradient. + * @param start Index right after the opening parenthesis. + */ +function readFirstArgument( gradient: string, start: number ) { + const tokens: string[] = []; + let token = ''; + let depth = 0; + + for ( let index = start; index < gradient.length; index++ ) { + const char = gradient[ index ]; + + if ( depth === 0 && ( char === ',' || char === ')' ) ) { + if ( token ) { + tokens.push( token ); + } + return { tokens, end: index }; + } + + if ( char === '(' ) { + depth++; + } else if ( char === ')' ) { + depth--; + } + + if ( depth === 0 && /\s/.test( char ) ) { + if ( token ) { + tokens.push( token ); + } + token = ''; + } else { + token += char; + } + } + + // The gradient is missing its closing parenthesis. + return undefined; +} + +/** + * `gradient-parser` can't read a color interpolation method such as + * `in oklch`, so it is taken out of the gradient before it is parsed. + * + * Only the first argument is searched, at its top level, so a color stop like + * `color-mix( in srgb, … )` is never mistaken for one. + * + * This is a workaround until `gradient-parser` supports color interpolation + * methods. It can be removed once an upstream release does: + * https://github.com/rafaelcaricio/gradient-parser/issues/57 + * + * @param gradient CSS gradient. + */ +function extractColorInterpolation( gradient: string ) { + const start = gradient.indexOf( '(' ) + 1; + const firstArgument = start && readFirstArgument( gradient, start ); + if ( ! firstArgument ) { + return { gradient }; + } + + const { tokens, end } = firstArgument; + const words = tokens.map( ( token ) => token.toLowerCase() ); + const index = words.indexOf( 'in' ); + if ( index === -1 ) { + return { gradient }; + } + + const colorSpace = words[ index + 1 ]; + let length; + if ( + POLAR_INTERPOLATION_COLOR_SPACES.includes( colorSpace ) && + HUE_INTERPOLATION_METHODS.includes( words[ index + 2 ] ) && + words[ index + 3 ] === 'hue' + ) { + length = 4; + } else if ( + RECTANGULAR_INTERPOLATION_COLOR_SPACES.includes( colorSpace ) || + POLAR_INTERPOLATION_COLOR_SPACES.includes( colorSpace ) + ) { + length = 2; + } else { + return { gradient }; + } + + const remainder = [ + ...tokens.slice( 0, index ), + ...tokens.slice( index + length ), + ].join( ' ' ); + // Without an orientation, the comma after the first argument goes too. + const hasEmptyFirstArgument = ! remainder && gradient[ end ] === ','; + + return { + gradient: + gradient.slice( 0, start ) + + remainder + + gradient.slice( hasEmptyFirstArgument ? end + 1 : end ), + colorInterpolation: words.slice( index, index + length ).join( ' ' ), + }; +} + export function getGradientAstWithDefault( value?: string | null ) { // gradientAST will contain the gradient AST as parsed by gradient-parser npm module. // More information of its structure available at https://www.npmjs.com/package/gradient-parser#ast. - let gradientAST: gradientParser.GradientNode | undefined; + let gradientAST: GradientAST | undefined; let hasGradient = !! value; - const valueToParse = value ?? DEFAULT_GRADIENT; + const { gradient: valueToParse, colorInterpolation } = + extractColorInterpolation( value ?? DEFAULT_GRADIENT ); try { gradientAST = gradientParser.parse( valueToParse )[ 0 ]; + if ( colorInterpolation ) { + gradientAST.colorInterpolation = colorInterpolation; + } } catch ( error ) { // eslint-disable-next-line no-console console.warn( @@ -103,7 +212,7 @@ export function getGradientAstWithDefault( value?: string | null ) { } export function getGradientAstWithControlPoints( - gradientAST: gradientParser.GradientNode, + gradientAST: GradientAST, newControlPoints: ControlPoint[] ) { return { @@ -122,7 +231,7 @@ export function getGradientAstWithControlPoints( : [ `${ r }`, `${ g }`, `${ b }` ], }; } ), - } as gradientParser.GradientNode; + } as GradientAST; } export function getStopCssColor( colorStop: gradientParser.ColorStop ) {