From 2732c5f3abcc9bd274547de77bb3d514ceca1f79 Mon Sep 17 00:00:00 2001
From: Sean Parsons <217400+seanparsons@users.noreply.github.com>
Date: Wed, 3 Jul 2024 04:57:22 -0400
Subject: [PATCH] Grid Swap! (#6035)
**Problem:**
Sometimes we want to swap items around in the grid.
**Fix:**
Ported over the `rearrangeGridSwapStrategy` from the spike branch, which
swaps the dragged cell with a target cell that it is dragged over.
**Commit Details:**
- Added `rearrangeGridSwapStrategy` to `moveOrReorderStrategies`.
**Manual Tests:**
I hereby swear that:
- [x] I opened a hydrogen project and it loaded
- [x] I could navigate to various routes in Preview mode
---
.../canvas-strategies/canvas-strategies.tsx | 2 +
...range-grid-swap-strategy.spec.browser2.tsx | 210 +++++++++++++++++
.../rearrange-grid-swap-strategy.ts | 216 ++++++++++++++++++
3 files changed, 428 insertions(+)
create mode 100644 editor/src/components/canvas/canvas-strategies/strategies/rearrange-grid-swap-strategy.spec.browser2.tsx
create mode 100644 editor/src/components/canvas/canvas-strategies/strategies/rearrange-grid-swap-strategy.ts
diff --git a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx
index 896e9abd5155..0301f4ee9dde 100644
--- a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx
+++ b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx
@@ -65,6 +65,7 @@ import { retargetStrategyToChildrenOfFragmentLikeElements } from './strategies/f
import { MetadataUtils } from '../../../core/model/element-metadata-utils'
import { gridRearrangeMoveStrategy } from './strategies/grid-rearrange-move-strategy'
import { resizeGridStrategy } from './strategies/resize-grid-strategy'
+import { rearrangeGridSwapStrategy } from './strategies/rearrange-grid-swap-strategy'
export type CanvasStrategyFactory = (
canvasState: InteractionCanvasState,
@@ -93,6 +94,7 @@ const moveOrReorderStrategies: MetaCanvasStrategy = (
convertToAbsoluteAndMoveAndSetParentFixedStrategy,
reorderSliderStategy,
gridRearrangeMoveStrategy,
+ rearrangeGridSwapStrategy,
],
)
}
diff --git a/editor/src/components/canvas/canvas-strategies/strategies/rearrange-grid-swap-strategy.spec.browser2.tsx b/editor/src/components/canvas/canvas-strategies/strategies/rearrange-grid-swap-strategy.spec.browser2.tsx
new file mode 100644
index 000000000000..4db6691175d2
--- /dev/null
+++ b/editor/src/components/canvas/canvas-strategies/strategies/rearrange-grid-swap-strategy.spec.browser2.tsx
@@ -0,0 +1,210 @@
+import { getPrintedUiJsCode, renderTestEditorWithCode } from '../../ui-jsx.test-utils'
+import * as EP from '../../../../core/shared/element-path'
+import { selectComponents } from '../../../../components/editor/actions/meta-actions'
+import { CanvasControlsContainerID } from '../../controls/new-canvas-controls'
+import {
+ mouseDownAtPoint,
+ mouseMoveToPoint,
+ mouseUpAtPoint,
+ pressKey,
+} from '../../event-helpers.test-utils'
+import { canvasPoint } from '../../../../core/shared/math-utils'
+import { GridCellTestId } from '../../controls/grid-controls'
+
+const testProject = `
+import * as React from 'react'
+import { Storyboard } from 'utopia-api'
+
+export var storyboard = (
+
+
+
+)
+`
+
+describe('swap an element', () => {
+ it('swap out an element for another from a different column and row', async () => {
+ const renderResult = await renderTestEditorWithCode(testProject, 'await-first-dom-report')
+ const draggedItem = EP.fromString(`sb/grid/row-1-column-2`)
+ await renderResult.dispatch(selectComponents([draggedItem], false), true)
+ await renderResult.getDispatchFollowUpActionsFinished()
+ const canvasControlsLayer = renderResult.renderedDOM.getByTestId(CanvasControlsContainerID)
+ const draggedItemElement = renderResult.renderedDOM.getByTestId(
+ GridCellTestId(EP.fromString(`sb/grid/row-1-column-2`)),
+ )
+ const draggedItemRect = draggedItemElement.getBoundingClientRect()
+ const startPoint = canvasPoint({
+ x: draggedItemRect.x + draggedItemRect.width / 2,
+ y: draggedItemRect.y + draggedItemRect.height / 2,
+ })
+ const swapTargetElement = renderResult.renderedDOM.getByTestId(
+ GridCellTestId(EP.fromString(`sb/grid/row-2-column-1`)),
+ )
+ const swapTargetRect = swapTargetElement.getBoundingClientRect()
+ const endPoint = canvasPoint({
+ x: swapTargetRect.x + swapTargetRect.width / 2,
+ y: swapTargetRect.y + swapTargetRect.height / 2,
+ })
+ await mouseMoveToPoint(draggedItemElement, startPoint)
+ await mouseDownAtPoint(draggedItemElement, startPoint)
+ await mouseMoveToPoint(canvasControlsLayer, endPoint)
+ await pressKey('Tab')
+ await renderResult.getDispatchFollowUpActionsFinished()
+ expect(
+ renderResult.getEditorState().editor.canvas.interactionSession?.userPreferredStrategy,
+ ).toEqual('rearrange-grid-swap-strategy')
+ await mouseUpAtPoint(canvasControlsLayer, endPoint)
+ await renderResult.getDispatchFollowUpActionsFinished()
+
+ expect(getPrintedUiJsCode(renderResult.getEditorState()))
+ .toEqual(`import * as React from 'react'
+import { Storyboard } from 'utopia-api'
+
+export var storyboard = (
+
+
+
+)
+`)
+ })
+})
diff --git a/editor/src/components/canvas/canvas-strategies/strategies/rearrange-grid-swap-strategy.ts b/editor/src/components/canvas/canvas-strategies/strategies/rearrange-grid-swap-strategy.ts
new file mode 100644
index 000000000000..a9179e482166
--- /dev/null
+++ b/editor/src/components/canvas/canvas-strategies/strategies/rearrange-grid-swap-strategy.ts
@@ -0,0 +1,216 @@
+import { MetadataUtils } from '../../../../core/model/element-metadata-utils'
+import { stripNulls } from '../../../../core/shared/array-utils'
+import * as EP from '../../../../core/shared/element-path'
+import type {
+ ElementInstanceMetadata,
+ GridElementProperties,
+ GridPosition,
+} from '../../../../core/shared/element-template'
+import {
+ isFiniteRectangle,
+ offsetPoint,
+ rectContainsPointInclusive,
+} from '../../../../core/shared/math-utils'
+import { optionalMap } from '../../../../core/shared/optional-utils'
+import type { ElementPath } from '../../../../core/shared/project-file-types'
+import { create } from '../../../../core/shared/property-path'
+import type { CanvasCommand } from '../../commands/commands'
+import { deleteProperties } from '../../commands/delete-properties-command'
+import { rearrangeChildren } from '../../commands/rearrange-children-command'
+import { setProperty } from '../../commands/set-property-command'
+import { GridControls } from '../../controls/grid-controls'
+import { recurseIntoChildrenOfMapOrFragment } from '../../gap-utils'
+import type { CanvasStrategyFactory } from '../canvas-strategies'
+import { onlyFitWhenDraggingThisControl } from '../canvas-strategies'
+import type { InteractionCanvasState } from '../canvas-strategy-types'
+import {
+ getTargetPathsFromInteractionTarget,
+ emptyStrategyApplicationResult,
+ strategyApplicationResult,
+} from '../canvas-strategy-types'
+import type { InteractionSession } from '../interaction-state'
+
+export const rearrangeGridSwapStrategy: CanvasStrategyFactory = (
+ canvasState: InteractionCanvasState,
+ interactionSession: InteractionSession | null,
+) => {
+ const selectedElements = getTargetPathsFromInteractionTarget(canvasState.interactionTarget)
+ if (selectedElements.length !== 1) {
+ return null
+ }
+
+ const selectedElement = selectedElements[0]
+ const ok = MetadataUtils.isGridLayoutedContainer(
+ MetadataUtils.findElementByElementPath(
+ canvasState.startingMetadata,
+ EP.parentPath(selectedElement),
+ ),
+ )
+ if (!ok) {
+ return null
+ }
+
+ const children = recurseIntoChildrenOfMapOrFragment(
+ canvasState.startingMetadata,
+ canvasState.startingAllElementProps,
+ canvasState.startingElementPathTree,
+ EP.parentPath(selectedElement),
+ )
+
+ return {
+ id: 'rearrange-grid-swap-strategy',
+ name: 'Rearrange Grid (Swap)',
+ descriptiveLabel: 'Rearrange Grid (Swap)',
+ icon: {
+ category: 'tools',
+ type: 'pointer',
+ },
+ controlsToRender: [
+ {
+ control: GridControls,
+ props: {},
+ key: `grid-controls-${EP.toString(selectedElement)}`,
+ show: 'always-visible',
+ },
+ ],
+ fitness: onlyFitWhenDraggingThisControl(interactionSession, 'GRID_CELL_HANDLE', 1),
+ apply: () => {
+ if (
+ interactionSession == null ||
+ interactionSession.interactionData.type !== 'DRAG' ||
+ interactionSession.interactionData.drag == null ||
+ interactionSession.activeControl.type !== 'GRID_CELL_HANDLE'
+ ) {
+ return emptyStrategyApplicationResult
+ }
+
+ const pointOnCanvas = offsetPoint(
+ interactionSession.interactionData.dragStart,
+ interactionSession.interactionData.drag,
+ )
+
+ const pointerOverChild = children.find(
+ (c) =>
+ c.globalFrame != null &&
+ isFiniteRectangle(c.globalFrame) &&
+ rectContainsPointInclusive(c.globalFrame, pointOnCanvas),
+ )
+
+ let commands: CanvasCommand[] = []
+
+ if (
+ pointerOverChild != null &&
+ EP.toUid(pointerOverChild.elementPath) !== interactionSession.activeControl.id
+ ) {
+ commands.push(
+ ...swapChildrenCommands({
+ grabbedElementUid: interactionSession.activeControl.id,
+ swapToElementUid: EP.toUid(pointerOverChild.elementPath),
+ children: children,
+ parentPath: EP.parentPath(selectedElement),
+ }),
+ )
+ }
+
+ if (commands == null) {
+ return emptyStrategyApplicationResult
+ }
+
+ return strategyApplicationResult(commands)
+ },
+ }
+}
+
+const GridPositioningProps: Array = [
+ 'gridColumn',
+ 'gridRow',
+ 'gridColumnStart',
+ 'gridColumnEnd',
+ 'gridRowStart',
+ 'gridRowEnd',
+]
+
+function gridPositionToValue(p: GridPosition | null): string | number | null {
+ if (p == null) {
+ return null
+ }
+ if (p === 'auto') {
+ return 'auto'
+ }
+
+ return p.numericalPosition
+}
+
+function setGridProps(elementPath: ElementPath, gridProps: GridElementProperties): CanvasCommand[] {
+ return stripNulls([
+ optionalMap(
+ (s) => setProperty('always', elementPath, create('style', 'gridColumnStart'), s),
+ gridPositionToValue(gridProps.gridColumnStart),
+ ),
+ optionalMap(
+ (s) => setProperty('always', elementPath, create('style', 'gridColumnEnd'), s),
+ gridPositionToValue(gridProps.gridColumnEnd),
+ ),
+ optionalMap(
+ (s) => setProperty('always', elementPath, create('style', 'gridRowStart'), s),
+ gridPositionToValue(gridProps.gridRowStart),
+ ),
+ optionalMap(
+ (s) => setProperty('always', elementPath, create('style', 'gridRowEnd'), s),
+ gridPositionToValue(gridProps.gridRowEnd),
+ ),
+ ])
+}
+
+function swapChildrenCommands({
+ grabbedElementUid,
+ swapToElementUid,
+ children,
+ parentPath,
+}: {
+ grabbedElementUid: string
+ swapToElementUid: string
+ children: ElementInstanceMetadata[]
+ parentPath: ElementPath
+}): CanvasCommand[] {
+ const grabbedElement = children.find((c) => EP.toUid(c.elementPath) === grabbedElementUid)
+ const swapToElement = children.find((c) => EP.toUid(c.elementPath) === swapToElementUid)
+
+ if (grabbedElement == null || swapToElement == null) {
+ return []
+ }
+
+ const rearrangedChildren = children
+ .map((c) => {
+ if (EP.pathsEqual(c.elementPath, grabbedElement.elementPath)) {
+ return swapToElement.elementPath
+ }
+ if (EP.pathsEqual(c.elementPath, swapToElement.elementPath)) {
+ return grabbedElement.elementPath
+ }
+ return c.elementPath
+ })
+ .map((path) => EP.dynamicPathToStaticPath(path))
+
+ return [
+ rearrangeChildren('always', parentPath, rearrangedChildren),
+ deleteProperties(
+ 'always',
+ swapToElement.elementPath,
+ GridPositioningProps.map((p) => create('style', p)),
+ ),
+ deleteProperties(
+ 'always',
+ grabbedElement.elementPath,
+ GridPositioningProps.map((p) => create('style', p)),
+ ),
+ ...setGridProps(
+ grabbedElement.elementPath,
+ swapToElement.specialSizeMeasurements.elementGridProperties,
+ ),
+ ...setGridProps(
+ swapToElement.elementPath,
+ grabbedElement.specialSizeMeasurements.elementGridProperties,
+ ),
+ ]
+}