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