Skip to content

Commit

Permalink
feat(editor): update correct breakpoint value (#6724)
Browse files Browse the repository at this point in the history
This PR adds the functionality of updating the correct breakpoint value
in Tailwind (`lg-`, `md-`, etc) according to the current Scene size.

**Details:**
- We're checking the current Scene size to see if the element has some
variant for this prop that matches it (we're choosing the best one if
there are several), and updating only this value
- If there are no variants or none matching, we update the default value
(no breakpoint)
- We're currently updating the value per-breakpoint **only** if it was
defined per-breakpoint in the first place (for example via the code
editor, with prefix)
- The main change is building a map of each property to the modifiers
that are *currenty* affecting it -
(`getPropertiesToAppliedModifiersMap()`) - so that the property updating
methods (that already exist in
[tailwind-class-list-utils.ts](https://github.com/concrete-utopia/utopia/compare/feat/update-breakpoint-value?expand=1#diff-a2fc3b378fc847952f33e12c787ded13ff87a7c1f4dc0e3154b73b976117f9f5))
could use this information to understand *which* variant they need to
update or remove (instead of always changing the default one as it was
until now).

Example:
(Note - in this PR the controls **are not** indicated in green, this is
left for future design decisions)
<video
src="https://github.com/user-attachments/assets/40815423-61e6-4f00-8a19-6d7cfc0d3bc3"></video>

**Manual Tests:**
I hereby swear that:

- [X] I opened a hydrogen project and it loaded
- [X] I could navigate to various routes in Play mode
  • Loading branch information
liady authored Dec 16, 2024
1 parent c0c4752 commit 5e3adee
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 33 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { Config } from 'tailwindcss/types/config'
import { type StyleMediaSizeModifier, type StyleModifier } from '../../canvas-types'
import { isStyleInfoKey, type StyleMediaSizeModifier, type StyleModifier } from '../../canvas-types'
import type { ScreenSize } from '../../responsive-types'
import { extractScreenSizeFromCss } from '../../responsive-utils'
import { mapDropNulls } from '../../../../core/shared/array-utils'
import { getTailwindClassMapping, TailwindPropertyMapping } from '../tailwind-style-plugin'
import { parseTailwindPropertyFactory } from '../tailwind-style-plugin'
import type { StylePluginContext } from '../style-plugins'

export const TAILWIND_DEFAULT_SCREENS = {
sm: '640px',
Expand Down Expand Up @@ -73,3 +76,38 @@ export function getModifiers(
})
.filter((m): m is StyleMediaSizeModifier => m != null)
}

export function getPropertiesToAppliedModifiersMap(
currentClassNameAttribute: string,
propertyNames: string[],
config: Config | null,
context: StylePluginContext,
): Record<string, StyleModifier[]> {
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<string, StyleModifier[]>)
}

export function getTailwindVariantFromAppliedModifier(
appliedModifier: StyleMediaSizeModifier | null,
): string | null {
return appliedModifier?.modifierOrigin?.type === 'tailwind'
? appliedModifier.modifierOrigin.variant
: null
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,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,
Expand All @@ -17,6 +20,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 type { StylePluginContext } from '../style-plugins'
import { getPropertiesToAppliedModifiersMap } from './tailwind-responsive-utils'

export type ClassListUpdate =
| { type: 'add'; property: string; value: string }
Expand All @@ -37,27 +42,54 @@ export const runUpdateClassList = (
element: ElementPath,
classNameUpdates: ClassListUpdate[],
config: Config | null,
context: StylePluginContext,
): EditorStateWithPatch => {
const currentClassNameAttribute =
getClassNameAttribute(getElementFromProjectContents(element, editorState.projectContents))
?.value ?? ''

// this will map every property to the modifiers that are currently affecting it
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]: {
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)

Expand Down
52 changes: 48 additions & 4 deletions editor/src/components/canvas/plugins/tailwind-style-plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { TailwindPlugin } from './tailwind-style-plugin'
import { createModifiedProject } from '../../../sample-projects/sample-project-utils.test-utils'
import { TailwindConfigPath } from '../../../core/tailwind/tailwind-config'
import { getTailwindConfigCached } from '../../../core/tailwind/tailwind-compilation'
import * as StylePlugins from './style-plugins'

const Project = createModifiedProject({
[StoryboardFilePath]: `
Expand All @@ -30,7 +31,7 @@ export var storyboard = (
>
<div
data-uid='div'
className='flex flex-row gap-[12px]'
className='flex flex-row gap-[12px] pt-[10px] lg:pt-[20px]'
/>
</Scene>
</Storyboard>
Expand All @@ -40,13 +41,16 @@ export var storyboard = (
[TailwindConfigPath]: `
const TailwindConfig = {
content: [],
theme: { extend: { gap: { enormous: '222px' } } }
theme: { extend: { gap: { enormous: '222px' }, screens: { lg: '500px' } } }
}
export default TailwindConfig
`,
})

describe('tailwind style plugin', () => {
afterAll(() => {
jest.restoreAllMocks()
})
it('can set Tailwind class', async () => {
const editor = await renderTestEditorWithModel(Project, 'await-first-dom-report')
const target = EP.fromString('sb/scene/div')
Expand All @@ -64,7 +68,7 @@ describe('tailwind style plugin', () => {
updatedEditor.editorStateWithChanges.projectContents,
)!
expect(formatJSXAttributes(normalizedElement.props)).toEqual({
className: 'flex flex-col gap-[222px]',
className: 'flex flex-col gap-[222px] pt-[10px] lg:pt-[20px]',
'data-uid': 'div',
})
})
Expand All @@ -84,7 +88,47 @@ describe('tailwind style plugin', () => {
updatedEditor.editorStateWithChanges.projectContents,
)!
expect(formatJSXAttributes(normalizedElement.props)).toEqual({
className: 'flex flex-col gap-enormous',
className: 'flex flex-col gap-enormous pt-[10px] lg:pt-[20px]',
'data-uid': 'div',
})
})

it('can set Tailwind class with size modifier and a custom config', async () => {
// this is done since we don't calculate the scene size in the test
jest.spyOn(StylePlugins, 'sceneSize').mockReturnValue({ type: 'scene', width: 700 })
const editor = await renderTestEditorWithModel(Project, 'await-first-dom-report')
const target = EP.fromString('sb/scene/div')
const updatedEditor = TailwindPlugin(
getTailwindConfigCached(editor.getEditorState().editor),
).updateStyles(editor.getEditorState().editor, target, [
{ type: 'set', property: 'paddingTop', value: '200px' },
])
const normalizedElement = getJSXElementFromProjectContents(
target,
updatedEditor.editorStateWithChanges.projectContents,
)!
expect(formatJSXAttributes(normalizedElement.props)).toEqual({
className: 'flex flex-row gap-[12px] pt-[10px] lg:pt-[200px]',
'data-uid': 'div',
})
})

it('can remove Tailwind class with size modifier and a custom config', async () => {
// this is done since we don't calculate the scene size in the test
jest.spyOn(StylePlugins, 'sceneSize').mockReturnValue({ type: 'scene', width: 700 })
const editor = await renderTestEditorWithModel(Project, 'await-first-dom-report')
const target = EP.fromString('sb/scene/div')
const updatedEditor = TailwindPlugin(
getTailwindConfigCached(editor.getEditorState().editor),
).updateStyles(editor.getEditorState().editor, target, [
{ type: 'delete', property: 'paddingTop' },
])
const normalizedElement = getJSXElementFromProjectContents(
target,
updatedEditor.editorStateWithChanges.projectContents,
)!
expect(formatJSXAttributes(normalizedElement.props)).toEqual({
className: 'flex flex-row gap-[12px] pt-[10px]',
'data-uid': 'div',
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ export const TailwindPlugin = (config: Config | null): StylePlugin => ({
elementPath,
[...propsToDelete, ...propsToSet],
config,
{ sceneSize: getContainingSceneSize(elementPath, editorState.jsxMetadata) },
)
},
})
Expand Down
24 changes: 16 additions & 8 deletions editor/src/core/tailwind/tailwind-class-list-utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: { modifiers: [] },
textColor: { modifiers: [] },
})(classList)
expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot(
`"m-2 w-4 flex flex-row"`,
)
Expand All @@ -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: { modifiers: [] },
textColor: { modifiers: [] },
})(classList)
expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot(
`"m-2 hover:text-red-100 w-4 flex flex-row"`,
)
Expand All @@ -162,16 +168,18 @@ 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"`,
)
})
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"`,
)
Expand All @@ -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]"`,
Expand Down
Loading

0 comments on commit 5e3adee

Please sign in to comment.