Skip to content

Commit 647fb23

Browse files
bkrmendyruggi
andauthored
Style plugins (#6513)
# [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>
1 parent a844aad commit 647fb23

16 files changed

+533
-52
lines changed

editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx

+14
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import { reparentSubjectsForInteractionTarget } from './strategies/reparent-help
8282
import { getReparentTargetUnified } from './strategies/reparent-helpers/reparent-strategy-parent-lookup'
8383
import { gridRearrangeResizeKeyboardStrategy } from './strategies/grid-rearrange-keyboard-strategy'
8484
import createCachedSelector from 're-reselect'
85+
import { getActivePlugin } from '../plugins/style-plugins'
8586

8687
export type CanvasStrategyFactory = (
8788
canvasState: InteractionCanvasState,
@@ -202,6 +203,7 @@ export function pickCanvasStateFromEditorState(
202203
editorState: EditorState,
203204
builtInDependencies: BuiltInDependencies,
204205
): InteractionCanvasState {
206+
const activePlugin = getActivePlugin(editorState)
205207
return {
206208
builtInDependencies: builtInDependencies,
207209
interactionTarget: getInteractionTargetFromEditorState(editorState, localSelectedViews),
@@ -214,6 +216,11 @@ export function pickCanvasStateFromEditorState(
214216
startingElementPathTree: editorState.elementPathTree,
215217
startingAllElementProps: editorState.allElementProps,
216218
propertyControlsInfo: editorState.propertyControlsInfo,
219+
styleInfoReader: activePlugin.styleInfoFactory({
220+
projectContents: editorState.projectContents,
221+
metadata: editorState.jsxMetadata,
222+
elementPathTree: editorState.elementPathTree,
223+
}),
217224
}
218225
}
219226

@@ -224,6 +231,8 @@ export function pickCanvasStateFromEditorStateWithMetadata(
224231
metadata: ElementInstanceMetadataMap,
225232
allElementProps?: AllElementProps,
226233
): InteractionCanvasState {
234+
const activePlugin = getActivePlugin(editorState)
235+
227236
return {
228237
builtInDependencies: builtInDependencies,
229238
interactionTarget: getInteractionTargetFromEditorState(editorState, localSelectedViews),
@@ -236,6 +245,11 @@ export function pickCanvasStateFromEditorStateWithMetadata(
236245
startingElementPathTree: editorState.elementPathTree, // IMPORTANT! This isn't based on the passed in metadata
237246
startingAllElementProps: allElementProps ?? editorState.allElementProps,
238247
propertyControlsInfo: editorState.propertyControlsInfo,
248+
styleInfoReader: activePlugin.styleInfoFactory({
249+
projectContents: editorState.projectContents,
250+
metadata: metadata,
251+
elementPathTree: editorState.elementPathTree,
252+
}),
239253
}
240254
}
241255

editor/src/components/canvas/canvas-strategies/canvas-strategy-types.ts

+10
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { AllElementProps } from '../../editor/store/editor-state'
1414
import type { CanvasCommand } from '../commands/commands'
1515
import type { ActiveFrameAction } from '../commands/set-active-frames-command'
1616
import type { StrategyApplicationStatus } from './interaction-state'
17+
import type { StyleInfo } from '../canvas-types'
1718

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

109+
export type StyleInfoReader = (elementPath: ElementPath) => StyleInfo | null
110+
111+
export type StyleInfoFactory = (context: {
112+
projectContents: ProjectContentTreeRoot
113+
metadata: ElementInstanceMetadataMap
114+
elementPathTree: ElementPathTrees
115+
}) => StyleInfoReader
116+
108117
export interface InteractionCanvasState {
109118
interactionTarget: InteractionTarget
110119
projectContents: ProjectContentTreeRoot
@@ -117,6 +126,7 @@ export interface InteractionCanvasState {
117126
startingElementPathTree: ElementPathTrees
118127
startingAllElementProps: AllElementProps
119128
propertyControlsInfo: PropertyControlsInfo
129+
styleInfoReader: StyleInfoReader
120130
}
121131

122132
export type InteractionTarget = TargetPaths | InsertionSubjects

editor/src/components/canvas/canvas-strategies/strategies/set-flex-gap-strategy.spec.browser2.tsx

+60-1
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,22 @@ import {
1313
getPrintedUiJsCode,
1414
makeTestProjectCodeWithSnippet,
1515
renderTestEditorWithCode,
16+
renderTestEditorWithModel,
1617
} from '../../ui-jsx.test-utils'
1718
import { shiftModifier } from '../../../../utils/modifiers'
1819
import { FlexGapTearThreshold } from './set-flex-gap-strategy'
1920
import type { CanvasPoint } from '../../../../core/shared/math-utils'
2021
import { canvasPoint } from '../../../../core/shared/math-utils'
2122
import { checkFlexGapHandlesPositionedCorrectly } from '../../controls/select-mode/flex-gap-control.test-utils'
2223
import { BakedInStoryboardUID } from '../../../../core/model/scene-utils'
23-
import { selectComponentsForTest, wait } from '../../../../utils/utils.test-utils'
24+
import {
25+
selectComponentsForTest,
26+
setFeatureForBrowserTestsUseInDescribeBlockOnly,
27+
} from '../../../../utils/utils.test-utils'
2428
import * as EP from '../../../../core/shared/element-path'
29+
import { createModifiedProject } from '../../../../sample-projects/sample-project-utils.test-utils'
30+
import { StoryboardFilePath } from '../../../editor/store/editor-state'
31+
import { TailwindConfigPath } from '../../../../core/tailwind/tailwind-config'
2532

2633
const DivTestId = 'mydiv'
2734

@@ -714,6 +721,58 @@ export var storyboard = (
714721
expect(getPrintedUiJsCode(editor.getEditorState())).toEqual(code(20))
715722
})
716723
})
724+
725+
describe('tailwind', () => {
726+
setFeatureForBrowserTestsUseInDescribeBlockOnly('Tailwind', true)
727+
const Project = createModifiedProject({
728+
[StoryboardFilePath]: `
729+
import React from 'react'
730+
import { Scene, Storyboard } from 'utopia-api'
731+
export var storyboard = (
732+
<Storyboard data-uid='sb'>
733+
<Scene
734+
id='scene'
735+
commentId='scene'
736+
data-uid='scene'
737+
style={{
738+
width: 700,
739+
height: 759,
740+
position: 'absolute',
741+
left: 212,
742+
top: 128,
743+
}}
744+
>
745+
<div
746+
data-uid='div'
747+
data-testid='${DivTestId}'
748+
className='top-10 left-10 absolute flex flex-row gap-12'
749+
>
750+
<div className='bg-red-500 w-10 h-10' data-uid='child-1' />
751+
<div className='bg-red-500 w-10 h-10' data-uid='child-2' />
752+
</div>
753+
</Scene>
754+
</Storyboard>
755+
)
756+
757+
`,
758+
[TailwindConfigPath]: `
759+
const TailwindConfig = { }
760+
export default TailwindConfig
761+
`,
762+
'app.css': `
763+
@tailwind base;
764+
@tailwind components;
765+
@tailwind utilities;`,
766+
})
767+
768+
it('can set tailwind gap', async () => {
769+
const editor = await renderTestEditorWithModel(Project, 'await-first-dom-report')
770+
await selectComponentsForTest(editor, [EP.fromString('sb/scene/div')])
771+
await doGapResize(editor, canvasPoint({ x: 10, y: 0 }))
772+
const div = editor.renderedDOM.getByTestId(DivTestId)
773+
expect(div.className).toEqual('top-10 left-10 absolute flex flex-row gap-16')
774+
})
775+
})
717776
})
718777

719778
interface GapTestCodeParams {

editor/src/components/canvas/canvas-strategies/strategies/set-flex-gap-strategy.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,11 @@ export const setFlexGapStrategy: CanvasStrategyFactory = (
9595
return null
9696
}
9797

98-
const flexGap = maybeFlexGapData(canvasState.startingMetadata, selectedElement)
98+
const flexGap = maybeFlexGapData(
99+
canvasState.styleInfoReader(selectedElement),
100+
MetadataUtils.findElementByElementPath(canvasState.startingMetadata, selectedElement),
101+
)
102+
99103
if (flexGap == null) {
100104
return null
101105
}

editor/src/components/canvas/canvas-types.ts

+22
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
import { InteractionSession } from './canvas-strategies/interaction-state'
2727
import type { CanvasStrategyId } from './canvas-strategies/canvas-strategy-types'
2828
import type { MouseButtonsPressed } from '../../utils/mouse'
29+
import type { CSSNumber, FlexDirection } from '../inspector/common/css-utils'
2930

3031
export const CanvasContainerID = 'canvas-container'
3132

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

535536
export type SelectionLocked = 'locked' | 'locked-hierarchy' | 'selectable'
537+
538+
export type PropertyTag = { type: 'hover' } | { type: 'breakpoint'; name: string }
539+
540+
export interface WithPropertyTag<T> {
541+
tag: PropertyTag | null
542+
value: T
543+
}
544+
545+
export const withPropertyTag = <T>(value: T): WithPropertyTag<T> => ({
546+
tag: null,
547+
value: value,
548+
})
549+
550+
export type FlexGapInfo = WithPropertyTag<CSSNumber>
551+
552+
export type FlexDirectionInfo = WithPropertyTag<FlexDirection>
553+
554+
export interface StyleInfo {
555+
gap: FlexGapInfo | null
556+
flexDirection: FlexDirectionInfo | null
557+
}

editor/src/components/canvas/commands/update-class-list-command.ts

+3-10
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,9 @@ export const runUpdateClassList: CommandFunction<UpdateClassList> = (
5656
) => {
5757
const { element, classNameUpdates } = command
5858

59-
const currentClassNameAttribute = getClassNameAttribute(
60-
getElementFromProjectContents(element, editorState.projectContents),
61-
)?.value
62-
63-
if (currentClassNameAttribute == null) {
64-
return {
65-
editorStatePatches: [],
66-
commandDescription: `Update class list for ${EP.toUid(element)} with ${classNameUpdates}`,
67-
}
68-
}
59+
const currentClassNameAttribute =
60+
getClassNameAttribute(getElementFromProjectContents(element, editorState.projectContents))
61+
?.value ?? ''
6962

7063
const parsedClassList = getParsedClassList(
7164
currentClassNameAttribute,

editor/src/components/canvas/controls/select-mode/flex-gap-control.tsx

+42-17
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ import CanvasActions from '../../canvas-actions'
2222
import { controlForStrategyMemoized } from '../../canvas-strategies/canvas-strategy-types'
2323
import { createInteractionViaMouse, flexGapHandle } from '../../canvas-strategies/interaction-state'
2424
import { windowToCanvasCoordinates } from '../../dom-lookup'
25+
import type { FlexGapData } from '../../gap-utils'
2526
import {
2627
cursorFromFlexDirection,
27-
maybeFlexGapData,
2828
gapControlBoundsFromMetadata,
29+
maybeFlexGapData,
2930
recurseIntoChildrenOfMapOrFragment,
3031
} from '../../gap-utils'
3132
import { CanvasOffsetWrapper } from '../canvas-offset-wrapper'
@@ -46,6 +47,7 @@ import {
4647
reverseJustifyContent,
4748
} from '../../../../core/model/flex-utils'
4849
import { optionalMap } from '../../../../core/shared/optional-utils'
50+
import { getActivePlugin } from '../../plugins/style-plugins'
4951

5052
interface FlexGapControlProps {
5153
selectedElement: ElementPath
@@ -130,21 +132,40 @@ export const FlexGapControl = controlForStrategyMemoized<FlexGapControlProps>((p
130132
elementPathTrees,
131133
selectedElement,
132134
)
133-
const flexGap = maybeFlexGapData(metadata, selectedElement)
134-
if (flexGap == null) {
135-
return null
136-
}
137135

138-
const flexGapValue = updatedGapValue ?? flexGap.value
136+
const flexGapFromEditor = useEditorState(
137+
Substores.fullStore,
138+
(store) =>
139+
maybeFlexGapData(
140+
getActivePlugin(store.editor).styleInfoFactory({
141+
projectContents: store.editor.projectContents,
142+
metadata: metadata,
143+
elementPathTree: store.editor.elementPathTree,
144+
})(selectedElement),
145+
MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, selectedElement),
146+
),
147+
'FlexGapControl flexGapFromEditor',
148+
)
139149

140-
const controlBounds = gapControlBoundsFromMetadata(
141-
metadata,
142-
selectedElement,
143-
children.map((c) => c.elementPath),
144-
flexGapValue.renderedValuePx,
145-
flexGap.direction,
150+
const flexGap: FlexGapData | null = optionalMap(
151+
(gap) => ({
152+
direction: gap.direction,
153+
value: updatedGapValue ?? gap.value,
154+
}),
155+
flexGapFromEditor,
146156
)
147157

158+
const controlBounds =
159+
flexGapFromEditor == null || flexGap == null
160+
? null
161+
: gapControlBoundsFromMetadata(
162+
metadata,
163+
selectedElement,
164+
children.map((c) => c.elementPath),
165+
flexGap.value.renderedValuePx,
166+
flexGapFromEditor.direction,
167+
)
168+
148169
const contentArea = React.useMemo((): Size => {
149170
function valueForDimension(
150171
directions: FlexDirection[],
@@ -162,25 +183,25 @@ export const FlexGapControl = controlForStrategyMemoized<FlexGapControlProps>((p
162183
}, children),
163184
)
164185

165-
if (bounds == null) {
186+
if (bounds == null || flexGap == null) {
166187
return zeroSize
167188
} else {
168189
return {
169190
width: valueForDimension(
170191
['column', 'column-reverse'],
171192
flexGap.direction,
172193
bounds.width,
173-
flexGapValue.renderedValuePx,
194+
flexGap.value.renderedValuePx,
174195
),
175196
height: valueForDimension(
176197
['row', 'row-reverse'],
177198
flexGap.direction,
178199
bounds.height,
179-
flexGapValue.renderedValuePx,
200+
flexGap.value.renderedValuePx,
180201
),
181202
}
182203
}
183-
}, [children, flexGap.direction, flexGapValue.renderedValuePx, metadata])
204+
}, [children, flexGap, metadata])
184205

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

220+
if (flexGap == null || controlBounds == null) {
221+
return null
222+
}
223+
199224
return (
200225
<CanvasOffsetWrapper>
201226
<div data-testid={FlexGapControlTestId} style={{ pointerEvents: 'none' }}>
202227
{controlBounds.map(({ bounds, path: p }) => {
203228
const path = EP.toString(p)
204-
const valueToShow = fallbackEmptyValue(flexGapValue)
229+
const valueToShow = fallbackEmptyValue(flexGap.value)
205230
return (
206231
<GapControlSegment
207232
key={path}

0 commit comments

Comments
 (0)