diff --git a/editor/src/components/canvas/canvas-types.ts b/editor/src/components/canvas/canvas-types.ts index 787c04380f36..a9c4f91ce700 100644 --- a/editor/src/components/canvas/canvas-types.ts +++ b/editor/src/components/canvas/canvas-types.ts @@ -29,6 +29,7 @@ import type { CSSOverflow, CSSPadding, FlexDirection, + ParsedCSSProperties, } from '../inspector/common/css-utils' import type { ScreenSize } from './responsive-types' @@ -553,17 +554,27 @@ interface CSSStylePropertyNotParsable { interface ParsedCSSStyleProperty { type: 'property' - tags: PropertyTag[] propertyValue: JSExpression | PartOfJSXAttributeValue - value: T + currentVariant: CSSVariant + variants?: CSSVariant[] } -type StyleHoverModifier = { type: 'hover' } -export type StyleMediaSizeModifier = { +type StyleModifierMetadata = { type: string; modifierOrigin?: StyleModifierOrigin } +type StyleHoverModifier = StyleModifierMetadata & { type: 'hover' } +export type StyleMediaSizeModifier = StyleModifierMetadata & { type: 'media-size' size: ScreenSize } export type StyleModifier = StyleHoverModifier | StyleMediaSizeModifier +type InlineModifierOrigin = { type: 'inline' } +type TailwindModifierOrigin = { type: 'tailwind'; variant: string } +export type StyleModifierOrigin = InlineModifierOrigin | TailwindModifierOrigin + +export type ParsedVariant = { + parsedValue: NonNullable + originalValue: string | number | undefined + modifiers?: StyleModifier[] +} export type CSSStyleProperty = | CSSStylePropertyNotFound @@ -580,16 +591,35 @@ export function cssStylePropertyNotParsable( return { type: 'not-parsable', originalValue: originalValue } } +export type CSSVariant = { + value: T + modifiers?: StyleModifier[] +} + +export function cssVariant(value: T, modifiers?: StyleModifier[]): CSSVariant { + return { value: value, modifiers: modifiers } +} + export function cssStyleProperty( - value: T, propertyValue: JSExpression | PartOfJSXAttributeValue, + currentVariant: CSSVariant, + variants: CSSVariant[], ): ParsedCSSStyleProperty { - return { type: 'property', tags: [], value: value, propertyValue: propertyValue } + return { + type: 'property', + propertyValue: propertyValue, + currentVariant: currentVariant, + variants: variants, + } +} + +export function screenSizeModifier(size: ScreenSize): StyleMediaSizeModifier { + return { type: 'media-size', size: size } } export function maybePropertyValue(property: CSSStyleProperty): T | null { if (property.type === 'property') { - return property.value + return property.currentVariant.value } return null } diff --git a/editor/src/components/canvas/commands/utils/property-utils.ts b/editor/src/components/canvas/commands/utils/property-utils.ts index 123bba748aae..b0804b3d48bc 100644 --- a/editor/src/components/canvas/commands/utils/property-utils.ts +++ b/editor/src/components/canvas/commands/utils/property-utils.ts @@ -117,8 +117,8 @@ export function getCSSNumberFromStyleInfo( return { type: 'not-found' } } - if (prop.type === 'not-parsable' || !isCSSNumber(prop.value)) { + if (prop.type === 'not-parsable' || !isCSSNumber(prop.currentVariant.value)) { return { type: 'not-css-number' } } - return { type: 'css-number', number: prop.value } + return { type: 'css-number', number: prop.currentVariant.value } } diff --git a/editor/src/components/canvas/plugins/inline-style-plugin.spec.ts b/editor/src/components/canvas/plugins/inline-style-plugin.spec.ts index e0e8029bce6f..054bcd20f9fb 100644 --- a/editor/src/components/canvas/plugins/inline-style-plugin.spec.ts +++ b/editor/src/components/canvas/plugins/inline-style-plugin.spec.ts @@ -43,13 +43,11 @@ export var storyboard = ( const { flexDirection, gap } = styleInfo! expect(flexDirection).toMatchObject({ type: 'property', - tags: [], value: 'column', propertyValue: { value: 'column' }, }) expect(gap).toMatchObject({ type: 'property', - tags: [], value: cssNumber(2, 'rem'), propertyValue: { value: '2rem' }, }) diff --git a/editor/src/components/canvas/plugins/inline-style-plugin.ts b/editor/src/components/canvas/plugins/inline-style-plugin.ts index d8c71ec114dc..7d3e29bdb21b 100644 --- a/editor/src/components/canvas/plugins/inline-style-plugin.ts +++ b/editor/src/components/canvas/plugins/inline-style-plugin.ts @@ -13,6 +13,7 @@ import { cssStyleProperty, cssStylePropertyNotParsable, cssStylePropertyNotFound, + cssVariant, } from '../canvas-types' import { mapDropNulls } from '../../../core/shared/array-utils' import { emptyComments, jsExpressionValue } from '../../../core/shared/element-template' @@ -45,7 +46,8 @@ function getPropertyFromInstance

{ + it('returns empty array for non-media variants', () => { + const variants = [{ type: 'hover', value: 'hover' }] + const config: Config = { theme: { screens: {} } } as Config + + const result = getModifiers(variants, config) + expect(result).toEqual([]) + }) + + it('handles default screen sizes correctly', () => { + const variants = [{ type: 'media', value: 'md' }] + const config = null // null config should use defaults + + const result = getModifiers(variants, config) + expect(result).toEqual([ + { + type: 'media-size', + size: { + min: { value: 768, unit: 'px' }, + }, + modifierOrigin: { type: 'tailwind', variant: 'md' }, + }, + ]) + }) + + it('handles custom screen sizes from config', () => { + const variants = [{ type: 'media', value: 'custom' }] + const config = { + theme: { + screens: { + custom: '1000px', + }, + }, + } as unknown as Config + + const result = getModifiers(variants, config) + expect(result).toEqual([ + { + type: 'media-size', + size: { + min: { value: 1000, unit: 'px' }, + }, + modifierOrigin: { type: 'tailwind', variant: 'custom' }, + }, + ]) + }) + + it('handles min-max range screen sizes', () => { + const variants = [{ type: 'media', value: 'tablet' }] + const config = { + theme: { + screens: { + tablet: { min: '768px', max: '1024px' }, + }, + }, + } as unknown as Config + + const result = getModifiers(variants, config) + expect(result).toEqual([ + { + type: 'media-size', + size: { + min: { value: 768, unit: 'px' }, + max: { value: 1024, unit: 'px' }, + }, + modifierOrigin: { type: 'tailwind', variant: 'tablet' }, + }, + ]) + }) + + it('handles extended screen sizes', () => { + const variants = [{ type: 'media', value: 'extra' }] + const config = { + theme: { + screens: { + sm: '640px', + }, + extend: { + screens: { + extra: '1400px', + }, + }, + }, + } as unknown as Config + + const result = getModifiers(variants, config) + expect(result).toEqual([ + { + type: 'media-size', + size: { + min: { value: 1400, unit: 'px' }, + }, + modifierOrigin: { type: 'tailwind', variant: 'extra' }, + }, + ]) + }) + + it('handles multiple media variants', () => { + const variants = [ + { type: 'media', value: 'sm' }, + { type: 'media', value: 'lg' }, + ] + const config = null // use defaults + + const result = getModifiers(variants, config) + expect(result).toEqual([ + { + type: 'media-size', + size: { + min: { value: 640, unit: 'px' }, + }, + modifierOrigin: { type: 'tailwind', variant: 'sm' }, + }, + { + type: 'media-size', + size: { + min: { value: 1024, unit: 'px' }, + }, + modifierOrigin: { type: 'tailwind', variant: 'lg' }, + }, + ]) + }) + + it('filters out invalid screen sizes', () => { + const variants = [ + { type: 'media', value: 'invalid' }, + { type: 'media', value: 'md' }, + ] + const config = null // use defaults + + const result = getModifiers(variants, config) + expect(result).toEqual([ + { + type: 'media-size', + size: { + min: { value: 768, unit: 'px' }, + }, + modifierOrigin: { type: 'tailwind', variant: 'md' }, + }, + ]) + }) +}) + +describe('screensConfigToScreenSizes', () => { + it('returns default screen sizes when config is null', () => { + const result = screensConfigToScreenSizes(null) + expect(result).toEqual({ + sm: { min: { value: 640, unit: 'px' } }, + md: { min: { value: 768, unit: 'px' } }, + lg: { min: { value: 1024, unit: 'px' } }, + xl: { min: { value: 1280, unit: 'px' } }, + '2xl': { min: { value: 1536, unit: 'px' } }, + }) + }) + + it('handles custom screen sizes', () => { + const config = { + theme: { + screens: { + mobile: '400px', + tablet: '800px', + }, + }, + } as unknown as Config + + const result = screensConfigToScreenSizes(config) + expect(result).toEqual({ + mobile: { min: { value: 400, unit: 'px' } }, + tablet: { min: { value: 800, unit: 'px' } }, + }) + }) + + it('handles min-max range screen sizes', () => { + const config = { + theme: { + screens: { + tablet: { min: '768px', max: '1024px' }, + }, + }, + } as unknown as Config + + const result = screensConfigToScreenSizes(config) + expect(result).toEqual({ + tablet: { + min: { value: 768, unit: 'px' }, + max: { value: 1024, unit: 'px' }, + }, + }) + }) + + it('merges extended screen sizes with base config', () => { + const config = { + theme: { + screens: { + sm: '640px', + }, + extend: { + screens: { + custom: '1400px', + }, + }, + }, + } as unknown as Config + + const result = screensConfigToScreenSizes(config) + expect(result).toEqual({ + sm: { min: { value: 640, unit: 'px' } }, + custom: { min: { value: 1400, unit: 'px' } }, + }) + }) + + it('handles empty config objects', () => { + const config = { + theme: {}, + } as unknown as Config + + const result = screensConfigToScreenSizes(config) + expect(result).toEqual({ + sm: { min: { value: 640, unit: 'px' } }, + md: { min: { value: 768, unit: 'px' } }, + lg: { min: { value: 1024, unit: 'px' } }, + xl: { min: { value: 1280, unit: 'px' } }, + '2xl': { min: { value: 1536, unit: 'px' } }, + }) + }) +}) diff --git a/editor/src/components/canvas/plugins/tailwind-style-plugin-utils/tailwind-responsive-utils.ts b/editor/src/components/canvas/plugins/tailwind-style-plugin-utils/tailwind-responsive-utils.ts new file mode 100644 index 000000000000..3e0f6ca423c6 --- /dev/null +++ b/editor/src/components/canvas/plugins/tailwind-style-plugin-utils/tailwind-responsive-utils.ts @@ -0,0 +1,115 @@ +import type { Config } from 'tailwindcss/types/config' +import { isStyleInfoKey, type StyleMediaSizeModifier, type StyleModifier } from '../../canvas-types' +import type { ScreenSize } from '../../responsive-types' +import { extractScreenSizeFromCss } from '../../responsive-utils' +import { TailwindPropertyMapping } from '../tailwind-style-plugin' +import { parseTailwindPropertyFactory } from '../tailwind-style-plugin' +import { getTailwindClassMapping } from '../tailwind-style-plugin' +import type { StylePluginContext } from '../style-plugins' + +export const TAILWIND_DEFAULT_SCREENS = { + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + '2xl': '1536px', +} +const defaultTailwindConfig = { + theme: { + screens: TAILWIND_DEFAULT_SCREENS, + }, +} as unknown as Config +type TailwindScreen = string | { min: string; max: string } + +export function screensConfigToScreenSizes(config: Config | null): Record { + const tailwindConfig = config ?? defaultTailwindConfig + const screenSizes: Record = { + ...((tailwindConfig.theme?.screens as Record) ?? + TAILWIND_DEFAULT_SCREENS), + ...((tailwindConfig.theme?.extend?.screens as Record) ?? {}), + } + + return Object.fromEntries( + Object.entries(screenSizes) + .map(([key, size]) => { + const mediaString = + typeof size === 'string' + ? `@media (min-width: ${size})` + : `@media ${[ + size.min != null ? `(min-width: ${size.min})` : '', + size.max != null ? `(max-width: ${size.max})` : '', + ] + .filter((s) => s != '') + .join(' and ')}` + + const screenSize = extractScreenSizeFromCss(mediaString) + if (screenSize == null) { + return null + } + return [key, screenSize] + }) + .filter((entry): entry is [string, ScreenSize] => entry != null), + ) +} + +/** + * This function gets variants in the form of {type: 'media', value: 'sm'} + * and turns them into modifiers in the form of [{type: 'media-size', size: {min: {value: 0, unit: 'px'}, max: {value: 100, unit: 'em'}}}] + * according to the tailwind config + */ +export function getModifiers( + variants: { type: string; value: string }[], + config: Config | null, +): StyleModifier[] { + const mediaModifiers = variants.filter((v) => v.type === 'media') + const screenSizes = screensConfigToScreenSizes(config) + + return mediaModifiers + .map((mediaModifier) => { + const size = screenSizes[mediaModifier.value] + if (size == null) { + return null + } + return { + type: 'media-size', + size: size, + modifierOrigin: { type: 'tailwind', variant: mediaModifier.value }, + } as StyleMediaSizeModifier + }) + .filter((m): m is StyleMediaSizeModifier => m != null) +} + +export function getPropertiesToAppliedModifiersMap( + currentClassNameAttribute: string, + propertyNames: string[], + config: Config | null, + context: StylePluginContext, +): Record { + const parseTailwindProperty = parseTailwindPropertyFactory(config, context) + const classMapping = getTailwindClassMapping(currentClassNameAttribute.split(' '), config) + return propertyNames.reduce((acc, propertyName) => { + if (!isStyleInfoKey(propertyName)) { + return acc + } + const parsedProperty = parseTailwindProperty( + classMapping[TailwindPropertyMapping[propertyName]], + propertyName, + ) + if (parsedProperty?.type == 'property' && parsedProperty.currentVariant.modifiers != null) { + return { + ...acc, + [propertyName]: parsedProperty.currentVariant.modifiers, + } + } else { + return acc + } + }, {} as Record) +} + +export function getTailwindVariantFromAppliedModifier( + appliedModifier: StyleMediaSizeModifier | null, +): string | null { + return appliedModifier?.modifierOrigin?.type === 'tailwind' + ? appliedModifier.modifierOrigin.variant + : null +} diff --git a/editor/src/components/canvas/plugins/tailwind-style-plugin.ts b/editor/src/components/canvas/plugins/tailwind-style-plugin.ts index e9fadf9ef689..5f378167a27c 100644 --- a/editor/src/components/canvas/plugins/tailwind-style-plugin.ts +++ b/editor/src/components/canvas/plugins/tailwind-style-plugin.ts @@ -1,4 +1,5 @@ import * as TailwindClassParser from '@xengine/tailwindcss-class-parser' +import type { Right } from '../../../core/shared/either' import { defaultEither, flatMapEither, isLeft } from '../../../core/shared/either' import { getClassNameAttribute } from '../../../core/tailwind/tailwind-options' import { getElementFromProjectContents } from '../../editor/store/editor-state' @@ -7,8 +8,8 @@ import { cssParsers } from '../../inspector/common/css-utils' import { mapDropNulls } from '../../../core/shared/array-utils' import type { StylePlugin, StylePluginContext } from './style-plugins' import type { Config } from 'tailwindcss/types/config' -import type { StyleInfo } from '../canvas-types' -import { cssStyleProperty, type CSSStyleProperty } from '../canvas-types' +import type { ParsedVariant, StyleInfo } from '../canvas-types' +import { cssStyleProperty, cssVariant, type CSSStyleProperty } from '../canvas-types' import * as UCL from './tailwind-style-plugin-utils/update-class-list' import { assertNever } from '../../../core/shared/utils' import { @@ -18,22 +19,49 @@ import { import { emptyComments, type JSXAttributes } from 'utopia-shared/src/types' import * as PP from '../../../core/shared/property-path' import { jsExpressionValue } from '../../../core/shared/element-template' -import { getContainingSceneSize } from '../responsive-utils' +import { getContainingSceneSize, selectValueByBreakpoint } from '../responsive-utils' +import { getModifiers } from './tailwind-style-plugin-utils/tailwind-responsive-utils' -const parseTailwindPropertyFactory = +type StyleValueVariants = { + value: string | number | undefined + modifiers?: TailwindModifier[] +}[] + +export const parseTailwindPropertyFactory = (config: Config | null, context: StylePluginContext) => ( - value: string | number | undefined, + styleDefinition: StyleValueVariants | undefined, prop: T, ): CSSStyleProperty> | null => { - const parsed = cssParsers[prop](value, null) - if (isLeft(parsed) || parsed.value == null) { + if (styleDefinition == null) { + return null + } + const possibleVariants: ParsedVariant[] = styleDefinition + .map((v) => ({ + parsedValue: cssParsers[prop](v.value, null), + originalValue: v.value, + modifiers: getModifiers(v.modifiers ?? [], config), + })) + .filter((v) => v.parsedValue != null && !isLeft(v.parsedValue)) + .map((v) => ({ + ...v, + parsedValue: (v.parsedValue as Right).value as NonNullable< + ParsedCSSProperties[T] + >, + })) + const sceneSize = context.sceneSize.type === 'scene' ? context.sceneSize.width : undefined + const selectedVariant = selectValueByBreakpoint(possibleVariants, sceneSize) + if (selectedVariant == null) { return null } - return cssStyleProperty(parsed.value, jsExpressionValue(value, emptyComments)) + return cssStyleProperty( + jsExpressionValue(selectedVariant.originalValue, emptyComments), + cssVariant(selectedVariant.parsedValue, selectedVariant.modifiers), + possibleVariants.map((variant) => cssVariant(variant.parsedValue, variant.modifiers)), + ) } -const TailwindPropertyMapping: Record = { +export const TailwindPropertyMapping: Record = { left: 'positionLeft', right: 'positionRight', top: 'positionTop', @@ -84,19 +112,51 @@ function stringifyPropertyValue(value: string | number): string { } } -function getTailwindClassMapping(classes: string[], config: Config | null): Record { - const mapping: Record = {} +type TailwindParsedStyle = { + kind: string + property: string + value: string + variants?: { type: string; value: string }[] +} + +export function getTailwindClassMapping( + classes: string[], + config: Config | null, +): Record { + const mapping: Record = {} classes.forEach((className) => { - const parsed = TailwindClassParser.parse(className, config ?? undefined) - if (parsed.kind === 'error' || !isSupportedTailwindProperty(parsed.property)) { + const parsed: TailwindParsedStyle | undefined = TailwindClassParser.parse( + className, + config ?? undefined, + ) + if ( + parsed == null || + parsed.kind === 'error' || + !isSupportedTailwindProperty(parsed.property) + ) { return } - mapping[parsed.property] = parsed.value + mapping[parsed.property] = mapping[parsed.property] ?? [] + const modifiers = (parsed.variants ?? []).filter(isTailwindModifier) + mapping[parsed.property].push({ + value: parsed.value, + modifiers: modifiers, + }) }) return mapping } -const underscoresToSpaces = (s: string | undefined) => s?.replace(/[-_]/g, ' ') +const underscoresToSpaces = ( + styleDef: StyleValueVariants | undefined, +): StyleValueVariants | undefined => { + if (styleDef == null) { + return undefined + } + return styleDef.map((style) => ({ + ...style, + value: typeof style.value === 'string' ? style.value.replace(/[-_]/g, ' ') : style.value, + })) +} export const TailwindPlugin = (config: Config | null): StylePlugin => ({ name: 'Tailwind', @@ -222,3 +282,11 @@ export const TailwindPlugin = (config: Config | null): StylePlugin => ({ ) }, }) + +type TailwindModifier = { type: 'media'; value: string } | { type: 'hover'; value: string } +function isTailwindModifier(modifier: { + type: string + value: string +}): modifier is TailwindModifier { + return modifier.type === 'media' || modifier.type === 'hover' +}