diff --git a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx index ce32f1cacebc..1f6367b241eb 100644 --- a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx +++ b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx @@ -228,6 +228,7 @@ export function pickCanvasStateFromEditorState( propertyControlsInfo: editorState.propertyControlsInfo, styleInfoReader: activePlugin.styleInfoFactory({ projectContents: editorState.projectContents, + jsxMetadata: editorState.jsxMetadata, }), } } @@ -255,6 +256,7 @@ export function pickCanvasStateFromEditorStateWithMetadata( propertyControlsInfo: editorState.propertyControlsInfo, styleInfoReader: activePlugin.styleInfoFactory({ projectContents: editorState.projectContents, + jsxMetadata: editorState.jsxMetadata, }), } } diff --git a/editor/src/components/canvas/canvas-strategies/canvas-strategy-types.ts b/editor/src/components/canvas/canvas-strategies/canvas-strategy-types.ts index 80d553a97a9a..03da8982132a 100644 --- a/editor/src/components/canvas/canvas-strategies/canvas-strategy-types.ts +++ b/editor/src/components/canvas/canvas-strategies/canvas-strategy-types.ts @@ -112,9 +112,12 @@ export function controlWithProps

(value: ControlWithProps

): ControlWithProp export type StyleInfoReader = (elementPath: ElementPath) => StyleInfo | null -export type StyleInfoFactory = (context: { +export type StyleInfoContext = { projectContents: ProjectContentTreeRoot -}) => StyleInfoReader + jsxMetadata: ElementInstanceMetadataMap +} + +export type StyleInfoFactory = (context: StyleInfoContext) => StyleInfoReader export interface InteractionCanvasState { interactionTarget: InteractionTarget diff --git a/editor/src/components/canvas/canvas-types.ts b/editor/src/components/canvas/canvas-types.ts index 06f6f0f5220e..aa909333c04c 100644 --- a/editor/src/components/canvas/canvas-types.ts +++ b/editor/src/components/canvas/canvas-types.ts @@ -29,7 +29,9 @@ import type { CSSOverflow, CSSPadding, FlexDirection, + ParsedCSSProperties, } from '../inspector/common/css-utils' +import type { ScreenSize } from './responsive-types' export const CanvasContainerID = 'canvas-container' export const SceneContainerName = 'scene' @@ -552,9 +554,26 @@ interface CSSStylePropertyNotParsable { interface ParsedCSSStyleProperty { type: 'property' - tags: PropertyTag[] propertyValue: JSExpression | PartOfJSXAttributeValue - value: T + currentVariant: CSSVariant + variants?: CSSVariant[] +} + +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 = @@ -572,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/adjust-css-length-command.ts b/editor/src/components/canvas/commands/adjust-css-length-command.ts index 128aeac077d6..df4b81ecd292 100644 --- a/editor/src/components/canvas/commands/adjust-css-length-command.ts +++ b/editor/src/components/canvas/commands/adjust-css-length-command.ts @@ -78,6 +78,7 @@ export const runAdjustCssLengthProperties = ( const styleInfoReader = getActivePlugin(withConflictingPropertiesRemoved).styleInfoFactory({ projectContents: withConflictingPropertiesRemoved.projectContents, + jsxMetadata: withConflictingPropertiesRemoved.jsxMetadata, }) const styleInfo = styleInfoReader(command.target) diff --git a/editor/src/components/canvas/commands/set-css-length-command.ts b/editor/src/components/canvas/commands/set-css-length-command.ts index f69f66c328df..cc2a0998f8a2 100644 --- a/editor/src/components/canvas/commands/set-css-length-command.ts +++ b/editor/src/components/canvas/commands/set-css-length-command.ts @@ -87,6 +87,7 @@ export const runSetCssLengthProperty = ( const styleInfo = getActivePlugin(editorStateWithPropsDeleted).styleInfoFactory({ projectContents: editorStateWithPropsDeleted.projectContents, + jsxMetadata: editorStateWithPropsDeleted.jsxMetadata, })(command.target) if (styleInfo == 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/controls/select-mode/border-radius-control.tsx b/editor/src/components/canvas/controls/select-mode/border-radius-control.tsx index eb0d98c4d493..7f01a640b7e8 100644 --- a/editor/src/components/canvas/controls/select-mode/border-radius-control.tsx +++ b/editor/src/components/canvas/controls/select-mode/border-radius-control.tsx @@ -63,6 +63,7 @@ const borderRadiusSelector = createCachedSelector( (store: StyleInfoSubstate) => getActivePlugin(store.editor).styleInfoFactory({ projectContents: store.editor.projectContents, + jsxMetadata: store.editor.jsxMetadata, }), (_: MetadataSubstate, x: ElementPath) => x, (metadata, styleInfoReader, selectedElement) => { diff --git a/editor/src/components/canvas/controls/select-mode/flex-gap-control.tsx b/editor/src/components/canvas/controls/select-mode/flex-gap-control.tsx index 672e28d2f801..6f0121923126 100644 --- a/editor/src/components/canvas/controls/select-mode/flex-gap-control.tsx +++ b/editor/src/components/canvas/controls/select-mode/flex-gap-control.tsx @@ -139,6 +139,7 @@ export const FlexGapControl = controlForStrategyMemoized((p maybeFlexGapData( getActivePlugin(store.editor).styleInfoFactory({ projectContents: store.editor.projectContents, + jsxMetadata: store.editor.jsxMetadata, })(selectedElement), MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, selectedElement), ), diff --git a/editor/src/components/canvas/controls/select-mode/padding-resize-control.tsx b/editor/src/components/canvas/controls/select-mode/padding-resize-control.tsx index c9d8906e69ba..81827667dec2 100644 --- a/editor/src/components/canvas/controls/select-mode/padding-resize-control.tsx +++ b/editor/src/components/canvas/controls/select-mode/padding-resize-control.tsx @@ -362,6 +362,7 @@ export const PaddingResizeControl = controlForStrategyMemoized((props: PaddingCo const styleInfoReaderRef = useRefEditorState((store) => getActivePlugin(store.editor).styleInfoFactory({ projectContents: store.editor.projectContents, + jsxMetadata: store.editor.jsxMetadata, }), ) diff --git a/editor/src/components/canvas/controls/select-mode/remix-scene-label.tsx b/editor/src/components/canvas/controls/select-mode/remix-scene-label.tsx index 2597448acfe4..52d0281ab262 100644 --- a/editor/src/components/canvas/controls/select-mode/remix-scene-label.tsx +++ b/editor/src/components/canvas/controls/select-mode/remix-scene-label.tsx @@ -324,6 +324,10 @@ const RemixSceneLabel = React.memo((props) => {

{scenelabel}{' '} {sceneSize} + + + +
((props) => { ) }) + +const DeviceSizeButton = React.memo<{ name: string; sizePx: number }>((props) => ( + { + const scene = document.querySelector('[data-testid=remix-scene]') + if (scene != null) { + ;(scene as HTMLElement).style.width = props.sizePx === 0 ? '' : `${props.sizePx}px` + } + e.preventDefault() + e.stopPropagation() + }} + onMouseDown={(e) => e.stopPropagation()} + > + {props.name} + +)) diff --git a/editor/src/components/canvas/controls/select-mode/subdued-flex-gap-controls.tsx b/editor/src/components/canvas/controls/select-mode/subdued-flex-gap-controls.tsx index 539d082cc253..4e0091951593 100644 --- a/editor/src/components/canvas/controls/select-mode/subdued-flex-gap-controls.tsx +++ b/editor/src/components/canvas/controls/select-mode/subdued-flex-gap-controls.tsx @@ -43,6 +43,7 @@ export const SubduedFlexGapControl = React.memo((pro maybeFlexGapData( getActivePlugin(store.editor).styleInfoFactory({ projectContents: store.editor.projectContents, + jsxMetadata: store.editor.jsxMetadata, })(selectedElement), MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, selectedElement), ), diff --git a/editor/src/components/canvas/controls/select-mode/subdued-padding-control.tsx b/editor/src/components/canvas/controls/select-mode/subdued-padding-control.tsx index 766a180c9386..3fb67c29a496 100644 --- a/editor/src/components/canvas/controls/select-mode/subdued-padding-control.tsx +++ b/editor/src/components/canvas/controls/select-mode/subdued-padding-control.tsx @@ -29,6 +29,7 @@ export const SubduedPaddingControl = React.memo((pro const styleInfoReaderRef = useRefEditorState((store) => getActivePlugin(store.editor).styleInfoFactory({ projectContents: store.editor.projectContents, + jsxMetadata: store.editor.jsxMetadata, }), ) 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 8ba0e9733b8c..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' }, }) @@ -110,6 +108,7 @@ function getStyleInfoFromInlineStyle(editor: EditorRenderResult) { const styleInfoReader = InlineStylePlugin.styleInfoFactory({ projectContents: projectContents, + jsxMetadata: jsxMetadata, }) const styleInfo = styleInfoReader(EP.fromString('sb/scene/div')) return styleInfo diff --git a/editor/src/components/canvas/plugins/inline-style-plugin.ts b/editor/src/components/canvas/plugins/inline-style-plugin.ts index d8c71ec114dc..8546bcb74b94 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,7 @@ function getPropertyFromInstance

( attributes: JSXAttributes, prop: T, + context: StylePluginContext, ) => CSSStyleProperty> | null updateStyles: ( editorState: EditorState, @@ -248,6 +252,7 @@ export function patchRemovedProperties(editorState: EditorState): EditorState { const styleInfoReader = activePlugin.styleInfoFactory({ projectContents: editorState.projectContents, + jsxMetadata: editorState.jsxMetadata, }) const propertiesUpdatedDuringInteraction = getPropertiesUpdatedDuringInteraction(editorState) diff --git a/editor/src/components/canvas/plugins/tailwind-style-plugin-utils/tailwind-responsive-utils.spec.ts b/editor/src/components/canvas/plugins/tailwind-style-plugin-utils/tailwind-responsive-utils.spec.ts new file mode 100644 index 000000000000..2787e8ecb8e7 --- /dev/null +++ b/editor/src/components/canvas/plugins/tailwind-style-plugin-utils/tailwind-responsive-utils.spec.ts @@ -0,0 +1,229 @@ +import type { Config } from 'tailwindcss/types/config' +import { getModifiers, screensConfigToScreenSizes } from './tailwind-responsive-utils' + +describe('getModifiers', () => { + 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-utils/update-class-list.ts b/editor/src/components/canvas/plugins/tailwind-style-plugin-utils/update-class-list.ts index b8896c93aa64..e22d61796e89 100644 --- a/editor/src/components/canvas/plugins/tailwind-style-plugin-utils/update-class-list.ts +++ b/editor/src/components/canvas/plugins/tailwind-style-plugin-utils/update-class-list.ts @@ -1,8 +1,10 @@ import type { ElementPath } from 'utopia-shared/src/types' import { emptyComments } from 'utopia-shared/src/types' -import { mapDropNulls } from '../../../../core/shared/array-utils' import { jsExpressionValue } from '../../../../core/shared/element-template' -import type { PropertiesToUpdate } from '../../../../core/tailwind/tailwind-class-list-utils' +import type { + PropertiesToRemove, + PropertiesToUpdate, +} from '../../../../core/tailwind/tailwind-class-list-utils' import { getParsedClassList, removeClasses, @@ -17,6 +19,8 @@ import type { EditorStateWithPatch } from '../../commands/utils/property-utils' import { applyValuesAtPath } from '../../commands/utils/property-utils' import * as PP from '../../../../core/shared/property-path' import type { Config } from 'tailwindcss/types/config' +import { getPropertiesToAppliedModifiersMap } from './tailwind-responsive-utils' +import type { StylePluginContext } from '../style-plugins' export type ClassListUpdate = | { type: 'add'; property: string; value: string } @@ -37,27 +41,55 @@ export const runUpdateClassList = ( element: ElementPath, classNameUpdates: ClassListUpdate[], config: Config | null, + context: StylePluginContext, ): EditorStateWithPatch => { const currentClassNameAttribute = getClassNameAttribute(getElementFromProjectContents(element, editorState.projectContents)) ?.value ?? '' + // this will tell for every property which modifiers are in action + const propertyToAppliedModifiersMap = getPropertiesToAppliedModifiersMap( + currentClassNameAttribute, + classNameUpdates.map((update) => update.property), + config, + context, + ) + const parsedClassList = getParsedClassList(currentClassNameAttribute, config) - const propertiesToRemove = mapDropNulls( - (update) => (update.type !== 'remove' ? null : update.property), - classNameUpdates, + const propertiesToRemove: PropertiesToRemove = classNameUpdates.reduce( + (acc: PropertiesToRemove, val) => + val.type === 'remove' + ? { + ...acc, + [val.property]: { + remove: true, + modifiers: propertyToAppliedModifiersMap[val.property] ?? [], + }, + } + : acc, + {}, ) const propertiesToUpdate: PropertiesToUpdate = classNameUpdates.reduce( - (acc: { [property: string]: string }, val) => - val.type === 'remove' ? acc : { ...acc, [val.property]: val.value }, + (acc: PropertiesToUpdate, val) => + val.type === 'remove' + ? acc + : { + ...acc, + [val.property]: { + newValue: val.value, + modifiers: propertyToAppliedModifiersMap[val.property] ?? [], + }, + }, {}, ) const updatedClassList = [ removeClasses(propertiesToRemove), updateExistingClasses(propertiesToUpdate), + // currently we're not adding new breakpoint styles (but only editing current ones), + // so we don't need to pass the propertyToAppliedModifiersMap here addNewClasses(propertiesToUpdate), ].reduce((classList, fn) => fn(classList), parsedClassList) diff --git a/editor/src/components/canvas/plugins/tailwind-style-plugin.ts b/editor/src/components/canvas/plugins/tailwind-style-plugin.ts index 2941f0bc9a12..73926a6df99d 100644 --- a/editor/src/components/canvas/plugins/tailwind-style-plugin.ts +++ b/editor/src/components/canvas/plugins/tailwind-style-plugin.ts @@ -1,14 +1,15 @@ import * as TailwindClassParser from '@xengine/tailwindcss-class-parser' -import { defaultEither, flatMapEither, isLeft } from '../../../core/shared/either' +import type { Right } from '../../../core/shared/either' +import { defaultEither, Either, flatMapEither, isLeft } from '../../../core/shared/either' import { getClassNameAttribute } from '../../../core/tailwind/tailwind-options' import { getElementFromProjectContents } from '../../editor/store/editor-state' import type { ParsedCSSProperties } from '../../inspector/common/css-utils' import { cssParsers } from '../../inspector/common/css-utils' import { mapDropNulls } from '../../../core/shared/array-utils' -import type { StylePlugin } from './style-plugins' +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,19 +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 { getModifiers } from './tailwind-style-plugin-utils/tailwind-responsive-utils' +import { getContainingSceneWidth, selectValueByBreakpoint } from '../responsive-utils' -function parseTailwindProperty( - value: string | number | undefined, - prop: T, -): CSSStyleProperty> | null { - const parsed = cssParsers[prop](value, null) - if (isLeft(parsed) || parsed.value == null) { - return null +type StyleValueVariants = { + value: string | number | undefined + modifiers?: TailwindModifier[] +}[] + +export const parseTailwindPropertyFactory = + (config: Config | null, context: StylePluginContext) => + ( + styleDefinition: StyleValueVariants | undefined, + prop: T, + ): CSSStyleProperty> | 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 selectedVariant = selectValueByBreakpoint(possibleVariants, context?.sceneWidth) + if (selectedVariant == null) { + return null + } + return cssStyleProperty( + jsExpressionValue(selectedVariant.originalValue, emptyComments), + cssVariant(selectedVariant.parsedValue, selectedVariant.modifiers), + possibleVariants.map((variant) => cssVariant(variant.parsedValue, variant.modifiers)), + ) } - return cssStyleProperty(parsed.value, jsExpressionValue(value, emptyComments)) -} -const TailwindPropertyMapping: Record = { +export const TailwindPropertyMapping: Record = { left: 'positionLeft', right: 'positionRight', top: 'positionTop', @@ -80,137 +111,182 @@ function stringifyPropertyValue(value: string | number): string { assertNever(value) } } - -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, ' ') - -export const TailwindPlugin = (config: Config | null): StylePlugin => ({ - name: 'Tailwind', - readStyleFromElementProps:

( - attributes: JSXAttributes, - prop: P, - ): CSSStyleProperty> | null => { - const classNameAttribute = defaultEither( - null, - flatMapEither( - (attr) => jsxSimpleAttributeToValue(attr), - getModifiableJSXAttributeAtPath(attributes, PP.create('className')), - ), - ) +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, + })) +} - if (typeof classNameAttribute !== 'string') { - return null - } +export const TailwindPlugin = (config: Config | null): StylePlugin => { + return { + name: 'Tailwind', + readStyleFromElementProps:

( + attributes: JSXAttributes, + prop: P, + context: StylePluginContext, + ): CSSStyleProperty> | null => { + const classNameAttribute = defaultEither( + null, + flatMapEither( + (attr) => jsxSimpleAttributeToValue(attr), + getModifiableJSXAttributeAtPath(attributes, PP.create('className')), + ), + ) - const mapping = getTailwindClassMapping(classNameAttribute.split(' '), config) - return parseTailwindProperty(mapping[TailwindPropertyMapping[prop]], prop) - }, - styleInfoFactory: - ({ projectContents }) => - (elementPath) => { - const classList = getClassNameAttribute( - getElementFromProjectContents(elementPath, projectContents), - )?.value - - if (classList == null || typeof classList !== 'string') { + if (typeof classNameAttribute !== 'string') { return null } - const mapping = getTailwindClassMapping(classList.split(' '), config) + const mapping = getTailwindClassMapping(classNameAttribute.split(' '), config) + const parseTailwindProperty = parseTailwindPropertyFactory(config, context ?? {}) + return parseTailwindProperty(mapping[TailwindPropertyMapping[prop]], prop) + }, + styleInfoFactory: + ({ projectContents, jsxMetadata }) => + (elementPath) => { + const classList = getClassNameAttribute( + getElementFromProjectContents(elementPath, projectContents), + )?.value - return { - gap: parseTailwindProperty(mapping[TailwindPropertyMapping.gap], 'gap'), - flexDirection: parseTailwindProperty( - mapping[TailwindPropertyMapping.flexDirection], - 'flexDirection', - ), - left: parseTailwindProperty(mapping[TailwindPropertyMapping.left], 'left'), - right: parseTailwindProperty(mapping[TailwindPropertyMapping.right], 'right'), - top: parseTailwindProperty(mapping[TailwindPropertyMapping.top], 'top'), - bottom: parseTailwindProperty(mapping[TailwindPropertyMapping.bottom], 'bottom'), - width: parseTailwindProperty(mapping[TailwindPropertyMapping.width], 'width'), - height: parseTailwindProperty(mapping[TailwindPropertyMapping.height], 'height'), - flexBasis: parseTailwindProperty(mapping[TailwindPropertyMapping.flexBasis], 'flexBasis'), - padding: parseTailwindProperty( - underscoresToSpaces(mapping[TailwindPropertyMapping.padding]), - 'padding', - ), - paddingTop: parseTailwindProperty( - mapping[TailwindPropertyMapping.paddingTop], - 'paddingTop', - ), - paddingRight: parseTailwindProperty( - mapping[TailwindPropertyMapping.paddingRight], - 'paddingRight', - ), - paddingBottom: parseTailwindProperty( - mapping[TailwindPropertyMapping.paddingBottom], - 'paddingBottom', - ), - paddingLeft: parseTailwindProperty( - mapping[TailwindPropertyMapping.paddingLeft], - 'paddingLeft', - ), - borderRadius: parseTailwindProperty( - mapping[TailwindPropertyMapping.borderRadius], - 'borderRadius', - ), - borderTopLeftRadius: parseTailwindProperty( - mapping[TailwindPropertyMapping.borderTopLeftRadius], - 'borderTopLeftRadius', - ), - borderTopRightRadius: parseTailwindProperty( - mapping[TailwindPropertyMapping.borderTopRightRadius], - 'borderTopRightRadius', - ), - borderBottomRightRadius: parseTailwindProperty( - mapping[TailwindPropertyMapping.borderBottomRightRadius], - 'borderBottomRightRadius', - ), - borderBottomLeftRadius: parseTailwindProperty( - mapping[TailwindPropertyMapping.borderBottomLeftRadius], - 'borderBottomLeftRadius', - ), - zIndex: parseTailwindProperty(mapping[TailwindPropertyMapping.zIndex], 'zIndex'), - flexWrap: parseTailwindProperty(mapping[TailwindPropertyMapping.flexWrap], 'flexWrap'), - overflow: parseTailwindProperty(mapping[TailwindPropertyMapping.overflow], 'overflow'), - } + if (classList == null || typeof classList !== 'string') { + return null + } + + const mapping = getTailwindClassMapping(classList.split(' '), config) + const parseTailwindProperty = parseTailwindPropertyFactory(config, { + sceneWidth: getContainingSceneWidth(elementPath, jsxMetadata), + }) + return { + gap: parseTailwindProperty(mapping[TailwindPropertyMapping.gap], 'gap'), + flexDirection: parseTailwindProperty( + mapping[TailwindPropertyMapping.flexDirection], + 'flexDirection', + ), + left: parseTailwindProperty(mapping[TailwindPropertyMapping.left], 'left'), + right: parseTailwindProperty(mapping[TailwindPropertyMapping.right], 'right'), + top: parseTailwindProperty(mapping[TailwindPropertyMapping.top], 'top'), + bottom: parseTailwindProperty(mapping[TailwindPropertyMapping.bottom], 'bottom'), + width: parseTailwindProperty(mapping[TailwindPropertyMapping.width], 'width'), + height: parseTailwindProperty(mapping[TailwindPropertyMapping.height], 'height'), + flexBasis: parseTailwindProperty(mapping[TailwindPropertyMapping.flexBasis], 'flexBasis'), + padding: parseTailwindProperty( + underscoresToSpaces(mapping[TailwindPropertyMapping.padding]), + 'padding', + ), + paddingTop: parseTailwindProperty( + mapping[TailwindPropertyMapping.paddingTop], + 'paddingTop', + ), + paddingRight: parseTailwindProperty( + mapping[TailwindPropertyMapping.paddingRight], + 'paddingRight', + ), + paddingBottom: parseTailwindProperty( + mapping[TailwindPropertyMapping.paddingBottom], + 'paddingBottom', + ), + paddingLeft: parseTailwindProperty( + mapping[TailwindPropertyMapping.paddingLeft], + 'paddingLeft', + ), + borderRadius: parseTailwindProperty( + mapping[TailwindPropertyMapping.borderRadius], + 'borderRadius', + ), + borderTopLeftRadius: parseTailwindProperty( + mapping[TailwindPropertyMapping.borderTopLeftRadius], + 'borderTopLeftRadius', + ), + borderTopRightRadius: parseTailwindProperty( + mapping[TailwindPropertyMapping.borderTopRightRadius], + 'borderTopRightRadius', + ), + borderBottomRightRadius: parseTailwindProperty( + mapping[TailwindPropertyMapping.borderBottomRightRadius], + 'borderBottomRightRadius', + ), + borderBottomLeftRadius: parseTailwindProperty( + mapping[TailwindPropertyMapping.borderBottomLeftRadius], + 'borderBottomLeftRadius', + ), + zIndex: parseTailwindProperty(mapping[TailwindPropertyMapping.zIndex], 'zIndex'), + flexWrap: parseTailwindProperty(mapping[TailwindPropertyMapping.flexWrap], 'flexWrap'), + overflow: parseTailwindProperty(mapping[TailwindPropertyMapping.overflow], 'overflow'), + } + }, + updateStyles: (editorState, elementPath, updates) => { + const propsToDelete = mapDropNulls( + (update) => + update.type !== 'delete' || TailwindPropertyMapping[update.property] == null // TODO: make this type-safe + ? null + : UCL.remove(TailwindPropertyMapping[update.property]), + updates, + ) + + const propsToSet = mapDropNulls( + (update) => + update.type !== 'set' || TailwindPropertyMapping[update.property] == null // TODO: make this type-safe + ? null + : UCL.add({ + property: TailwindPropertyMapping[update.property], + value: stringifyPropertyValue(update.value), + }), + updates, + ) + return UCL.runUpdateClassList( + editorState, + elementPath, + [...propsToDelete, ...propsToSet], + config, + { sceneWidth: getContainingSceneWidth(elementPath, editorState.jsxMetadata) }, + ) }, - updateStyles: (editorState, elementPath, updates) => { - const propsToDelete = mapDropNulls( - (update) => - update.type !== 'delete' || TailwindPropertyMapping[update.property] == null // TODO: make this type-safe - ? null - : UCL.remove(TailwindPropertyMapping[update.property]), - updates, - ) + } +} - const propsToSet = mapDropNulls( - (update) => - update.type !== 'set' || TailwindPropertyMapping[update.property] == null // TODO: make this type-safe - ? null - : UCL.add({ - property: TailwindPropertyMapping[update.property], - value: stringifyPropertyValue(update.value), - }), - updates, - ) - return UCL.runUpdateClassList( - editorState, - elementPath, - [...propsToDelete, ...propsToSet], - config, - ) - }, -}) +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' +} 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..a6c7f426a362 --- /dev/null +++ b/editor/src/components/canvas/responsive-utils.spec.ts @@ -0,0 +1,168 @@ +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 { StyleMediaSizeModifier } from './canvas-types' + +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) + } + }) + }) + }) +}) + +describe('extractScreenSizeFromCss', () => { + beforeEach(() => { + // Clear the cache before each test + ;(extractScreenSizeFromCss as any).screenSizeCache?.clear() + }) + + 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('caches null results', () => { + const css = 'invalid-css' + + // First call + const result1 = extractScreenSizeFromCss(css) + // Second call - should return same null reference + const result2 = extractScreenSizeFromCss(css) + + expect(result1).toBe(result2) + expect(result1).toBeNull() + }) + + 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?: StyleMediaSizeModifier[] }[] = [ + { + value: 'b', + modifiers: [{ type: 'media-size', size: { min: { value: 200, unit: 'px' } } }], + }, + { + value: 'a', + modifiers: [{ type: 'media-size', size: { min: { value: 100, unit: 'px' } } }], + }, + { + value: 'c', + modifiers: [{ type: 'media-size', size: { min: { value: 20, unit: 'em' } } }], + }, + { value: 'd' }, + ] + it('selects the correct value', () => { + expect(selectValueByBreakpoint(variants, 150)).toEqual({ + value: 'a', + modifiers: [{ type: 'media-size', size: { min: { value: 100, unit: 'px' } } }], + }) + }) + it('select the closest value', () => { + expect(selectValueByBreakpoint(variants, 250)).toEqual({ + value: 'b', + modifiers: [{ type: 'media-size', size: { min: { value: 200, unit: 'px' } } }], + }) + }) + it('converts em to px', () => { + expect(selectValueByBreakpoint(variants, 350)).toEqual({ + value: 'c', + modifiers: [{ type: 'media-size', size: { min: { value: 20, unit: 'em' } } }], + }) + }) + it('selects the first value if no breakpoint is matched', () => { + expect(selectValueByBreakpoint(variants, 50)).toEqual({ value: 'd' }) + }) + it('selects null if no matching breakpoint and no default value', () => { + expect(selectValueByBreakpoint(variants.slice(0, 2), 50)).toBeNull() + }) + it('selects default value if no media modifiers', () => { + expect( + selectValueByBreakpoint( + [ + { + value: 'a', + modifiers: [{ type: 'hover' }], + }, + { value: 'c' }, + ], + 50, + ), + ).toEqual({ value: 'c' }) + }) +}) diff --git a/editor/src/components/canvas/responsive-utils.ts b/editor/src/components/canvas/responsive-utils.ts new file mode 100644 index 000000000000..dcaf0232b55a --- /dev/null +++ b/editor/src/components/canvas/responsive-utils.ts @@ -0,0 +1,238 @@ +import type { Feature, FeatureRange, MediaQuery, ScreenSize } from './responsive-types' +import * as csstree from 'css-tree' +import type { StyleMediaSizeModifier, StyleInfo, StyleModifier } from './canvas-types' +import { + type CSSNumber, + type CSSNumberUnit, + compValueAsPx, + cssNumber, +} from '../inspector/common/css-utils' +import type { ElementPath } from 'utopia-shared/src/types' +import type { ElementInstanceMetadataMap } from '../../core/shared/element-template' +import { MetadataUtils } from '../../core/model/element-metadata-utils' + +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') { + // 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 + } + } + }) + + // 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 +} + +export function extractMediaQueryFromCss(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 +} + +// Cache for storing previously parsed screen sizes +const screenSizeCache: Map = new Map() +export function extractScreenSizeFromCss(css: string): ScreenSize | null { + // Check cache first + const cached = screenSizeCache.get(css) + if (cached !== undefined) { + return cached + } + + // If not in cache, compute and store result + const mediaQuery = extractMediaQueryFromCss(css) + const result = mediaQuery == null ? null : mediaQueryToScreenSize(mediaQuery) + + screenSizeCache.set(css, result) + 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 + } + // FIXME - we take only the first media modifier for now + 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 = compValueAsPx(mediaModifier.size.max) + const minSizeInPx = compValueAsPx(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 + } + // fixme - select by actual media query precedence + const minSizeInPx = compValueAsPx(variantMediaModifier.size.min) + const chosenMinSizeInPx = compValueAsPx(chosenMediaModifier.size.min) + if (minSizeInPx != null && (chosenMinSizeInPx == null || minSizeInPx > chosenMinSizeInPx)) { + chosen = variant + } + const maxSizeInPx = compValueAsPx(variantMediaModifier.size.max) + const chosenMaxSizeInPx = compValueAsPx(chosenMediaModifier.size.max) + if (maxSizeInPx != null && (chosenMaxSizeInPx == null || maxSizeInPx < chosenMaxSizeInPx)) { + chosen = variant + } + } + if (chosen == null) { + return null + } + return chosen +} + +export function getContainingSceneWidth( + elementPath: ElementPath, + jsxMetadata: ElementInstanceMetadataMap, +): number | undefined { + const containingScene = MetadataUtils.getParentSceneMetadata(jsxMetadata, elementPath) + return containingScene?.specialSizeMeasurements?.clientWidth +} + +export function getAppliedMediaSizeModifierFromBreakpoint( + styleInfo: StyleInfo, + prop: keyof StyleInfo, +): StyleMediaSizeModifier | null { + if (styleInfo == null) { + return null + } + return styleInfo[prop]?.type === 'property' + ? (styleInfo[prop].currentVariant.modifiers ?? []).find((m) => m.type === 'media-size') ?? null + : null +} diff --git a/editor/src/components/inspector/common/css-utils.ts b/editor/src/components/inspector/common/css-utils.ts index 6147a5ce6441..139caa4e84ac 100644 --- a/editor/src/components/inspector/common/css-utils.ts +++ b/editor/src/components/inspector/common/css-utils.ts @@ -5980,3 +5980,8 @@ export function maybeParseGridLine( return null } } + +const EM_TO_PX_RATIO = 16 +export function compValueAsPx(value: CSSNumber | null | undefined): number | null { + return value == null ? null : value.unit === 'em' ? value.value * EM_TO_PX_RATIO : value.value +} diff --git a/editor/src/components/inspector/common/property-path-hooks.ts b/editor/src/components/inspector/common/property-path-hooks.ts index 1c8c719c6911..7392893e7eec 100644 --- a/editor/src/components/inspector/common/property-path-hooks.ts +++ b/editor/src/components/inspector/common/property-path-hooks.ts @@ -96,6 +96,7 @@ import { getActivePlugin } from '../../canvas/plugins/style-plugins' import { isStyleInfoKey, type StyleInfo } from '../../canvas/canvas-types' import { assertNever } from '../../../core/shared/utils' import { maybeCssPropertyFromInlineStyle } from '../../canvas/commands/utils/property-utils' +import { getContainingSceneWidth } from '../../canvas/responsive-utils' export interface InspectorPropsContextData { selectedViews: Array @@ -763,7 +764,13 @@ export function useGetMultiselectedProps

( const styleInfoReaderRef = useRefEditorState( (store) => (props: JSXAttributes, prop: keyof StyleInfo): GetModifiableAttributeResult => { - const elementStyle = getActivePlugin(store.editor).readStyleFromElementProps(props, prop) + const elementStyle = getActivePlugin(store.editor).readStyleFromElementProps(props, prop, { + sceneWidth: getContainingSceneWidth( + // todo: support multiple selected elements + store.editor.selectedViews[0], + store.editor.jsxMetadata, + ), + }) if (elementStyle == null) { return right({ type: 'ATTRIBUTE_NOT_FOUND' }) } diff --git a/editor/src/components/inspector/sections/style-section/container-subsection/padding-row.tsx b/editor/src/components/inspector/sections/style-section/container-subsection/padding-row.tsx index 986035e61e6a..501d9da01d84 100644 --- a/editor/src/components/inspector/sections/style-section/container-subsection/padding-row.tsx +++ b/editor/src/components/inspector/sections/style-section/container-subsection/padding-row.tsx @@ -3,6 +3,7 @@ import { useContextSelector } from 'use-context-selector' import { MetadataUtils } from '../../../../../core/model/element-metadata-utils' import { mapArrayToDictionary } from '../../../../../core/shared/array-utils' import type { PropertyPath } from '../../../../../core/shared/project-file-types' +import type { IcnColor } from '../../../../../uuiui' import { Icons } from '../../../../../uuiui' import { useSetHoveredControlsHandlers } from '../../../../canvas/controls/select-mode/select-mode-hooks' import type { SubduedPaddingControlProps } from '../../../../canvas/controls/select-mode/subdued-padding-control' @@ -47,6 +48,10 @@ import { longhandShorthandEventHandler, splitChainedEventValueForProp, } from '../../layout-section/layout-system-subsection/split-chained-number-input' +import { getActivePlugin } from '../../../../canvas/plugins/style-plugins' +import type { StyleInfo } from '../../../../canvas/canvas-types' +import { getTailwindVariantFromAppliedModifier } from '../../../../canvas/plugins/tailwind-style-plugin-utils/tailwind-responsive-utils' +import { getAppliedMediaSizeModifierFromBreakpoint } from '../../../../canvas/responsive-utils' function buildPaddingPropsToUnset(propertyTarget: ReadonlyArray): Array { return [ @@ -132,6 +137,18 @@ const PaddingControl = React.memo(() => { const shorthand = useInspectorLayoutInfo('padding') const dispatch = useDispatch() + const styleInfo = useRefEditorState((store) => { + const getStyleInfo = getActivePlugin(store.editor).styleInfoFactory({ + projectContents: store.editor.projectContents, + jsxMetadata: store.editor.jsxMetadata, + }) + // TODO: support multiple selected views + return getStyleInfo(store.editor.selectedViews[0]) + }) + const metadataRef = useRefEditorState((store) => store.editor.jsxMetadata) + const pathTreesRef = useRefEditorState((store) => store.editor.elementPathTree) + const breakpointColors = getBreakpointColors(styleInfo.current) + const { selectedViewsRef } = useInspectorContext() const canvasControlsForSides = React.useMemo(() => { @@ -177,8 +194,6 @@ const PaddingControl = React.memo(() => { return shorthand.controlStatus === 'simple' || allUnset }, [allUnset, shorthand.controlStatus]) - const metadataRef = useRefEditorState((store) => store.editor.jsxMetadata) - const pathTreesRef = useRefEditorState((store) => store.editor.elementPathTree) const startingFrame = MetadataUtils.getFrameOrZeroRect( selectedViewsRef.current[0], metadataRef.current, @@ -325,13 +340,54 @@ const PaddingControl = React.memo(() => { return ( , - horizontal: , - vertical: , - top: , - left: , - bottom: , - right: , + oneValue: ( + + ), + horizontal: ( + + ), + vertical: ( + + ), + top: ( + + ), + left: ( + + ), + bottom: ( + + ), + right: ( + + ), }} tooltips={{ oneValue: 'Padding', @@ -349,3 +405,38 @@ const PaddingControl = React.memo(() => { /> ) }) + +function getBreakpointColors(styleInfo: StyleInfo | null) { + function getStyleFromBreakpoint(prop: keyof StyleInfo): { + color: IcnColor + style: React.CSSProperties + tooltipText?: string + } { + if (styleInfo == null) { + return { color: 'on-highlight-secondary', style: {} } + } + const appliedModifier = getAppliedMediaSizeModifierFromBreakpoint(styleInfo, prop) + const fromBreakpoint = getTailwindVariantFromAppliedModifier(appliedModifier) + if (appliedModifier != null) { + return { + color: 'green', + style: { + backgroundColor: '#90ee9063', + borderRadius: '2px', + }, + tooltipText: + fromBreakpoint != null + ? `This value comes from the ':${fromBreakpoint}' breakpoint` + : undefined, + } + } + return { color: 'on-highlight-secondary', style: {} } + } + return { + padding: getStyleFromBreakpoint('padding'), + paddingTop: getStyleFromBreakpoint('paddingTop'), + paddingRight: getStyleFromBreakpoint('paddingRight'), + paddingBottom: getStyleFromBreakpoint('paddingBottom'), + paddingLeft: getStyleFromBreakpoint('paddingLeft'), + } as const +} diff --git a/editor/src/core/model/element-metadata-utils.ts b/editor/src/core/model/element-metadata-utils.ts index 7c3661bbcc77..e9892d1e1c96 100644 --- a/editor/src/core/model/element-metadata-utils.ts +++ b/editor/src/core/model/element-metadata-utils.ts @@ -1061,6 +1061,13 @@ export const MetadataUtils = { const elementMetadata = MetadataUtils.findElementByElementPath(metadata, path) return elementMetadata != null && isSceneFromMetadata(elementMetadata) }, + getParentSceneMetadata( + metadata: ElementInstanceMetadataMap, + path: ElementPath, + ): ElementInstanceMetadata | null { + const parentPath = MetadataUtils.findSceneOfTarget(path, metadata) + return parentPath == null ? null : MetadataUtils.findElementByElementPath(metadata, parentPath) + }, overflows(allElementProps: AllElementProps, path: ElementPath): boolean { const elementProps = allElementProps[EP.toString(path)] ?? {} const styleProps = elementProps.style ?? null diff --git a/editor/src/core/tailwind/tailwind-class-list-utils.spec.ts b/editor/src/core/tailwind/tailwind-class-list-utils.spec.ts index ca8816febed2..5a149b28ff06 100644 --- a/editor/src/core/tailwind/tailwind-class-list-utils.spec.ts +++ b/editor/src/core/tailwind/tailwind-class-list-utils.spec.ts @@ -141,7 +141,10 @@ describe('tailwind class list utils', () => { describe('removing classes', () => { it('can remove property', () => { const classList = getParsedClassList('p-4 m-2 text-white w-4 flex flex-row', null) - const updatedClassList = removeClasses(['padding', 'textColor'])(classList) + const updatedClassList = removeClasses({ + padding: { remove: true, modifiers: [] }, + textColor: { remove: true, modifiers: [] }, + })(classList) expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( `"m-2 w-4 flex flex-row"`, ) @@ -151,7 +154,10 @@ describe('tailwind class list utils', () => { 'p-4 m-2 text-white hover:text-red-100 w-4 flex flex-row', null, ) - const updatedClassList = removeClasses(['padding', 'textColor'])(classList) + const updatedClassList = removeClasses({ + padding: { remove: true, modifiers: [] }, + textColor: { remove: true, modifiers: [] }, + })(classList) expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( `"m-2 hover:text-red-100 w-4 flex flex-row"`, ) @@ -162,8 +168,8 @@ describe('tailwind class list utils', () => { it('can update class in class list', () => { const classList = getParsedClassList('p-4 m-2 text-white w-4 flex flex-row', null) const updatedClassList = updateExistingClasses({ - flexDirection: 'column', - width: '23px', + flexDirection: { newValue: 'column', modifiers: [] }, + width: { newValue: '23px', modifiers: [] }, })(classList) expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( `"p-4 m-2 text-white w-[23px] flex flex-col"`, @@ -171,7 +177,9 @@ describe('tailwind class list utils', () => { }) it('does not remove property with selector', () => { const classList = getParsedClassList('p-4 hover:p-6 m-2 text-white w-4 flex flex-row', null) - const updatedClassList = updateExistingClasses({ padding: '8rem' })(classList) + const updatedClassList = updateExistingClasses({ + padding: { newValue: '8rem', modifiers: [] }, + })(classList) expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( `"p-32 hover:p-6 m-2 text-white w-4 flex flex-row"`, ) @@ -182,9 +190,9 @@ describe('tailwind class list utils', () => { it('can add new class to class list', () => { const classList = getParsedClassList('p-4 m-2 text-white w-4 flex flex-row', null) const updatedClassList = addNewClasses({ - backgroundColor: 'white', - justifyContent: 'space-between', - positionLeft: '-20px', + backgroundColor: { newValue: 'white', modifiers: [] }, + justifyContent: { newValue: 'space-between', modifiers: [] }, + positionLeft: { newValue: '-20px', modifiers: [] }, })(classList) expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( `"p-4 m-2 text-white w-4 flex flex-row bg-white justify-between -left-[20px]"`, diff --git a/editor/src/core/tailwind/tailwind-class-list-utils.ts b/editor/src/core/tailwind/tailwind-class-list-utils.ts index fd6152c79f32..aa191ad779e3 100644 --- a/editor/src/core/tailwind/tailwind-class-list-utils.ts +++ b/editor/src/core/tailwind/tailwind-class-list-utils.ts @@ -1,11 +1,12 @@ import * as TailwindClassParser from '@xengine/tailwindcss-class-parser' import type { Config } from 'tailwindcss/types/config' import { mapDropNulls } from '../shared/array-utils' +import type { StyleMediaSizeModifier, StyleModifier } from '../../components/canvas/canvas-types' export type ParsedTailwindClass = { property: string value: string - variants: unknown[] + variants: { type: string; value: string }[] negative: boolean } & Record @@ -49,7 +50,14 @@ export type ClassListTransform = ( ) => TailwindClassParserResult[] export interface PropertiesToUpdate { - [property: string]: string + [property: string]: { newValue: string; modifiers: StyleModifier[] } +} + +export interface PropertiesToRemove { + [property: string]: { + remove: boolean + modifiers: StyleModifier[] + } } export const addNewClasses = @@ -60,12 +68,12 @@ export const addNewClasses = ) const newClasses: TailwindClassParserResult[] = mapDropNulls( - ([prop, value]) => + ([prop, update]) => existingProperties.has(prop) ? null : { type: 'parsed', - ast: { property: prop, value: value, variants: [], negative: false }, + ast: { property: prop, value: update.newValue, variants: [], negative: false }, }, Object.entries(propertiesToAdd), ) @@ -78,30 +86,79 @@ export const updateExistingClasses = (propertiesToUpdate: PropertiesToUpdate): ClassListTransform => (parsedClassList: TailwindClassParserResult[]) => { const classListWithUpdatedClasses: TailwindClassParserResult[] = parsedClassList.map((cls) => { - if (cls.type !== 'parsed' || cls.ast.variants.length > 0) { - return cls - } - const updatedProperty = propertiesToUpdate[cls.ast.property] - if (updatedProperty == null) { + if (cls.type !== 'parsed' || !shouldUpdateClass(cls, propertiesToUpdate)) { return cls } + const propertyToUpdate = propertiesToUpdate[cls.ast.property] return { type: 'parsed', - ast: { property: cls.ast.property, value: updatedProperty, variants: [], negative: false }, + ast: { + property: cls.ast.property, + value: propertyToUpdate.newValue, + variants: cls.ast.variants, + negative: false, + }, } }) return classListWithUpdatedClasses } export const removeClasses = - (propertiesToRemove: string[]): ClassListTransform => + (propertiesToRemove: PropertiesToRemove): ClassListTransform => (parsedClassList: TailwindClassParserResult[]) => { - const propertiesToRemoveSet = new Set(propertiesToRemove) const classListWithRemovedClasses = parsedClassList.filter((cls) => { - if (cls.type !== 'parsed' || cls.ast.variants.length > 0) { + if (cls.type !== 'parsed' || !shouldUpdateClass(cls, propertiesToRemove)) { return cls } - return !propertiesToRemoveSet.has(cls.ast.property) + return !propertiesToRemove[cls.ast.property]?.remove }) return classListWithRemovedClasses } + +function getTailwindSizeVariant(modifiers: StyleModifier[]): string | null { + const mediaModifier = modifiers.find((m): m is StyleMediaSizeModifier => m.type === 'media-size') + if (mediaModifier == null) { + return null + } + if (mediaModifier.modifierOrigin?.type !== 'tailwind') { + return null + } + return mediaModifier.modifierOrigin.variant +} + +function shouldUpdateClass( + cls: TailwindClassParserResult, + propertiesToUpdate: PropertiesToUpdate | PropertiesToRemove, +): boolean { + if (cls.type !== 'parsed') { + return false + } + const propertyToUpdate = propertiesToUpdate[cls.ast.property] + if (propertyToUpdate == null) { + // this property is not in the list + return false + } + const sizeVariantToUpdate = getTailwindSizeVariant(propertyToUpdate.modifiers) + if ( + sizeVariantToUpdate == null && + cls.ast.variants.filter((v) => v.type === 'media').length > 0 + ) { + // we need to update the default property value but this class has size variants + return false + } + if ( + sizeVariantToUpdate != null && + !variantsHasMediaSizeVariant(cls.ast.variants, sizeVariantToUpdate) + ) { + // we need to update a specific size variant but this class doesn't have it + return false + } + return true +} + +function variantsHasMediaSizeVariant( + variants: { type: string; value: string }[], + sizeVariant: string, +): boolean { + return variants.some((v) => v.type === 'media' && v.value === sizeVariant) +}