Skip to content

Commit

Permalink
Style plugins (#6513)
Browse files Browse the repository at this point in the history
# [Example project with a flex
gap](https://utopia.fish/p/71b4ada2-boatneck-lamp/?branch_name=feature-style-plugins)

## Problem
The canvas controls can't edit elements that are styled with Tailwind

## Fix
Add a plugin system, where each plugin provides a data source that
strategies can read from (so as of this PR, either Tailwind or the
inline style), and a function that takes props from the inline style,
and moves them to the right place the style element. For example, the
plugin that implements Tailwind styling takes the inline style props,
converts them to the right tailwind classes, and adds them to the
`className` prop.

The flex gap strategy and the flex gap control is updated to use the
data provided by the plugin system to read the necessary data.

### Out of scope
There's one known limitation of this system as implemented in this PR:
props that are removed by a strategy don't get removed by the
normalization step in plugins. This is only a problem for the Tailwind
plugin (since the inline style plugin leaves the inline style prop as
the strategies left them, so the fix for this will come on a follow-up
PR after this one (the actual fix is already implemented here for anyone
interested
b2fc141)

### Details
- Types for the plugin system, and two concrete plugin implementations
are added in the `canvas/plugins` folder
- The data provided by the plugins is added to `InteractionCanvasState`
as the `styleInfoReader` function
- The normalization step provided by the plugins is hooked into the
strategy lifecycle in `interactionFinished`
- The flex gap strategy/controls are updated to use `styleInfoReader`
- `UpdateClassList` is fixed so that it can write the `className` prop
even if it doesn't exist on element at the start of the command

---------

Co-authored-by: Federico Ruggi <1081051+ruggi@users.noreply.github.com>
  • Loading branch information
bkrmendy and ruggi authored Oct 10, 2024
1 parent a844aad commit 647fb23
Show file tree
Hide file tree
Showing 16 changed files with 533 additions and 52 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import { reparentSubjectsForInteractionTarget } from './strategies/reparent-help
import { getReparentTargetUnified } from './strategies/reparent-helpers/reparent-strategy-parent-lookup'
import { gridRearrangeResizeKeyboardStrategy } from './strategies/grid-rearrange-keyboard-strategy'
import createCachedSelector from 're-reselect'
import { getActivePlugin } from '../plugins/style-plugins'

export type CanvasStrategyFactory = (
canvasState: InteractionCanvasState,
Expand Down Expand Up @@ -202,6 +203,7 @@ export function pickCanvasStateFromEditorState(
editorState: EditorState,
builtInDependencies: BuiltInDependencies,
): InteractionCanvasState {
const activePlugin = getActivePlugin(editorState)
return {
builtInDependencies: builtInDependencies,
interactionTarget: getInteractionTargetFromEditorState(editorState, localSelectedViews),
Expand All @@ -214,6 +216,11 @@ export function pickCanvasStateFromEditorState(
startingElementPathTree: editorState.elementPathTree,
startingAllElementProps: editorState.allElementProps,
propertyControlsInfo: editorState.propertyControlsInfo,
styleInfoReader: activePlugin.styleInfoFactory({
projectContents: editorState.projectContents,
metadata: editorState.jsxMetadata,
elementPathTree: editorState.elementPathTree,
}),
}
}

Expand All @@ -224,6 +231,8 @@ export function pickCanvasStateFromEditorStateWithMetadata(
metadata: ElementInstanceMetadataMap,
allElementProps?: AllElementProps,
): InteractionCanvasState {
const activePlugin = getActivePlugin(editorState)

return {
builtInDependencies: builtInDependencies,
interactionTarget: getInteractionTargetFromEditorState(editorState, localSelectedViews),
Expand All @@ -236,6 +245,11 @@ export function pickCanvasStateFromEditorStateWithMetadata(
startingElementPathTree: editorState.elementPathTree, // IMPORTANT! This isn't based on the passed in metadata
startingAllElementProps: allElementProps ?? editorState.allElementProps,
propertyControlsInfo: editorState.propertyControlsInfo,
styleInfoReader: activePlugin.styleInfoFactory({
projectContents: editorState.projectContents,
metadata: metadata,
elementPathTree: editorState.elementPathTree,
}),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { AllElementProps } from '../../editor/store/editor-state'
import type { CanvasCommand } from '../commands/commands'
import type { ActiveFrameAction } from '../commands/set-active-frames-command'
import type { StrategyApplicationStatus } from './interaction-state'
import type { StyleInfo } from '../canvas-types'

// TODO: fill this in, maybe make it an ADT for different strategies
export interface CustomStrategyState {
Expand Down Expand Up @@ -105,6 +106,14 @@ export function controlWithProps<P>(value: ControlWithProps<P>): ControlWithProp
return value
}

export type StyleInfoReader = (elementPath: ElementPath) => StyleInfo | null

export type StyleInfoFactory = (context: {
projectContents: ProjectContentTreeRoot
metadata: ElementInstanceMetadataMap
elementPathTree: ElementPathTrees
}) => StyleInfoReader

export interface InteractionCanvasState {
interactionTarget: InteractionTarget
projectContents: ProjectContentTreeRoot
Expand All @@ -117,6 +126,7 @@ export interface InteractionCanvasState {
startingElementPathTree: ElementPathTrees
startingAllElementProps: AllElementProps
propertyControlsInfo: PropertyControlsInfo
styleInfoReader: StyleInfoReader
}

export type InteractionTarget = TargetPaths | InsertionSubjects
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,22 @@ import {
getPrintedUiJsCode,
makeTestProjectCodeWithSnippet,
renderTestEditorWithCode,
renderTestEditorWithModel,
} from '../../ui-jsx.test-utils'
import { shiftModifier } from '../../../../utils/modifiers'
import { FlexGapTearThreshold } from './set-flex-gap-strategy'
import type { CanvasPoint } from '../../../../core/shared/math-utils'
import { canvasPoint } from '../../../../core/shared/math-utils'
import { checkFlexGapHandlesPositionedCorrectly } from '../../controls/select-mode/flex-gap-control.test-utils'
import { BakedInStoryboardUID } from '../../../../core/model/scene-utils'
import { selectComponentsForTest, wait } from '../../../../utils/utils.test-utils'
import {
selectComponentsForTest,
setFeatureForBrowserTestsUseInDescribeBlockOnly,
} from '../../../../utils/utils.test-utils'
import * as EP from '../../../../core/shared/element-path'
import { createModifiedProject } from '../../../../sample-projects/sample-project-utils.test-utils'
import { StoryboardFilePath } from '../../../editor/store/editor-state'
import { TailwindConfigPath } from '../../../../core/tailwind/tailwind-config'

const DivTestId = 'mydiv'

Expand Down Expand Up @@ -714,6 +721,58 @@ export var storyboard = (
expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(code(20))
})
})

describe('tailwind', () => {
setFeatureForBrowserTestsUseInDescribeBlockOnly('Tailwind', true)
const Project = createModifiedProject({
[StoryboardFilePath]: `
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'
data-testid='${DivTestId}'
className='top-10 left-10 absolute flex flex-row gap-12'
>
<div className='bg-red-500 w-10 h-10' data-uid='child-1' />
<div className='bg-red-500 w-10 h-10' data-uid='child-2' />
</div>
</Scene>
</Storyboard>
)
`,
[TailwindConfigPath]: `
const TailwindConfig = { }
export default TailwindConfig
`,
'app.css': `
@tailwind base;
@tailwind components;
@tailwind utilities;`,
})

it('can set tailwind gap', async () => {
const editor = await renderTestEditorWithModel(Project, 'await-first-dom-report')
await selectComponentsForTest(editor, [EP.fromString('sb/scene/div')])
await doGapResize(editor, canvasPoint({ x: 10, y: 0 }))
const div = editor.renderedDOM.getByTestId(DivTestId)
expect(div.className).toEqual('top-10 left-10 absolute flex flex-row gap-16')
})
})
})

interface GapTestCodeParams {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ export const setFlexGapStrategy: CanvasStrategyFactory = (
return null
}

const flexGap = maybeFlexGapData(canvasState.startingMetadata, selectedElement)
const flexGap = maybeFlexGapData(
canvasState.styleInfoReader(selectedElement),
MetadataUtils.findElementByElementPath(canvasState.startingMetadata, selectedElement),
)

if (flexGap == null) {
return null
}
Expand Down
22 changes: 22 additions & 0 deletions editor/src/components/canvas/canvas-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
import { InteractionSession } from './canvas-strategies/interaction-state'
import type { CanvasStrategyId } from './canvas-strategies/canvas-strategy-types'
import type { MouseButtonsPressed } from '../../utils/mouse'
import type { CSSNumber, FlexDirection } from '../inspector/common/css-utils'

export const CanvasContainerID = 'canvas-container'

Expand Down Expand Up @@ -533,3 +534,24 @@ export const EdgePositionBottomRight: EdgePosition = { x: 1, y: 1 }
export const EdgePositionTopRight: EdgePosition = { x: 1, y: 0 }

export type SelectionLocked = 'locked' | 'locked-hierarchy' | 'selectable'

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

export interface WithPropertyTag<T> {
tag: PropertyTag | null
value: T
}

export const withPropertyTag = <T>(value: T): WithPropertyTag<T> => ({
tag: null,
value: value,
})

export type FlexGapInfo = WithPropertyTag<CSSNumber>

export type FlexDirectionInfo = WithPropertyTag<FlexDirection>

export interface StyleInfo {
gap: FlexGapInfo | null
flexDirection: FlexDirectionInfo | null
}
13 changes: 3 additions & 10 deletions editor/src/components/canvas/commands/update-class-list-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,9 @@ export const runUpdateClassList: CommandFunction<UpdateClassList> = (
) => {
const { element, classNameUpdates } = command

const currentClassNameAttribute = getClassNameAttribute(
getElementFromProjectContents(element, editorState.projectContents),
)?.value

if (currentClassNameAttribute == null) {
return {
editorStatePatches: [],
commandDescription: `Update class list for ${EP.toUid(element)} with ${classNameUpdates}`,
}
}
const currentClassNameAttribute =
getClassNameAttribute(getElementFromProjectContents(element, editorState.projectContents))
?.value ?? ''

const parsedClassList = getParsedClassList(
currentClassNameAttribute,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ import CanvasActions from '../../canvas-actions'
import { controlForStrategyMemoized } from '../../canvas-strategies/canvas-strategy-types'
import { createInteractionViaMouse, flexGapHandle } from '../../canvas-strategies/interaction-state'
import { windowToCanvasCoordinates } from '../../dom-lookup'
import type { FlexGapData } from '../../gap-utils'
import {
cursorFromFlexDirection,
maybeFlexGapData,
gapControlBoundsFromMetadata,
maybeFlexGapData,
recurseIntoChildrenOfMapOrFragment,
} from '../../gap-utils'
import { CanvasOffsetWrapper } from '../canvas-offset-wrapper'
Expand All @@ -46,6 +47,7 @@ import {
reverseJustifyContent,
} from '../../../../core/model/flex-utils'
import { optionalMap } from '../../../../core/shared/optional-utils'
import { getActivePlugin } from '../../plugins/style-plugins'

interface FlexGapControlProps {
selectedElement: ElementPath
Expand Down Expand Up @@ -130,21 +132,40 @@ export const FlexGapControl = controlForStrategyMemoized<FlexGapControlProps>((p
elementPathTrees,
selectedElement,
)
const flexGap = maybeFlexGapData(metadata, selectedElement)
if (flexGap == null) {
return null
}

const flexGapValue = updatedGapValue ?? flexGap.value
const flexGapFromEditor = useEditorState(
Substores.fullStore,
(store) =>
maybeFlexGapData(
getActivePlugin(store.editor).styleInfoFactory({
projectContents: store.editor.projectContents,
metadata: metadata,
elementPathTree: store.editor.elementPathTree,
})(selectedElement),
MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, selectedElement),
),
'FlexGapControl flexGapFromEditor',
)

const controlBounds = gapControlBoundsFromMetadata(
metadata,
selectedElement,
children.map((c) => c.elementPath),
flexGapValue.renderedValuePx,
flexGap.direction,
const flexGap: FlexGapData | null = optionalMap(
(gap) => ({
direction: gap.direction,
value: updatedGapValue ?? gap.value,
}),
flexGapFromEditor,
)

const controlBounds =
flexGapFromEditor == null || flexGap == null
? null
: gapControlBoundsFromMetadata(
metadata,
selectedElement,
children.map((c) => c.elementPath),
flexGap.value.renderedValuePx,
flexGapFromEditor.direction,
)

const contentArea = React.useMemo((): Size => {
function valueForDimension(
directions: FlexDirection[],
Expand All @@ -162,25 +183,25 @@ export const FlexGapControl = controlForStrategyMemoized<FlexGapControlProps>((p
}, children),
)

if (bounds == null) {
if (bounds == null || flexGap == null) {
return zeroSize
} else {
return {
width: valueForDimension(
['column', 'column-reverse'],
flexGap.direction,
bounds.width,
flexGapValue.renderedValuePx,
flexGap.value.renderedValuePx,
),
height: valueForDimension(
['row', 'row-reverse'],
flexGap.direction,
bounds.height,
flexGapValue.renderedValuePx,
flexGap.value.renderedValuePx,
),
}
}
}, [children, flexGap.direction, flexGapValue.renderedValuePx, metadata])
}, [children, flexGap, metadata])

const justifyContent = React.useMemo(() => {
return (
Expand All @@ -196,12 +217,16 @@ export const FlexGapControl = controlForStrategyMemoized<FlexGapControlProps>((p
)
}, [metadata, selectedElement])

if (flexGap == null || controlBounds == null) {
return null
}

return (
<CanvasOffsetWrapper>
<div data-testid={FlexGapControlTestId} style={{ pointerEvents: 'none' }}>
{controlBounds.map(({ bounds, path: p }) => {
const path = EP.toString(p)
const valueToShow = fallbackEmptyValue(flexGapValue)
const valueToShow = fallbackEmptyValue(flexGap.value)
return (
<GapControlSegment
key={path}
Expand Down
Loading

0 comments on commit 647fb23

Please sign in to comment.