diff --git a/editor/src/components/canvas/canvas-types.ts b/editor/src/components/canvas/canvas-types.ts index 010fb804613f..218f168e7dec 100644 --- a/editor/src/components/canvas/canvas-types.ts +++ b/editor/src/components/canvas/canvas-types.ts @@ -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' @@ -543,11 +544,13 @@ interface CSSStylePropertyNotFound { interface CSSStylePropertyNotParsable { type: 'not-parsable' + originalValue: JSExpression | PartOfJSXAttributeValue } interface ParsedCSSStyleProperty { type: 'property' tags: PropertyTag[] + propertyValue: JSExpression | PartOfJSXAttributeValue value: T } @@ -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(value: T): ParsedCSSStyleProperty { - return { type: 'property', tags: [], value: value } +export function cssStyleProperty( + value: T, + propertyValue: JSExpression | PartOfJSXAttributeValue, +): ParsedCSSStyleProperty { + return { type: 'property', tags: [], value: value, propertyValue: propertyValue } } export function maybePropertyValue(property: CSSStyleProperty): T | null { diff --git a/editor/src/components/canvas/commands/utils/property-utils.ts b/editor/src/components/canvas/commands/utils/property-utils.ts index 1a28302d8c7d..123bba748aae 100644 --- a/editor/src/components/canvas/commands/utils/property-utils.ts +++ b/editor/src/components/canvas/commands/utils/property-utils.ts @@ -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 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 7be9e9103a53..8ba0e9733b8c 100644 --- a/editor/src/components/canvas/plugins/inline-style-plugin.spec.ts +++ b/editor/src/components/canvas/plugins/inline-style-plugin.spec.ts @@ -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' @@ -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 () => { @@ -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', + }, + }) }) }) diff --git a/editor/src/components/canvas/plugins/inline-style-plugin.ts b/editor/src/components/canvas/plugins/inline-style-plugin.ts index 0a7d9f1f95ae..226ff8a45401 100644 --- a/editor/src/components/canvas/plugins/inline-style-plugin.ts +++ b/editor/src/components/canvas/plugins/inline-style-plugin.ts @@ -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, @@ -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, @@ -29,7 +28,7 @@ function getPropValue(attributes: JSXAttributes, path: PropertyPath): Modifiable return result.attribute } -function getPropertyFromInstance

( +function getPropertyFromInstance

( prop: P, attributes: JSXAttributes, ): CSSStyleProperty> | null { @@ -39,18 +38,24 @@ function getPropertyFromInstance

Either.Either 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: ( + attributes: JSXAttributes, + prop: T, + ): CSSStyleProperty> | null => { + return getPropertyFromInstance(prop, attributes) + }, styleInfoFactory: ({ projectContents }) => (elementPath) => { diff --git a/editor/src/components/canvas/plugins/style-plugins.ts b/editor/src/components/canvas/plugins/style-plugins.ts index 5fa2e0aed628..c23215963f48 100644 --- a/editor/src/components/canvas/plugins/style-plugins.ts +++ b/editor/src/components/canvas/plugins/style-plugins.ts @@ -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, @@ -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' @@ -51,6 +53,10 @@ export type StyleUpdate = UpdateCSSProp | DeleteCSSProp export interface StylePlugin { name: string styleInfoFactory: StyleInfoFactory + readStyleFromElementProps: ( + attributes: JSXAttributes, + prop: T, + ) => CSSStyleProperty> | null updateStyles: ( editorState: EditorState, elementPath: ElementPath, diff --git a/editor/src/components/canvas/plugins/tailwind-style-plugin.ts b/editor/src/components/canvas/plugins/tailwind-style-plugin.ts index 21d5508638a0..768f66dd6207 100644 --- a/editor/src/components/canvas/plugins/tailwind-style-plugin.ts +++ b/editor/src/components/canvas/plugins/tailwind-style-plugin.ts @@ -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( - value: unknown, - parse: Parser, -): CSSStyleProperty> | null { - const parsed = parse(value, null) +function parseTailwindProperty( + value: string | number | undefined, + prop: T, +): CSSStyleProperty> | 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 = { @@ -81,6 +89,25 @@ 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')), + ), + ) + + if (typeof classNameAttribute !== 'string') { + return null + } + + const mapping = getTailwindClassMapping(classNameAttribute.split(' '), config) + return parseTailwindProperty(mapping[TailwindPropertyMapping[prop]], prop) + }, styleInfoFactory: ({ projectContents }) => (elementPath) => { @@ -95,48 +122,45 @@ 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, @@ -144,7 +168,7 @@ export const TailwindPlugin = (config: Config | null): StylePlugin => ({ 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], diff --git a/editor/src/components/editor/actions/actions.tsx b/editor/src/components/editor/actions/actions.tsx index 2d158b0407b4..9e156336f069 100644 --- a/editor/src/components/editor/actions/actions.tsx +++ b/editor/src/components/editor/actions/actions.tsx @@ -630,10 +630,15 @@ import { getParseCacheOptions } from '../../../core/shared/parse-cache-utils' import { styleP } from '../../inspector/inspector-common' import { getUpdateOperationResult } from '../../../core/shared/import/import-operation-service' import { updateRequirements } from '../../../core/shared/import/project-health-check/utopia-requirements-service' -import { applyValuesAtPath, deleteValuesAtPath } from '../../canvas/commands/utils/property-utils' +import { + applyValuesAtPath, + deleteValuesAtPath, + maybeCssPropertyFromInlineStyle, +} from '../../canvas/commands/utils/property-utils' import type { HuggingElementContentsStatus } from '../../../components/canvas/hugging-utils' import { getHuggingElementContentsStatus } from '../../../components/canvas/hugging-utils' import { createStoryboardFileIfNecessary } from '../../../core/shared/import/project-health-check/requirements/requirement-storyboard' +import { setProperty } from '../../canvas/commands/set-property-command' export const MIN_CODE_PANE_REOPEN_WIDTH = 100 @@ -1727,50 +1732,41 @@ export const UPDATE_FNS = { }, UNSET_PROPERTY: (action: UnsetProperty, editor: EditorModel): EditorModel => { // TODO also queue group true up, just like for SET_PROP - let unsetPropFailedMessage: string | null = null - const updatedEditor = modifyUnderlyingElementForOpenFile( - action.element, - editor, - (element) => { - const updatedProps = unsetJSXValueAtPath(element.props, action.property) - return foldEither( - (failureMessage) => { - unsetPropFailedMessage = failureMessage - return element - }, - (updatedAttributes) => ({ - ...element, - props: updatedAttributes, - }), - updatedProps, - ) - }, - (success) => success, - ) - if (unsetPropFailedMessage != null) { - const toastAction = showToast(notice(unsetPropFailedMessage, 'ERROR')) - return UPDATE_FNS.ADD_TOAST(toastAction, editor) - } else { - return updatedEditor - } + // TODO this used to fire a toast if the prop couldn't be removed + return foldAndApplyCommandsSimple(editor, [ + deleteProperties('always', action.element, [action.property]), + ]) }, SET_PROP: (action: SetProp, editor: EditorModel): EditorModel => { let setPropFailedMessage: string | null = null let newSelectedViews: Array = editor.selectedViews + const prop = maybeCssPropertyFromInlineStyle(action.propertyPath) + const valueForStyleProp = + action.value.type === 'ATTRIBUTE_VALUE' && + (typeof action.value.value === 'number' || typeof action.value.value === 'string') + ? action.value.value + : null + + const editorWithPropSet = + prop == null || valueForStyleProp == null + ? applyValuesAtPath(editor, action.target, [ + { path: action.propertyPath, value: action.value }, + ]).editorStateWithChanges + : foldAndApplyCommandsSimple(editor, [ + setProperty('always', action.target, PP.create('style', prop), valueForStyleProp), + ]) + + if (isJSXElement(action.value)) { + newSelectedViews = [EP.appendToPath(action.target, action.value.uid)] + } let updatedEditor = modifyUnderlyingTargetElement( action.target, - editor, + editorWithPropSet, (element) => { if (!isJSXElement(element)) { return element } - const updatedProps = setJSXValueAtPath(element.props, action.propertyPath, action.value) - // when this is a render prop we should select it - if (isJSXElement(action.value)) { - newSelectedViews = [EP.appendToPath(action.target, action.value.uid)] - } if ( - isRight(updatedProps) && PP.contains( [ PP.create('style', 'top'), @@ -1783,8 +1779,9 @@ export const UPDATE_FNS = { action.propertyPath, ) ) { + // TODO: refactor this to read from the plugins const maybeInvalidGroupState = groupStateFromJSXElement( - { ...element, props: updatedProps.value }, + element, action.target, editor.jsxMetadata, editor.elementPathTree, @@ -1803,18 +1800,11 @@ export const UPDATE_FNS = { return element } } - return foldEither( - (failureMessage) => { - setPropFailedMessage = failureMessage - return element - }, - (updatedAttributes) => ({ - ...element, - // we round style.left/top/right/bottom/width/height pins for the modified element - props: roundAttributeLayoutValues(styleStringInArray, updatedAttributes), - }), - updatedProps, - ) + return { + ...element, + // TODO: refactor this to use commands + props: roundAttributeLayoutValues(styleStringInArray, element.props), + } }, (success, _, underlyingFilePath) => { const updatedImports = mergeImports( diff --git a/editor/src/components/inspector/common/property-path-hooks.ts b/editor/src/components/inspector/common/property-path-hooks.ts index cd7d469b963f..4bfb0b2b1f50 100644 --- a/editor/src/components/inspector/common/property-path-hooks.ts +++ b/editor/src/components/inspector/common/property-path-hooks.ts @@ -48,7 +48,7 @@ import type { StyleLayoutProp } from '../../../core/layout/layout-helpers-new' import { isHTMLComponent, isUtopiaAPIComponent } from '../../../core/model/project-file-utils' import { stripNulls } from '../../../core/shared/array-utils' import type { Either } from '../../../core/shared/either' -import { eitherToMaybe, flatMapEither, isRight, left } from '../../../core/shared/either' +import { eitherToMaybe, flatMapEither, isRight, left, right } from '../../../core/shared/either' import type { JSXAttributes, ComputedStyle, @@ -92,6 +92,10 @@ import type { EditorAction } from '../../editor/action-types' import { useDispatch } from '../../editor/store/dispatch-context' import { eitherRight, fromTypeGuard } from '../../../core/shared/optics/optic-creators' import { modify } from '../../../core/shared/optics/optic-utilities' +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' export interface InspectorPropsContextData { selectedViews: Array @@ -744,10 +748,38 @@ const getModifiableAttributeResultToExpressionOptic = eitherRight< ModifiableAttribute >().compose(fromTypeGuard(isRegularJSXAttribute)) +function maybeStyleInfoKeyFromPropertyPath(propertyPath: PropertyPath): keyof StyleInfo | null { + const maybeCSSProp = maybeCssPropertyFromInlineStyle(propertyPath) + if (maybeCSSProp == null || !isStyleInfoKey(maybeCSSProp)) { + return null + } + return maybeCSSProp +} + export function useGetMultiselectedProps

( pathMappingFn: PathMappingFn

, propKeys: P[], ): MultiselectAtProps

{ + const styleInfoReaderRef = useRefEditorState( + (store) => + (props: JSXAttributes, prop: keyof StyleInfo): GetModifiableAttributeResult => { + const elementStyle = getActivePlugin(store.editor).readStyleFromElementProps(props, prop) + if (elementStyle == null) { + return left('not found') + } + switch (elementStyle.type) { + case 'not-found': + return right({ type: 'ATTRIBUTE_NOT_FOUND' }) + case 'not-parsable': + return right(elementStyle.originalValue) + case 'property': + return right(elementStyle.propertyValue) + default: + assertNever(elementStyle) + } + }, + ) + return useKeepReferenceEqualityIfPossible( useContextSelector( InspectorPropsContext, @@ -755,10 +787,13 @@ export function useGetMultiselectedProps

( const keyFn = (propKey: P) => propKey const mapFn = (propKey: P) => { return contextData.editedMultiSelectedProps.map((props) => { - const result = getModifiableJSXAttributeAtPath( - props, - pathMappingFn(propKey, contextData.targetPath), - ) + const targetPath = pathMappingFn(propKey, contextData.targetPath) + const maybeStyleInfoKey = maybeStyleInfoKeyFromPropertyPath(targetPath) + const result = + maybeStyleInfoKey != null + ? styleInfoReaderRef.current(props, maybeStyleInfoKey) + : getModifiableJSXAttributeAtPath(props, targetPath) + // This wipes the uid from any `JSExpression` values we may have retrieved, // as that can cause the deep equality check to fail for different elements // with the same value for a given property.