Skip to content

Commit

Permalink
Style plugin-aware useInspectorInfo (#6655)
Browse files Browse the repository at this point in the history
## Problem
`useInspectorInfo`, a widely used helper in the inspector, cannot read
element styles set by Tailwind

## Fix
The fix is made up from multiple pieces:
- I extended `StylePlugin` with a new function,
`readStyleFromElementProps`, which reads a given style info key from the
`JSXAttributes` passed to it. I updated both `InlineStylePlugin` and
`TailwindStylePlugin` to support this function, and updated all affected
tests. This change was originally prototyped on the
[Megaspike](#6574)
- I updated `useGetMultiselectedProps` to use
`StylePlugin.readStyleFromElementProps` when a style prop is read.
- I updated `CSSStylePropertyNotParsable` and `ParsedCSSStyleProperty`
to preserve the original value read from `projectContents`, in addition
to the parsed representation. This way, `CSSStyleProperties` can be used
by code that expect to work with this lower-level representation (such
as the internals of `useInspectorInfo`)
- I updated the `SET_PROP` and `UNSET_PROP` actions to use the
`setProperty` and `deleteProperty` commands under the hood. This way,
any editor code using these actions will be able to use the style
plugins to write element style. This change was originally prototyped on
the [Megaspike](#6574)

### Out of scope
This change only touches `useGetMultiselectedProps` from the internals
of `useInspectorInfo`. `useGetSpiedProps` is left unchanged, since the
values element style props set through Tailwind don't show up in
`allElementProps` (which is the data source for spied props)

## 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
bkrmendy authored Nov 20, 2024
1 parent dca813f commit 21024fe
Show file tree
Hide file tree
Showing 8 changed files with 182 additions and 101 deletions.
16 changes: 12 additions & 4 deletions editor/src/components/canvas/canvas-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactElement } from 'react'
import type { JSExpression, PartOfJSXAttributeValue } from '../../core/shared/element-template'
import { ElementInstanceMetadataMap } from '../../core/shared/element-template'
import type { PropertyPath, ElementPath } from '../../core/shared/project-file-types'
import type { KeysPressed } from '../../utils/keyboard'
Expand Down Expand Up @@ -543,11 +544,13 @@ interface CSSStylePropertyNotFound {

interface CSSStylePropertyNotParsable {
type: 'not-parsable'
originalValue: JSExpression | PartOfJSXAttributeValue
}

interface ParsedCSSStyleProperty<T> {
type: 'property'
tags: PropertyTag[]
propertyValue: JSExpression | PartOfJSXAttributeValue
value: T
}

Expand All @@ -560,12 +563,17 @@ export function cssStylePropertyNotFound(): CSSStylePropertyNotFound {
return { type: 'not-found' }
}

export function cssStylePropertyNotParsable(): CSSStylePropertyNotParsable {
return { type: 'not-parsable' }
export function cssStylePropertyNotParsable(
originalValue: JSExpression | PartOfJSXAttributeValue,
): CSSStylePropertyNotParsable {
return { type: 'not-parsable', originalValue: originalValue }
}

export function cssStyleProperty<T>(value: T): ParsedCSSStyleProperty<T> {
return { type: 'property', tags: [], value: value }
export function cssStyleProperty<T>(
value: T,
propertyValue: JSExpression | PartOfJSXAttributeValue,
): ParsedCSSStyleProperty<T> {
return { type: 'property', tags: [], value: value, propertyValue: propertyValue }
}

export function maybePropertyValue<T>(property: CSSStyleProperty<T>): T | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { modifyUnderlyingElementForOpenFile } from '../../../editor/store/editor
import { patchParseSuccessAtElementPath } from '../patch-utils'
import type { CSSNumber } from '../../../inspector/common/css-utils'
import { isCSSNumber } from '../../../inspector/common/css-utils'
import { type StyleInfo, isStyleInfoKey } from '../../canvas-types'
import type { StyleInfo } from '../../canvas-types'

export interface EditorStateWithPatch {
editorStateWithChanges: EditorState
Expand Down
29 changes: 21 additions & 8 deletions editor/src/components/canvas/plugins/inline-style-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import * as EP from '../../../core/shared/element-path'
import { cssNumber } from '../../inspector/common/css-utils'
import {
cssStyleProperty,
cssStylePropertyNotFound,
cssStylePropertyNotParsable,
} from '../canvas-types'
import { cssStylePropertyNotFound } from '../canvas-types'
import type { EditorRenderResult } from '../ui-jsx.test-utils'
import { renderTestEditorWithCode } from '../ui-jsx.test-utils'
import { InlineStylePlugin } from './inline-style-plugin'
Expand Down Expand Up @@ -45,8 +41,18 @@ export var storyboard = (

expect(styleInfo).not.toBeNull()
const { flexDirection, gap } = styleInfo!
expect(flexDirection).toEqual(cssStyleProperty('column'))
expect(gap).toEqual(cssStyleProperty(cssNumber(2, 'rem')))
expect(flexDirection).toMatchObject({
type: 'property',
tags: [],
value: 'column',
propertyValue: { value: 'column' },
})
expect(gap).toMatchObject({
type: 'property',
tags: [],
value: cssNumber(2, 'rem'),
propertyValue: { value: '2rem' },
})
})

it('can parse style info with missing/unparsable props', async () => {
Expand Down Expand Up @@ -88,7 +94,14 @@ export var storyboard = (
expect(styleInfo).not.toBeNull()
const { flexDirection, gap } = styleInfo!
expect(flexDirection).toEqual(cssStylePropertyNotFound())
expect(gap).toEqual(cssStylePropertyNotParsable())
expect(gap).toMatchObject({
type: 'not-parsable',
originalValue: {
type: 'JS_PROPERTY_ACCESS',
onValue: { type: 'JS_IDENTIFIER', name: 'gap' },
property: 'small',
},
})
})
})

Expand Down
17 changes: 11 additions & 6 deletions editor/src/components/canvas/plugins/inline-style-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { JSXAttributes, PropertyPath } from 'utopia-shared/src/types'
import type { StyleLayoutProp } from '../../../core/layout/layout-helpers-new'
import * as Either from '../../../core/shared/either'
import {
getJSXAttributesAtPath,
Expand All @@ -9,7 +8,7 @@ import type { ModifiableAttribute } from '../../../core/shared/jsx-attributes'
import { getJSXElementFromProjectContents } from '../../editor/store/editor-state'
import { cssParsers, type ParsedCSSProperties } from '../../inspector/common/css-utils'
import { stylePropPathMappingFn } from '../../inspector/common/property-path-hooks'
import type { CSSStyleProperty } from '../canvas-types'
import type { CSSStyleProperty, StyleInfo } from '../canvas-types'
import {
cssStyleProperty,
cssStylePropertyNotParsable,
Expand All @@ -29,7 +28,7 @@ function getPropValue(attributes: JSXAttributes, path: PropertyPath): Modifiable
return result.attribute
}

function getPropertyFromInstance<P extends StyleLayoutProp, T = ParsedCSSProperties[P]>(
function getPropertyFromInstance<P extends keyof StyleInfo, T = ParsedCSSProperties[P]>(
prop: P,
attributes: JSXAttributes,
): CSSStyleProperty<NonNullable<T>> | null {
Expand All @@ -39,18 +38,24 @@ function getPropertyFromInstance<P extends StyleLayoutProp, T = ParsedCSSPropert
}
const simpleValue = jsxSimpleAttributeToValue(attribute)
if (Either.isLeft(simpleValue)) {
return cssStylePropertyNotParsable()
return cssStylePropertyNotParsable(attribute)
}
const parser = cssParsers[prop] as (value: unknown) => Either.Either<string, T>
const parsed = parser(simpleValue.value)
if (Either.isLeft(parsed) || parsed.value == null) {
return cssStylePropertyNotParsable()
return cssStylePropertyNotParsable(attribute)
}
return cssStyleProperty(parsed.value)
return cssStyleProperty(parsed.value, attribute)
}

export const InlineStylePlugin: StylePlugin = {
name: 'Inline Style',
readStyleFromElementProps: <T extends keyof StyleInfo>(
attributes: JSXAttributes,
prop: T,
): CSSStyleProperty<NonNullable<ParsedCSSProperties[T]>> | null => {
return getPropertyFromInstance(prop, attributes)
},
styleInfoFactory:
({ projectContents }) =>
(elementPath) => {
Expand Down
8 changes: 7 additions & 1 deletion editor/src/components/canvas/plugins/style-plugins.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ElementPath } from 'utopia-shared/src/types'
import type { ElementPath, JSXAttributes } from 'utopia-shared/src/types'
import type { EditorState, EditorStatePatch } from '../../editor/store/editor-state'
import type {
InteractionLifecycle,
Expand All @@ -18,7 +18,9 @@ import type { EditorStateWithPatch } from '../commands/utils/property-utils'
import { applyValuesAtPath } from '../commands/utils/property-utils'
import * as PP from '../../../core/shared/property-path'
import { emptyComments, jsExpressionValue } from '../../../core/shared/element-template'
import type { CSSStyleProperty } from '../canvas-types'
import { isStyleInfoKey, type StyleInfo } from '../canvas-types'
import type { ParsedCSSProperties } from '../../inspector/common/css-utils'

export interface UpdateCSSProp {
type: 'set'
Expand Down Expand Up @@ -51,6 +53,10 @@ export type StyleUpdate = UpdateCSSProp | DeleteCSSProp
export interface StylePlugin {
name: string
styleInfoFactory: StyleInfoFactory
readStyleFromElementProps: <T extends keyof StyleInfo>(
attributes: JSXAttributes,
prop: T,
) => CSSStyleProperty<NonNullable<ParsedCSSProperties[T]>> | null
updateStyles: (
editorState: EditorState,
elementPath: ElementPath,
Expand Down
82 changes: 53 additions & 29 deletions editor/src/components/canvas/plugins/tailwind-style-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
import * as TailwindClassParser from '@xengine/tailwindcss-class-parser'
import { isLeft } from '../../../core/shared/either'
import { defaultEither, flatMapEither, isLeft } from '../../../core/shared/either'
import { getClassNameAttribute } from '../../../core/tailwind/tailwind-options'
import { getElementFromProjectContents } from '../../editor/store/editor-state'
import type { Parser } from '../../inspector/common/css-utils'
import type { ParsedCSSProperties, Parser } 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 { Config } from 'tailwindcss/types/config'
import { cssStyleProperty, type CSSStyleProperty } from '../canvas-types'
import type { StyleInfo } from '../canvas-types'
import { cssStyleProperty, isStyleInfoKey, type CSSStyleProperty } from '../canvas-types'
import * as UCL from './tailwind-style-plugin-utils/update-class-list'
import { assertNever } from '../../../core/shared/utils'
import {
jsxSimpleAttributeToValue,
getModifiableJSXAttributeAtPath,
} from '../../../core/shared/jsx-attribute-utils'
import { emptyComments, type JSXAttributes } from 'utopia-shared/src/types'
import * as PP from '../../../core/shared/property-path'
import { jsExpressionValue } from '../../../core/shared/element-template'

function parseTailwindProperty<T>(
value: unknown,
parse: Parser<T>,
): CSSStyleProperty<NonNullable<T>> | null {
const parsed = parse(value, null)
function parseTailwindProperty<T extends keyof StyleInfo>(
value: string | number | undefined,
prop: T,
): CSSStyleProperty<NonNullable<ParsedCSSProperties[T]>> | null {
const parsed = cssParsers[prop](value, null)
if (isLeft(parsed) || parsed.value == null) {
return null
}
return cssStyleProperty(parsed.value)
return cssStyleProperty(parsed.value, jsExpressionValue(value, emptyComments))
}

const TailwindPropertyMapping: Record<string, string> = {
Expand Down Expand Up @@ -81,6 +89,25 @@ const underscoresToSpaces = (s: string | undefined) => s?.replace(/[-_]/g, ' ')

export const TailwindPlugin = (config: Config | null): StylePlugin => ({
name: 'Tailwind',
readStyleFromElementProps: <P extends keyof StyleInfo>(
attributes: JSXAttributes,
prop: P,
): CSSStyleProperty<NonNullable<ParsedCSSProperties[P]>> | null => {
const classNameAttribute = defaultEither(
null,
flatMapEither(
(attr) => jsxSimpleAttributeToValue(attr),
getModifiableJSXAttributeAtPath(attributes, PP.create('className')),
),
)

if (typeof classNameAttribute !== 'string') {
return null
}

const mapping = getTailwindClassMapping(classNameAttribute.split(' '), config)
return parseTailwindProperty(mapping[TailwindPropertyMapping[prop]], prop)
},
styleInfoFactory:
({ projectContents }) =>
(elementPath) => {
Expand All @@ -95,56 +122,53 @@ export const TailwindPlugin = (config: Config | null): StylePlugin => ({
const mapping = getTailwindClassMapping(classList.split(' '), config)

return {
gap: parseTailwindProperty(mapping[TailwindPropertyMapping.gap], cssParsers.gap),
gap: parseTailwindProperty(mapping[TailwindPropertyMapping.gap], 'gap'),
flexDirection: parseTailwindProperty(
mapping[TailwindPropertyMapping.flexDirection],
cssParsers.flexDirection,
),
left: parseTailwindProperty(mapping[TailwindPropertyMapping.left], cssParsers.left),
right: parseTailwindProperty(mapping[TailwindPropertyMapping.right], cssParsers.right),
top: parseTailwindProperty(mapping[TailwindPropertyMapping.top], cssParsers.top),
bottom: parseTailwindProperty(mapping[TailwindPropertyMapping.bottom], cssParsers.bottom),
width: parseTailwindProperty(mapping[TailwindPropertyMapping.width], cssParsers.width),
height: parseTailwindProperty(mapping[TailwindPropertyMapping.height], cssParsers.height),
flexBasis: parseTailwindProperty(
mapping[TailwindPropertyMapping.flexBasis],
cssParsers.flexBasis,
'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]),
cssParsers.padding,
'padding',
),
paddingTop: parseTailwindProperty(
mapping[TailwindPropertyMapping.paddingTop],
cssParsers.paddingTop,
'paddingTop',
),
paddingRight: parseTailwindProperty(
mapping[TailwindPropertyMapping.paddingRight],
cssParsers.paddingRight,
'paddingRight',
),
paddingBottom: parseTailwindProperty(
mapping[TailwindPropertyMapping.paddingBottom],
cssParsers.paddingBottom,
'paddingBottom',
),
paddingLeft: parseTailwindProperty(
mapping[TailwindPropertyMapping.paddingLeft],
cssParsers.paddingLeft,
'paddingLeft',
),
zIndex: parseTailwindProperty(mapping[TailwindPropertyMapping.zIndex], cssParsers.zIndex),
zIndex: parseTailwindProperty(mapping[TailwindPropertyMapping.zIndex], 'zIndex'),
}
},
updateStyles: (editorState, elementPath, updates) => {
const propsToDelete = mapDropNulls(
(update) =>
update.type !== 'delete' || TailwindPropertyMapping[update.property] == null
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
update.type !== 'set' || TailwindPropertyMapping[update.property] == null // TODO: make this type-safe
? null
: UCL.add({
property: TailwindPropertyMapping[update.property],
Expand Down
Loading

0 comments on commit 21024fe

Please sign in to comment.