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 +}