Skip to content

Commit

Permalink
Convert to grid (#6097)
Browse files Browse the repository at this point in the history
## Description
This PR adds a basic convert-to-grid strategy, makes the flex <-> grid
toggle work again, and turns the layout system + button into a
menu-on-button where on can pick between grid or flex

### Details/breakdown
- add the convert-to-grid strategy (and a remove grid strategy)
- make the flex <-> grid toggle work again
- add the menu-on-button to choose between grid and flex
- add a shortcut that turns a layout into a grid
- expose a prop on the radix-based dropdown to align the dropdown
element relative to the opener
- update tests

---------

Co-authored-by: Federico Ruggi <[email protected]>
Co-authored-by: McKayla Lankau <[email protected]>
Co-authored-by: RheeseyB <[email protected]>
  • Loading branch information
4 people authored and liady committed Dec 13, 2024
1 parent 80c523c commit 7a8a062
Show file tree
Hide file tree
Showing 17 changed files with 547 additions and 87 deletions.
1 change: 1 addition & 0 deletions editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
"@shopify/hydrogen": "2024.4.1",
"@stitches/react": "1.2.8",
"@svgr/plugin-jsx": "5.5.0",
"@testing-library/user-event": "14.5.2",
"@tippyjs/react": "4.1.0",
"@twind/core": "1.1.3",
"@twind/preset-autoprefix": "1.0.7",
Expand Down
9 changes: 9 additions & 0 deletions editor/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
} from '../canvas-strategies/canvas-strategies'
import CanvasActions from '../canvas-actions'
import { getNavigatorTargetsFromEditorState } from '../../navigator/navigator-utils'
import { addFlexLayout } from '../../inspector/layout-systems.test-utils'

const DefaultRouteTextContent = 'Hello Remix!'
const RootTextContent = 'This is root!'
Expand Down Expand Up @@ -1756,8 +1757,7 @@ export default function Index() {

const absoluteDiv = await clickElementOnCanvasControlsLayer(renderResult, AbsoluteDivTestId)

const targetElement = renderResult.renderedDOM.getByTestId(AddRemoveLayoutSystemControlTestId())
await mouseClickAtPoint(targetElement, { x: 1, y: 1 }, { modifiers: cmdModifier })
await addFlexLayout(renderResult)

expect(absoluteDiv.style.display).toEqual('flex')
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ElementPath } from 'utopia-shared/src/types'
import { MetadataUtils } from '../../../core/model/element-metadata-utils'
import type { ElementPathTrees } from '../../../core/shared/element-path-tree'
import type { ElementInstanceMetadataMap } from '../../../core/shared/element-template'
import {
isElementNonDOMElement,
replaceNonDOMElementPathsWithTheirChildrenRecursive,
} from '../../canvas/canvas-strategies/strategies/fragment-like-helpers'
import type { AllElementProps } from '../../editor/store/editor-state'

export type FlexDirectionRowColumn = 'row' | 'column' // a limited subset as we never guess row-reverse or column-reverse
export type FlexAlignItems = 'center' | 'flex-end'

export function getChildrenPathsForContainer(
metadata: ElementInstanceMetadataMap,
elementPathTree: ElementPathTrees,
path: ElementPath,
allElementProps: AllElementProps,
) {
return MetadataUtils.getChildrenPathsOrdered(metadata, elementPathTree, path).flatMap((child) =>
isElementNonDOMElement(metadata, allElementProps, elementPathTree, child)
? replaceNonDOMElementPathsWithTheirChildrenRecursive(
metadata,
allElementProps,
elementPathTree,
[child],
)
: child,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,8 @@ import {
sizeToVisualDimensions,
} from '../../inspector/inspector-common'
import { setHugContentForAxis } from '../../inspector/inspector-strategies/hug-contents-strategy'

type FlexDirectionRowColumn = 'row' | 'column' // a limited subset as we won't never guess row-reverse or column-reverse
type FlexAlignItems = 'center' | 'flex-end'
import type { FlexAlignItems, FlexDirectionRowColumn } from './convert-strategies-common'
import { getChildrenPathsForContainer } from './convert-strategies-common'

function checkConstraintsForThreeElementRow(
allElementProps: AllElementProps,
Expand Down Expand Up @@ -268,19 +267,11 @@ export function convertLayoutToFlexCommands(
]
}

const childrenPaths = MetadataUtils.getChildrenPathsOrdered(
const childrenPaths = getChildrenPathsForContainer(
metadata,
elementPathTree,
path,
).flatMap((child) =>
isElementNonDOMElement(metadata, allElementProps, elementPathTree, child)
? replaceNonDOMElementPathsWithTheirChildrenRecursive(
metadata,
allElementProps,
elementPathTree,
[child],
)
: child,
allElementProps,
)

const parentFlexDirection =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { ElementPath } from 'utopia-shared/src/types'
import type { ElementPathTrees } from '../../../core/shared/element-path-tree'
import type { ElementInstanceMetadataMap } from '../../../core/shared/element-template'
import type { AllElementProps } from '../../editor/store/editor-state'
import type { CanvasCommand } from '../../canvas/commands/commands'
import {
flexContainerProps,
gridContainerProps,
nukeAllAbsolutePositioningPropsCommands,
prunePropsCommands,
sizeToVisualDimensions,
} from '../../inspector/inspector-common'
import { getChildrenPathsForContainer } from './convert-strategies-common'
import type { CanvasFrameAndTarget } from '../../canvas/canvas-types'
import { MetadataUtils } from '../../../core/model/element-metadata-utils'
import { setProperty } from '../../canvas/commands/set-property-command'
import * as PP from '../../../core/shared/property-path'

function guessLayoutInfoAlongAxis(
children: Array<CanvasFrameAndTarget>,
sortFn: (a: CanvasFrameAndTarget, b: CanvasFrameAndTarget) => number,
comesAfter: (a: CanvasFrameAndTarget, b: CanvasFrameAndTarget) => boolean,
gapBetween: (a: CanvasFrameAndTarget, b: CanvasFrameAndTarget) => number,
): { nChildren: number; averageGap: number } {
if (children.length === 0) {
return { nChildren: 0, averageGap: 0 }
}

const sortedChildren = children.sort(sortFn)
let childrenAlongAxis = 1
let gaps: number[] = []
let currentChild = sortedChildren[0]
for (const child of sortedChildren.slice(1)) {
if (comesAfter(currentChild, child)) {
childrenAlongAxis += 1
gaps.push(gapBetween(currentChild, child))
currentChild = child
}
}

const averageGap =
gaps.length === 0 ? 0 : Math.floor(gaps.reduce((a, b) => a + b, 0) / gaps.length)

return {
nChildren: childrenAlongAxis,
averageGap: averageGap,
}
}

function guessMatchingGridSetup(children: Array<CanvasFrameAndTarget>): {
gap: number
numberOfColumns: number
numberOfRows: number
} {
const horizontalData = guessLayoutInfoAlongAxis(
children,
(a, b) => a.frame.x - b.frame.x,
(a, b) => a.frame.x + a.frame.width <= b.frame.x,
(a, b) => b.frame.x - (a.frame.x + a.frame.width),
)
const verticalData = guessLayoutInfoAlongAxis(
children,
(a, b) => a.frame.y - b.frame.y,
(a, b) => a.frame.y + a.frame.height <= b.frame.y,
(a, b) => b.frame.y - (a.frame.y + a.frame.height),
)

return {
gap: (horizontalData.averageGap + verticalData.averageGap) / 2,
numberOfColumns: horizontalData.nChildren,
numberOfRows: verticalData.nChildren,
}
}

export function convertLayoutToGridCommands(
metadata: ElementInstanceMetadataMap,
elementPathTree: ElementPathTrees,
elementPaths: Array<ElementPath>,
allElementProps: AllElementProps,
): Array<CanvasCommand> {
return elementPaths.flatMap((elementPath) => {
const childrenPaths = getChildrenPathsForContainer(
metadata,
elementPathTree,
elementPath,
allElementProps,
)
const childFrames: Array<CanvasFrameAndTarget> = childrenPaths.map((child) => ({
target: child,
frame: MetadataUtils.getFrameOrZeroRectInCanvasCoords(child, metadata),
}))

const { gap, numberOfColumns, numberOfRows } = guessMatchingGridSetup(childFrames)

return [
...prunePropsCommands(flexContainerProps, elementPath),
...prunePropsCommands(gridContainerProps, elementPath),
...childrenPaths.flatMap((child) => [
...nukeAllAbsolutePositioningPropsCommands(child),
...sizeToVisualDimensions(metadata, elementPathTree, child),
]),
setProperty('always', elementPath, PP.create('style', 'display'), 'grid'),
setProperty('always', elementPath, PP.create('style', 'gap'), gap),
setProperty(
'always',
elementPath,
PP.create('style', 'gridTemplateColumns'),
Array(numberOfColumns).fill('1fr').join(' '),
),
setProperty(
'always',
elementPath,
PP.create('style', 'gridTemplateRows'),
Array(numberOfRows).fill('1fr').join(' '),
),
]
})
}
45 changes: 37 additions & 8 deletions editor/src/components/editor/global-shortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@ import {
PASTE_TO_REPLACE,
WRAP_IN_DIV,
COMMENT_SHORTCUT,
CONVERT_TO_GRID_CONTAINER,
} from './shortcut-definitions'
import type { EditorState, LockedElements, NavigatorEntry, UserState } from './store/editor-state'
import type { EditorState, LockedElements, NavigatorEntry } from './store/editor-state'
import { getOpenFile, RightMenuTab } from './store/editor-state'
import { CanvasMousePositionRaw, WindowMousePositionRaw } from '../../utils/global-positions'
import { pickColorWithEyeDropper } from '../canvas/canvas-utils'
Expand All @@ -114,11 +115,7 @@ import {
createHoverInteractionViaMouse,
} from '../canvas/canvas-strategies/interaction-state'
import type { ElementInstanceMetadataMap } from '../../core/shared/element-template'
import {
emptyComments,
jsExpressionValue,
isJSXElementLike,
} from '../../core/shared/element-template'
import { emptyComments, jsExpressionValue } from '../../core/shared/element-template'
import {
toggleTextBold,
toggleTextItalic,
Expand All @@ -128,25 +125,26 @@ import {
import { commandsForFirstApplicableStrategy } from '../inspector/inspector-strategies/inspector-strategy'
import {
addFlexLayoutStrategies,
addGridLayoutStrategies,
removeFlexLayoutStrategies,
removeGridLayoutStrategies,
} from '../inspector/inspector-strategies/inspector-strategies'
import {
detectAreElementsFlexContainers,
toggleResizeToFitSetToFixed,
toggleAbsolutePositioningCommands,
detectAreElementsGridContainers,
} from '../inspector/inspector-common'
import { zeroCanvasPoint } from '../../core/shared/math-utils'
import * as EP from '../../core/shared/element-path'
import { createWrapInGroupActions } from '../canvas/canvas-strategies/strategies/group-conversion-helpers'
import { isRight } from '../../core/shared/either'
import type { ElementPathTrees } from '../../core/shared/element-path-tree'
import { createPasteToReplacePostActionActions } from '../canvas/canvas-strategies/post-action-options/post-action-options'
import { wrapInDivStrategy } from './wrap-in-callbacks'
import { type ProjectServerState } from './store/project-server-state'
import { allowedToEditProject } from './store/collaborative-editing'
import { hasCommentPermission } from './store/permissions'
import { type ShowComponentPickerContextMenuCallback } from '../navigator/navigator-item/component-picker-context-menu'
import { showReplaceComponentPicker } from '../context-menu-items'

function updateKeysPressed(
keysPressed: KeysPressed,
Expand Down Expand Up @@ -916,6 +914,37 @@ export function handleKeyDown(
}
return [EditorActions.applyCommandsAction(commands)]
},
[CONVERT_TO_GRID_CONTAINER]: () => {
if (!isSelectMode(editor.mode)) {
return []
}
const elementsConsideredForGridConversion = editor.selectedViews.filter(
(elementPath) =>
MetadataUtils.getJSXElementFromMetadata(editor.jsxMetadata, elementPath) != null,
)
const selectedElementsGridContainers = detectAreElementsGridContainers(
editor.jsxMetadata,
elementsConsideredForGridConversion,
)
const commands = commandsForFirstApplicableStrategy(
selectedElementsGridContainers
? removeGridLayoutStrategies(
editor.jsxMetadata,
elementsConsideredForGridConversion,
editor.elementPathTree,
)
: addGridLayoutStrategies(
editor.jsxMetadata,
elementsConsideredForGridConversion,
editor.elementPathTree,
editor.allElementProps,
),
)
if (commands == null) {
return []
}
return [EditorActions.applyCommandsAction(commands)]
},
[REMOVE_ABSOLUTE_POSITIONING]: () => {
if (!isSelectMode(editor.mode)) {
return []
Expand Down
5 changes: 5 additions & 0 deletions editor/src/components/editor/shortcut-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const REMOVE_ABSOLUTE_POSITIONING = 'remove-absolute-positioning'
export const RESIZE_TO_FIT = 'resize-to-fit'
export const OPEN_INSERT_MENU = 'open-insert-menu'
export const WRAP_IN_DIV = 'wrap-in-div'
export const CONVERT_TO_GRID_CONTAINER = 'convert-to-grid-container'

export type ShortcutDetails = { [key: string]: Shortcut }

Expand Down Expand Up @@ -232,6 +233,10 @@ export const shortcutDetailsWithDefaults: ShortcutDetails = {
'Convert selected elements to flex containers',
key('a', ['shift']),
),
[CONVERT_TO_GRID_CONTAINER]: shortcut(
'Convert selected elements to grid containers',
key('a', ['shift', 'alt']),
),
[REMOVE_ABSOLUTE_POSITIONING]: shortcut(`Strip absolute sizing props props`, key('x', [])),
[COPY_STYLE_PROPERTIES]: shortcut('Copy style properties', key('c', ['alt', 'cmd'])),
[PASTE_STYLE_PROPERTIES]: shortcut('Paste style properties', key('v', ['alt', 'cmd'])),
Expand Down
Loading

0 comments on commit 7a8a062

Please sign in to comment.