From accbe3d09cbdb12250dbf46cd8ace03adfbcf06c Mon Sep 17 00:00:00 2001 From: Liad Yosef Date: Wed, 11 Dec 2024 15:41:08 +0200 Subject: [PATCH] feat(editor): add responsive utils (#6716) This prep PR adds the responsive utilities we'll use in our responsive PR, along with types and tests. The important utilities (that are exposed outside) are: 1. `extractScreenSizeFromCss(css: string)` - which recieves a CSS string of the media query (i.e `@media (min-width: 100px)`) and turns it into a `ScreenSize` object (in our example - `{ min: {value: 100, unit: 'px'} }`). We also support media ranges (`@media (20px < width < 50em)`). This function is covered in tests. It uses `mediaQueryToScreenSize(mediaQuery: MediaQuery)` internally, which takes a `MediaQuery` object (after it was parsed from the CSS string), and does the "heavy lifting" of converting it to a `ScreenSize` representation. This inner function is also covered in tests. 2. `selectValueByBreakpoint` - this function recieves a list of possible variants (values with corresponding `ScreenSize`s which they apply in), and the current Scene size. It infers the most matching variant according to the Scene size (can also be the default variant if none are matching). This function is also fully covered in tests. No functionality is added to the app itself, this is just a prep PR for the functionality in the subsequent PR. **Manual Tests:** I hereby swear that: - [X] I opened a hydrogen project and it loaded - [X] I could navigate to various routes in Play mode --- editor/src/components/canvas/canvas-types.ts | 8 + .../src/components/canvas/responsive-types.ts | 39 ++++ .../canvas/responsive-utils.spec.ts | 176 +++++++++++++++ .../src/components/canvas/responsive-utils.ts | 210 ++++++++++++++++++ 4 files changed, 433 insertions(+) create mode 100644 editor/src/components/canvas/responsive-types.ts create mode 100644 editor/src/components/canvas/responsive-utils.spec.ts create mode 100644 editor/src/components/canvas/responsive-utils.ts diff --git a/editor/src/components/canvas/canvas-types.ts b/editor/src/components/canvas/canvas-types.ts index 06f6f0f5220e..787c04380f36 100644 --- a/editor/src/components/canvas/canvas-types.ts +++ b/editor/src/components/canvas/canvas-types.ts @@ -30,6 +30,7 @@ import type { CSSPadding, FlexDirection, } from '../inspector/common/css-utils' +import type { ScreenSize } from './responsive-types' export const CanvasContainerID = 'canvas-container' export const SceneContainerName = 'scene' @@ -557,6 +558,13 @@ interface ParsedCSSStyleProperty { value: T } +type StyleHoverModifier = { type: 'hover' } +export type StyleMediaSizeModifier = { + type: 'media-size' + size: ScreenSize +} +export type StyleModifier = StyleHoverModifier | StyleMediaSizeModifier + export type CSSStyleProperty = | CSSStylePropertyNotFound | CSSStylePropertyNotParsable diff --git a/editor/src/components/canvas/responsive-types.ts b/editor/src/components/canvas/responsive-types.ts new file mode 100644 index 000000000000..c1e025a6f8fc --- /dev/null +++ b/editor/src/components/canvas/responsive-types.ts @@ -0,0 +1,39 @@ +import type { Identifier, Dimension } from 'css-tree' +import type { CSSNumber } from '../inspector/common/css-utils' +// @media (min-width: 100px) and (max-width: 200em) => { min: { value: 100, unit: 'px' }, max: { value: 200, unit: 'em' } } +export type ScreenSize = { + min?: CSSNumber + max?: CSSNumber +} + +export interface MediaQuery { + type: 'MediaQuery' + loc: null + modifier: null + mediaType: null + condition?: { + type: 'Condition' + loc: null + kind: 'media' + children: Array + } +} + +export interface FeatureRange { + type: 'FeatureRange' + loc: null + kind: 'media' + left?: Dimension | Identifier + leftComparison: '<' | '>' + middle: Dimension | Identifier + rightComparison: '<' | '>' + right?: Dimension | Identifier +} + +export interface Feature { + type: 'Feature' + loc: null + kind: 'media' + name: 'min-width' | 'max-width' + value?: Dimension +} diff --git a/editor/src/components/canvas/responsive-utils.spec.ts b/editor/src/components/canvas/responsive-utils.spec.ts new file mode 100644 index 000000000000..dcbcc0c434c1 --- /dev/null +++ b/editor/src/components/canvas/responsive-utils.spec.ts @@ -0,0 +1,176 @@ +import * as csstree from 'css-tree' +import { mediaQueryToScreenSize, selectValueByBreakpoint } from './responsive-utils' +import type { ScreenSize, MediaQuery } from './responsive-types' +import { extractScreenSizeFromCss } from './responsive-utils' +import type { StyleModifier } from './canvas-types' + +describe('extractScreenSizeFromCss', () => { + it('extracts screen size from simple media query', () => { + const css = '@media (min-width: 100px) and (max-width: 500px)' + const result = extractScreenSizeFromCss(css) + expect(result).toEqual({ + min: { value: 100, unit: 'px' }, + max: { value: 500, unit: 'px' }, + }) + }) + + it('returns null for invalid media query', () => { + const css = 'not-a-media-query' + const result = extractScreenSizeFromCss(css) + expect(result).toBeNull() + }) + + it('uses cache for repeated calls with same CSS', () => { + const css = '@media (min-width: 100px)' + + // First call + const result1 = extractScreenSizeFromCss(css) + // Second call - should return same object reference + const result2 = extractScreenSizeFromCss(css) + + expect(result1).toBe(result2) // Use toBe for reference equality + expect(result1).toEqual({ + min: { value: 100, unit: 'px' }, + }) + }) + + it('handles different CSS strings independently in cache', () => { + const css1 = '@media (min-width: 100px)' + const css2 = '@media (max-width: 500px)' + + // First string + const result1a = extractScreenSizeFromCss(css1) + const result1b = extractScreenSizeFromCss(css1) + expect(result1a).toBe(result1b) + expect(result1a).toEqual({ + min: { value: 100, unit: 'px' }, + }) + + // Second string + const result2a = extractScreenSizeFromCss(css2) + const result2b = extractScreenSizeFromCss(css2) + expect(result2a).toBe(result2b) + expect(result2a).toEqual({ + max: { value: 500, unit: 'px' }, + }) + + // Different strings should have different references + expect(result1a).not.toBe(result2a) + }) +}) + +describe('selectValueByBreakpoint', () => { + const variants: { value: string; modifiers?: StyleModifier[] }[] = [ + { + value: 'Desktop Value', + modifiers: [{ type: 'media-size', size: { min: { value: 200, unit: 'px' } } }], + }, + { + value: 'Tablet Value', + modifiers: [{ type: 'media-size', size: { min: { value: 100, unit: 'px' } } }], + }, + { + value: 'Extra Large Value', + modifiers: [{ type: 'media-size', size: { min: { value: 20, unit: 'em' } } }], + }, + { + value: 'Ranged Value', + modifiers: [ + { + type: 'media-size', + size: { min: { value: 80, unit: 'px' }, max: { value: 90, unit: 'px' } }, + }, + ], + }, + { + value: 'Mobile Value', + modifiers: [{ type: 'media-size', size: { min: { value: 60, unit: 'px' } } }], + }, + { value: 'Default Value' }, + ] + const tests: { title: string; screenSize: number; expected: string }[] = [ + { title: 'selects the correct value', screenSize: 150, expected: 'Tablet Value' }, + { title: 'select the closest value', screenSize: 250, expected: 'Desktop Value' }, + { title: 'converts em to px', screenSize: 350, expected: 'Extra Large Value' }, + { + title: 'selects the default value if no breakpoint is matched', + screenSize: 50, + expected: 'Default Value', + }, + { + title: 'selects the ranged value if the screen size is within the range', + screenSize: 85, + expected: 'Ranged Value', + }, + { + title: 'selects the mobile value if the screen size is outside the ranged values', + screenSize: 95, + expected: 'Mobile Value', + }, + ] as const + + tests.forEach((test) => { + it(`${test.title}`, () => { + expect(selectValueByBreakpoint(variants, test.screenSize)?.value).toEqual(test.expected) + }) + }) + + it('selects null if no matching breakpoint and no default value', () => { + const largeVariants: { value: string; modifiers?: StyleModifier[] }[] = [ + { + value: 'Desktop Value', + modifiers: [{ type: 'media-size', size: { min: { value: 200, unit: 'px' } } }], + }, + { + value: 'Tablet Value', + modifiers: [{ type: 'media-size', size: { min: { value: 100, unit: 'px' } } }], + }, + ] + expect(selectValueByBreakpoint(largeVariants, 50)).toBeNull() + }) + it('selects default value if no media modifiers', () => { + const noMediaVariants: { value: string; modifiers?: StyleModifier[] }[] = [ + { + value: 'Hover Value', + modifiers: [{ type: 'hover' }], + }, + { value: 'Default Value' }, + ] + expect(selectValueByBreakpoint(noMediaVariants, 50)?.value).toEqual('Default Value') + }) +}) + +describe('mediaQueryToScreenSize', () => { + it('converts simple screen size queries', () => { + const testCases: { input: string; expected: ScreenSize }[] = [ + { + input: '@media (100px 100px)', + expected: { min: { value: 100, unit: 'px' } }, + }, + ] + testCases.forEach((testCase) => { + csstree.walk(csstree.parse(testCase.input), (node) => { + if (node.type === 'MediaQuery') { + const result = mediaQueryToScreenSize(node as unknown as MediaQuery) + expect(result).toEqual(testCase.expected) + } + }) + }) + }) +}) diff --git a/editor/src/components/canvas/responsive-utils.ts b/editor/src/components/canvas/responsive-utils.ts new file mode 100644 index 000000000000..52349915b1b6 --- /dev/null +++ b/editor/src/components/canvas/responsive-utils.ts @@ -0,0 +1,210 @@ +import type { Feature, FeatureRange, MediaQuery, ScreenSize } from './responsive-types' +import * as csstree from 'css-tree' +import type { StyleMediaSizeModifier, StyleModifier } from './canvas-types' +import { type CSSNumber, type CSSNumberUnit, cssNumber } from '../inspector/common/css-utils' +import { memoize } from '../../core/shared/memoize' + +/** + * Extracts the screen size from a CSS string, for example: + * `@media (min-width: 100px)` -> { min: {value: 100, unit: 'px'} } + * `@media (20px < width < 50em)` -> { min: {value: 20, unit: 'px'}, max: {value: 50, unit: 'em'} } + */ +export const extractScreenSizeFromCss = memoize((css: string): ScreenSize | null => { + const mediaQuery = parseMediaQueryFromCss(css) + return mediaQuery == null ? null : mediaQueryToScreenSize(mediaQuery) +}) + +function extractFromFeatureRange(featureRange: FeatureRange): { + leftValue: CSSNumber | null + rightValue: CSSNumber | null + leftComparison: '<' | '>' | null + rightComparison: '<' | '>' | null +} | null { + // (100px < width < 500px) OR (500px > width > 100px) OR (100px > width) OR (500px < width) + if (featureRange?.middle?.type === 'Identifier' && featureRange.middle.name === 'width') { + const leftValue = + featureRange.left?.type === 'Dimension' + ? cssNumber(Number(featureRange.left.value), featureRange.left.unit as CSSNumberUnit) + : null + + const rightValue = + featureRange.right?.type === 'Dimension' + ? cssNumber(Number(featureRange.right.value), featureRange.right.unit as CSSNumberUnit) + : null + + return { + leftValue: leftValue, + rightValue: rightValue, + leftComparison: featureRange.leftComparison, + rightComparison: featureRange.rightComparison, + } + } + // (width > 100px) OR (width < 500px) + if (featureRange?.left?.type === 'Identifier' && featureRange.left.name === 'width') { + const rightValue = + featureRange.middle?.type === 'Dimension' + ? cssNumber(Number(featureRange.middle.value), featureRange.middle.unit as CSSNumberUnit) + : null + // this is not a mistake, since we normalize the "width" to be in the middle + const rightComparison = featureRange.leftComparison + + return { + leftValue: null, + leftComparison: null, + rightValue: rightValue, + rightComparison: rightComparison, + } + } + return null +} + +export function mediaQueryToScreenSize(mediaQuery: MediaQuery): ScreenSize { + const result: ScreenSize = {} + + if (mediaQuery.condition?.type === 'Condition') { + // 1. Handle FeatureRange case + const featureRanges = mediaQuery.condition.children.filter( + (child): child is FeatureRange => child.type === 'FeatureRange', + ) as Array + + featureRanges.forEach((featureRange) => { + const rangeData = extractFromFeatureRange(featureRange) + if (rangeData == null) { + return + } + const { leftValue, rightValue, leftComparison, rightComparison } = rangeData + if (leftValue != null) { + if (leftComparison === '<') { + result.min = leftValue + } else { + result.max = leftValue + } + } + if (rightValue != null) { + if (rightComparison === '<') { + result.max = rightValue + } else { + result.min = rightValue + } + } + }) + + // 2. Handle Feature case (min-width/max-width) + const features = mediaQuery.condition.children.filter( + (child): child is Feature => child.type === 'Feature', + ) + features.forEach((feature) => { + if (feature.value?.type === 'Dimension') { + if (feature.name === 'min-width') { + result.min = cssNumber(Number(feature.value.value), feature.value.unit as CSSNumberUnit) + } else if (feature.name === 'max-width') { + result.max = cssNumber(Number(feature.value.value), feature.value.unit as CSSNumberUnit) + } + } + }) + } + + return result +} + +function parseMediaQueryFromCss(css: string): MediaQuery | null { + let result: MediaQuery | null = null + csstree.walk(csstree.parse(css), (node) => { + if (node.type === 'MediaQuery') { + result = node as unknown as MediaQuery + } + }) + return result +} + +function getMediaModifier( + modifiers: StyleModifier[] | undefined | null, +): StyleMediaSizeModifier | null { + return (modifiers ?? []).filter( + (modifier): modifier is StyleMediaSizeModifier => modifier.type === 'media-size', + )[0] +} + +export function selectValueByBreakpoint( + parsedVariants: T[], + sceneWidthInPx?: number, +): T | null { + const relevantModifiers = parsedVariants.filter((variant) => { + // 1. filter out variants that don't have media modifiers, but keep variants with no modifiers at all + if (variant.modifiers == null || variant.modifiers.length === 0) { + return true + } + const mediaModifier = getMediaModifier(variant.modifiers) + if (mediaModifier == null) { + // this means it only has other modifiers + return false + } + + if (sceneWidthInPx == null) { + // filter out variants that require a scene width + return false + } + + // 2. check that it has at least one media modifier that satisfies the current scene width + const maxSizeInPx = cssNumberAsPx(mediaModifier.size.max) + const minSizeInPx = cssNumberAsPx(mediaModifier.size.min) + + // if it has only max + if (maxSizeInPx != null && minSizeInPx == null && sceneWidthInPx <= maxSizeInPx) { + return true + } + + // if it has only min + if (maxSizeInPx == null && minSizeInPx != null && sceneWidthInPx >= minSizeInPx) { + return true + } + + // if it has both max and min + if ( + maxSizeInPx != null && + minSizeInPx != null && + sceneWidthInPx >= minSizeInPx && + sceneWidthInPx <= maxSizeInPx + ) { + return true + } + return false + }) + let chosen: T | null = null + for (const variant of relevantModifiers) { + const chosenMediaModifier = getMediaModifier(chosen?.modifiers) + const variantMediaModifier = getMediaModifier(variant.modifiers) + if (variantMediaModifier == null) { + if (chosenMediaModifier == null) { + // if we have nothing chosen then we'll take the base value + chosen = variant + } + continue + } + if (chosenMediaModifier == null) { + chosen = variant + continue + } + // find the closest media modifier + const minSizeInPx = cssNumberAsPx(variantMediaModifier.size.min) + const chosenMinSizeInPx = cssNumberAsPx(chosenMediaModifier.size.min) + if (minSizeInPx != null && (chosenMinSizeInPx == null || minSizeInPx > chosenMinSizeInPx)) { + chosen = variant + } + const maxSizeInPx = cssNumberAsPx(variantMediaModifier.size.max) + const chosenMaxSizeInPx = cssNumberAsPx(chosenMediaModifier.size.max) + if (maxSizeInPx != null && (chosenMaxSizeInPx == null || maxSizeInPx < chosenMaxSizeInPx)) { + chosen = variant + } + } + if (chosen == null) { + return null + } + return chosen +} + +// TODO: get this value from the Scene +const EM_TO_PX_RATIO = 16 +export function cssNumberAsPx(value: CSSNumber | null | undefined): number | null { + return value == null ? null : value.unit === 'em' ? value.value * EM_TO_PX_RATIO : value.value +}