Skip to content

Commit

Permalink
more granular unparsable prop detection for StyleInfo (#6631)
Browse files Browse the repository at this point in the history
## Problem
The `StyleInfo` type doesn't express the state when a property is set,
but it cannot parsed in the desired format. For example, in `style={{
top: '300px', left: theme.left + 50 }}`, `top` can be parsed as a css
number, but left cannot be.

We need to be able to express this, since some code
(`AdjustCssLengthProperties` and `SetCSSLength` for example) need to
handle it. Once `AdjustCssLengthProperties` and `SetCSSLength` use this
infra, we can make more strategies Tailwind-compatible.

## Fix
Extend the `StyleInfo` type, and fix any affected code. 

**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 12, 2024
1 parent 07b81de commit a668e00
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 39 deletions.
44 changes: 36 additions & 8 deletions editor/src/components/canvas/canvas-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,19 +537,47 @@ export type SelectionLocked = 'locked' | 'locked-hierarchy' | 'selectable'

export type PropertyTag = { type: 'hover' } | { type: 'breakpoint'; name: string }

export interface WithPropertyTag<T> {
tag: PropertyTag | null
interface CSSStylePropertyNotFound {
type: 'not-found'
}

interface CSSStylePropertyNotParsable {
type: 'not-parsable'
}

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

export const withPropertyTag = <T>(value: T): WithPropertyTag<T> => ({
tag: null,
value: value,
})
export type CSSStyleProperty<T> =
| CSSStylePropertyNotFound
| CSSStylePropertyNotParsable
| ParsedCSSStyleProperty<T>

export function cssStylePropertyNotFound(): CSSStylePropertyNotFound {
return { type: 'not-found' }
}

export function cssStylePropertyNotParsable(): CSSStylePropertyNotParsable {
return { type: 'not-parsable' }
}

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

export function maybePropertyValue<T>(property: CSSStyleProperty<T>): T | null {
if (property.type === 'property') {
return property.value
}
return null
}

export type FlexGapInfo = WithPropertyTag<CSSNumber>
export type FlexGapInfo = CSSStyleProperty<CSSNumber>

export type FlexDirectionInfo = WithPropertyTag<FlexDirection>
export type FlexDirectionInfo = CSSStyleProperty<FlexDirection>

export interface StyleInfo {
gap: FlexGapInfo | null
Expand Down
7 changes: 4 additions & 3 deletions editor/src/components/canvas/gap-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { canvasRectangle, isInfinityRectangle } from '../../core/shared/math-uti
import type { ElementPath } from '../../core/shared/project-file-types'
import { assertNever } from '../../core/shared/utils'
import type { StyleInfo } from './canvas-types'
import { CSSCursor } from './canvas-types'
import { CSSCursor, maybePropertyValue } from './canvas-types'
import type { CSSNumberWithRenderedValue } from './controls/select-mode/controls-common'
import type { CSSNumber, FlexDirection } from '../inspector/common/css-utils'
import type { Sides } from 'utopia-api/core'
Expand All @@ -34,6 +34,7 @@ import { treatElementAsFragmentLike } from './canvas-strategies/strategies/fragm
import type { AllElementProps } from '../editor/store/editor-state'
import type { GridData } from './controls/grid-controls-for-strategies'
import { getNullableAutoOrTemplateBaseString } from './controls/grid-controls-for-strategies'
import { optionalMap } from '../../core/shared/optional-utils'

export interface PathWithBounds {
bounds: CanvasRectangle
Expand Down Expand Up @@ -238,14 +239,14 @@ export function maybeFlexGapData(
}

const gap = element.specialSizeMeasurements.gap ?? 0
const gapFromReader = info.gap?.value
const gapFromReader = optionalMap(maybePropertyValue, info.gap)

const flexDirection = element.specialSizeMeasurements.flexDirection ?? 'row'

return {
value: {
renderedValuePx: gap,
value: gapFromReader ?? null,
value: gapFromReader,
},
direction: flexDirection,
}
Expand Down
105 changes: 105 additions & 0 deletions editor/src/components/canvas/plugins/inline-style-plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as EP from '../../../core/shared/element-path'
import { cssNumber } from '../../inspector/common/css-utils'
import {
cssStyleProperty,
cssStylePropertyNotFound,
cssStylePropertyNotParsable,
} from '../canvas-types'
import type { EditorRenderResult } from '../ui-jsx.test-utils'
import { renderTestEditorWithCode } from '../ui-jsx.test-utils'
import { InlineStylePlugin } from './inline-style-plugin'

describe('inline style plugin', () => {
it('can parse style info from element', async () => {
const editor = await renderTestEditorWithCode(
`
import React from 'react'
import { Scene, Storyboard } from 'utopia-api'
export var storyboard = (
<Storyboard data-uid='sb'>
<Scene
id='scene'
commentId='scene'
data-uid='scene'
style={{
width: 700,
height: 759,
position: 'absolute',
left: 212,
top: 128,
}}
>
<div
data-uid='div'
style={{ display: 'flex', flexDirection: 'column', gap: '2rem'}}
/>
</Scene>
</Storyboard>
)
`,
'await-first-dom-report',
)

const styleInfo = getStyleInfoFromInlineStyle(editor)

expect(styleInfo).not.toBeNull()
const { flexDirection, gap } = styleInfo!
expect(flexDirection).toEqual(cssStyleProperty('column'))
expect(gap).toEqual(cssStyleProperty(cssNumber(2, 'rem')))
})

it('can parse style info with missing/unparsable props', async () => {
const editor = await renderTestEditorWithCode(
`
import React from 'react'
import { Scene, Storyboard } from 'utopia-api'
const gap = { small: '1rem' }
export var storyboard = (
<Storyboard data-uid='sb'>
<Scene
id='scene'
commentId='scene'
data-uid='scene'
style={{
width: 700,
height: 759,
position: 'absolute',
left: 212,
top: 128,
}}
>
<div
data-uid='div'
style={{ display: 'flex', gap: gap.small }}
/>
</Scene>
</Storyboard>
)
`,
'await-first-dom-report',
)

const styleInfo = getStyleInfoFromInlineStyle(editor)

expect(styleInfo).not.toBeNull()
const { flexDirection, gap } = styleInfo!
expect(flexDirection).toEqual(cssStylePropertyNotFound())
expect(gap).toEqual(cssStylePropertyNotParsable())
})
})

function getStyleInfoFromInlineStyle(editor: EditorRenderResult) {
const { jsxMetadata, projectContents, elementPathTree } = editor.getEditorState().editor

const styleInfoReader = InlineStylePlugin.styleInfoFactory({
metadata: jsxMetadata,
projectContents: projectContents,
elementPathTree: elementPathTree,
})
const styleInfo = styleInfoReader(EP.fromString('sb/scene/div'))
return styleInfo
}
69 changes: 46 additions & 23 deletions editor/src/components/canvas/plugins/inline-style-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,66 @@
import { getLayoutProperty } from '../../../core/layout/getLayoutProperty'
import type { JSXAttributes, PropertyPath } from 'utopia-shared/src/types'
import type { StyleLayoutProp } from '../../../core/layout/layout-helpers-new'
import { MetadataUtils } from '../../../core/model/element-metadata-utils'
import { mapDropNulls } from '../../../core/shared/array-utils'
import { defaultEither, isLeft, mapEither, right } from '../../../core/shared/either'
import type { JSXElement } from '../../../core/shared/element-template'
import * as Either from '../../../core/shared/either'
import {
getJSXAttributesAtPath,
jsxSimpleAttributeToValue,
} from '../../../core/shared/jsx-attribute-utils'
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 {
emptyComments,
isJSXElement,
jsExpressionValue,
} from '../../../core/shared/element-template'
cssStyleProperty,
cssStylePropertyNotParsable,
cssStylePropertyNotFound,
} from '../canvas-types'
import { mapDropNulls } from '../../../core/shared/array-utils'
import { emptyComments, jsExpressionValue } from '../../../core/shared/element-template'
import * as PP from '../../../core/shared/property-path'
import { styleStringInArray } from '../../../utils/common-constants'
import type { ParsedCSSProperties } from '../../inspector/common/css-utils'
import { withPropertyTag, type WithPropertyTag } from '../canvas-types'
import { applyValuesAtPath, deleteValuesAtPath } from '../commands/utils/property-utils'
import type { StylePlugin } from './style-plugins'

function getPropValue(attributes: JSXAttributes, path: PropertyPath): ModifiableAttribute {
const result = getJSXAttributesAtPath(attributes, path)
if (result.remainingPath != null) {
return { type: 'ATTRIBUTE_NOT_FOUND' }
}
return result.attribute
}

function getPropertyFromInstance<P extends StyleLayoutProp, T = ParsedCSSProperties[P]>(
prop: P,
element: JSXElement,
): WithPropertyTag<T> | null {
return defaultEither(
null,
mapEither(withPropertyTag, getLayoutProperty(prop, right(element.props), styleStringInArray)),
) as WithPropertyTag<T> | null
attributes: JSXAttributes,
): CSSStyleProperty<NonNullable<T>> | null {
const attribute = getPropValue(attributes, stylePropPathMappingFn(prop, ['style']))
if (attribute.type === 'ATTRIBUTE_NOT_FOUND') {
return cssStylePropertyNotFound()
}
const simpleValue = jsxSimpleAttributeToValue(attribute)
if (Either.isLeft(simpleValue)) {
return cssStylePropertyNotParsable()
}
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 cssStyleProperty(parsed.value)
}

export const InlineStylePlugin: StylePlugin = {
name: 'Inline Style',
styleInfoFactory:
({ metadata }) =>
({ projectContents }) =>
(elementPath) => {
const instance = MetadataUtils.findElementByElementPath(metadata, elementPath)
if (instance == null || isLeft(instance.element) || !isJSXElement(instance.element.value)) {
const element = getJSXElementFromProjectContents(elementPath, projectContents)
if (element == null) {
return null
}

const gap = getPropertyFromInstance('gap', instance.element.value)
const flexDirection = getPropertyFromInstance('flexDirection', instance.element.value)
const gap = getPropertyFromInstance('gap', element.props)
const flexDirection = getPropertyFromInstance('flexDirection', element.props)

return {
gap: gap,
Expand Down
12 changes: 7 additions & 5 deletions editor/src/components/canvas/plugins/tailwind-style-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ import type { 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 { WithPropertyTag } from '../canvas-types'
import { withPropertyTag } from '../canvas-types'
import type { Config } from 'tailwindcss/types/config'
import { cssStyleProperty, type CSSStyleProperty } from '../canvas-types'
import * as UCL from './tailwind-style-plugin-utils/update-class-list'
import { assertNever } from '../../../core/shared/utils'

function parseTailwindProperty<T>(value: unknown, parse: Parser<T>): WithPropertyTag<T> | null {
function parseTailwindProperty<T>(
value: unknown,
parse: Parser<T>,
): CSSStyleProperty<NonNullable<T>> | null {
const parsed = parse(value, null)
if (isLeft(parsed)) {
if (isLeft(parsed) || parsed.value == null) {
return null
}
return withPropertyTag(parsed.value)
return cssStyleProperty(parsed.value)
}

const TailwindPropertyMapping: Record<string, string> = {
Expand Down

0 comments on commit a668e00

Please sign in to comment.