From 542ddd64bb7880b8bee4d6c8d790c524597febbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bertalan=20K=C3=B6rmendy?= Date: Wed, 10 Jul 2024 10:38:34 +0200 Subject: [PATCH] Resize a Grid Element (#6038) ## Description This PR adds a strategy and the canvas controls to resize grid elements. https://github.com/concrete-utopia/utopia/assets/16385508/b048879c-6210-4a30-abbd-4bc310ce7a63 **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 | 4 +- .../canvas-strategies/interaction-state.ts | 18 ++ .../strategies/grid-helpers.ts | 53 +++- ...-resize-element-strategy.spec.browser2.tsx | 282 ++++++++++++++++++ .../grid-resize-element-strategy.ts | 179 +++++++++++ .../rearrange-grid-swap-strategy.ts | 46 +-- .../canvas/controls/grid-controls.tsx | 235 +++++++++++++++ .../store/store-deep-equality-instances.ts | 17 ++ 8 files changed, 786 insertions(+), 48 deletions(-) create mode 100644 editor/src/components/canvas/canvas-strategies/strategies/grid-resize-element-strategy.spec.browser2.tsx create mode 100644 editor/src/components/canvas/canvas-strategies/strategies/grid-resize-element-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 47c335e97dfb..b21d9353b610 100644 --- a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx +++ b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx @@ -66,6 +66,7 @@ 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' +import { gridResizeElementStrategy } from './strategies/grid-resize-element-strategy' import { gridRearrangeMoveDuplicateStrategy } from './strategies/grid-rearrange-move-duplicate-strategy' export type CanvasStrategyFactory = ( @@ -115,6 +116,7 @@ const resizeStrategies: MetaCanvasStrategy = ( flexResizeStrategy, basicResizeStrategy, resizeGridStrategy, + gridResizeElementStrategy, ], ) } @@ -521,7 +523,7 @@ export function isResizableStrategy(canvasStrategy: CanvasStrategy): boolean { case 'FLEX_RESIZE_BASIC': case 'FLEX_RESIZE': case 'BASIC_RESIZE': - // TODO add grid cell resize + case 'GRID-CELL-RESIZE-STRATEGY': return true default: return false diff --git a/editor/src/components/canvas/canvas-strategies/interaction-state.ts b/editor/src/components/canvas/canvas-strategies/interaction-state.ts index e51cb5477744..52e118ef560e 100644 --- a/editor/src/components/canvas/canvas-strategies/interaction-state.ts +++ b/editor/src/components/canvas/canvas-strategies/interaction-state.ts @@ -628,6 +628,23 @@ export function gridCellHandle(params: { id: string }): GridCellHandle { } } +export const GridResizeEdges = ['row-start', 'row-end', 'column-start', 'column-end'] as const +export type GridResizeEdge = (typeof GridResizeEdges)[number] + +export interface GridResizeHandle { + type: 'GRID_RESIZE_HANDLE' + id: string + edge: GridResizeEdge +} + +export function gridResizeHandle(id: string, edge: GridResizeEdge): GridResizeHandle { + return { + type: 'GRID_RESIZE_HANDLE', + id: id, + edge: edge, + } +} + export type CanvasControlType = | BoundingArea | ResizeHandle @@ -638,6 +655,7 @@ export type CanvasControlType = | BorderRadiusResizeHandle | GridCellHandle | GridAxisHandle + | GridResizeHandle export function isDragToPan( interaction: InteractionSession | null, diff --git a/editor/src/components/canvas/canvas-strategies/strategies/grid-helpers.ts b/editor/src/components/canvas/canvas-strategies/strategies/grid-helpers.ts index a9b7a90a15bc..f636e65ce7f2 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/grid-helpers.ts +++ b/editor/src/components/canvas/canvas-strategies/strategies/grid-helpers.ts @@ -4,6 +4,7 @@ import type { ElementInstanceMetadata, ElementInstanceMetadataMap, GridElementProperties, + GridPosition, } from '../../../../core/shared/element-template' import type { CanvasVector } from '../../../../core/shared/math-utils' import { @@ -13,15 +14,18 @@ import { windowRectangle, type WindowPoint, } from '../../../../core/shared/math-utils' -import { create } from '../../../../core/shared/property-path' +import * as PP from '../../../../core/shared/property-path' import type { CanvasCommand } from '../../commands/commands' import { setProperty } from '../../commands/set-property-command' import { canvasPointToWindowPoint } from '../../dom-lookup' import type { DragInteractionData } from '../interaction-state' +import { stripNulls } from '../../../../core/shared/array-utils' +import { optionalMap } from '../../../../core/shared/optional-utils' import type { GridCustomStrategyState } from '../canvas-strategy-types' import type { GridCellCoordinates } from '../../controls/grid-controls' import { gridCellCoordinates } from '../../controls/grid-controls' import * as EP from '../../../../core/shared/element-path' +import { deleteProperties } from '../../commands/delete-properties-command' export function getGridCellUnderMouse(mousePoint: WindowPoint, canvasScale: number) { return getGridCellAtPoint(mousePoint, canvasScale, false) @@ -173,10 +177,10 @@ export function runGridRearrangeMove( return { commands: [ - setProperty('always', targetElement, create('style', 'gridColumnStart'), column.start), - setProperty('always', targetElement, create('style', 'gridColumnEnd'), column.end), - setProperty('always', targetElement, create('style', 'gridRowStart'), row.start), - setProperty('always', targetElement, create('style', 'gridRowEnd'), row.end), + setProperty('always', targetElement, PP.create('style', 'gridColumnStart'), column.start), + setProperty('always', targetElement, PP.create('style', 'gridColumnEnd'), column.end), + setProperty('always', targetElement, PP.create('style', 'gridRowStart'), row.start), + setProperty('always', targetElement, PP.create('style', 'gridRowEnd'), row.end), ], targetCell: newTargetCell, originalRootCell: rootCell, @@ -185,6 +189,45 @@ export function runGridRearrangeMove( } } +export function gridPositionToValue(p: GridPosition | null | undefined): string | number | null { + if (p == null) { + return null + } + if (p === 'auto') { + return 'auto' + } + + return p.numericalPosition +} + +export function setGridPropsCommands( + elementPath: ElementPath, + gridProps: Partial, +): CanvasCommand[] { + return stripNulls([ + deleteProperties('always', elementPath, [ + PP.create('style', 'gridColumn'), + PP.create('style', 'gridRow'), + ]), + optionalMap( + (s) => setProperty('always', elementPath, PP.create('style', 'gridColumnStart'), s), + gridPositionToValue(gridProps?.gridColumnStart), + ), + optionalMap( + (s) => setProperty('always', elementPath, PP.create('style', 'gridColumnEnd'), s), + gridPositionToValue(gridProps?.gridColumnEnd), + ), + optionalMap( + (s) => setProperty('always', elementPath, PP.create('style', 'gridRowStart'), s), + gridPositionToValue(gridProps?.gridRowStart), + ), + optionalMap( + (s) => setProperty('always', elementPath, PP.create('style', 'gridRowEnd'), s), + gridPositionToValue(gridProps?.gridRowEnd), + ), + ]) +} + function getTargetCell( previousTargetCell: GridCellCoordinates | null, canvasScale: number, diff --git a/editor/src/components/canvas/canvas-strategies/strategies/grid-resize-element-strategy.spec.browser2.tsx b/editor/src/components/canvas/canvas-strategies/strategies/grid-resize-element-strategy.spec.browser2.tsx new file mode 100644 index 000000000000..eccff9e9e4e7 --- /dev/null +++ b/editor/src/components/canvas/canvas-strategies/strategies/grid-resize-element-strategy.spec.browser2.tsx @@ -0,0 +1,282 @@ +import * as EP from '../../../../core/shared/element-path' +import { getRectCenter, localRectangle } from '../../../../core/shared/math-utils' +import { selectComponentsForTest } from '../../../../utils/utils.test-utils' +import { GridResizeEdgeTestId } from '../../controls/grid-controls' +import { mouseDragFromPointToPoint } from '../../event-helpers.test-utils' +import type { EditorRenderResult } from '../../ui-jsx.test-utils' +import { renderTestEditorWithCode } from '../../ui-jsx.test-utils' +import type { GridResizeEdge } from '../interaction-state' +import { gridCellTargetId } from './grid-helpers' + +async function runCellResizeTest( + editor: EditorRenderResult, + edge: GridResizeEdge, + dragToCellTestId: string, +) { + const elementPathToDrag = EP.fromString('sb/scene/grid/ddd') + + await selectComponentsForTest(editor, [elementPathToDrag]) + + const resizeControl = editor.renderedDOM.getByTestId(GridResizeEdgeTestId(edge)) + const targetGridCell = editor.renderedDOM.getByTestId(dragToCellTestId) + + await mouseDragFromPointToPoint( + resizeControl, + { + x: resizeControl.getBoundingClientRect().x + 2, + y: resizeControl.getBoundingClientRect().y + 2, + }, + getRectCenter( + localRectangle({ + x: targetGridCell.getBoundingClientRect().x, + y: targetGridCell.getBoundingClientRect().y, + width: targetGridCell.getBoundingClientRect().width, + height: targetGridCell.getBoundingClientRect().height, + }), + ), + ) +} + +describe('grid rearrange move strategy', () => { + describe('column-end', () => { + it('can enlarge element', async () => { + const editor = await renderTestEditorWithCode(ProjectCode, 'await-first-dom-report') + + await runCellResizeTest( + editor, + 'column-end', + gridCellTargetId(EP.fromString('sb/scene/grid'), 2, 10), + ) + + const { gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd } = + editor.renderedDOM.getByTestId('grid-child').style + expect({ gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd }).toEqual({ + gridColumnEnd: '11', + gridColumnStart: '7', + gridRowEnd: '3', + gridRowStart: '2', + }) + }) + + it('can shrink element', async () => { + const editor = await renderTestEditorWithCode(ProjectCode, 'await-first-dom-report') + + await runCellResizeTest( + editor, + 'column-end', + gridCellTargetId(EP.fromString('sb/scene/grid'), 2, 8), + ) + + const { gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd } = + editor.renderedDOM.getByTestId('grid-child').style + expect({ gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd }).toEqual({ + gridColumnEnd: '9', + gridColumnStart: '7', + gridRowEnd: '3', + gridRowStart: '2', + }) + }) + }) + + describe('column-start', () => { + it('can enlarge element', async () => { + const editor = await renderTestEditorWithCode(ProjectCode, 'await-first-dom-report') + + await runCellResizeTest( + editor, + 'column-start', + gridCellTargetId(EP.fromString('sb/scene/grid'), 2, 6), + ) + + const { gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd } = + editor.renderedDOM.getByTestId('grid-child').style + expect({ gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd }).toEqual({ + gridColumnEnd: '10', + gridColumnStart: '6', + gridRowEnd: '3', + gridRowStart: '2', + }) + }) + + it('can shrink element', async () => { + const editor = await renderTestEditorWithCode(ProjectCode, 'await-first-dom-report') + + await runCellResizeTest( + editor, + 'column-start', + gridCellTargetId(EP.fromString('sb/scene/grid'), 2, 8), + ) + + const { gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd } = + editor.renderedDOM.getByTestId('grid-child').style + expect({ gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd }).toEqual({ + gridColumnEnd: '10', + gridColumnStart: '8', + gridRowEnd: '3', + gridRowStart: '2', + }) + }) + }) + + describe('row-end', () => { + it('can resize element', async () => { + const editor = await renderTestEditorWithCode(ProjectCode, 'await-first-dom-report') + + await runCellResizeTest( + editor, + 'row-end', + gridCellTargetId(EP.fromString('sb/scene/grid'), 3, 6), + ) + { + const { gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd } = + editor.renderedDOM.getByTestId('grid-child').style + expect({ gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd }).toEqual({ + gridColumnEnd: '10', + gridColumnStart: '7', + gridRowEnd: '4', + gridRowStart: '2', + }) + } + + await runCellResizeTest( + editor, + 'row-end', + gridCellTargetId(EP.fromString('sb/scene/grid'), 2, 8), + ) + { + const { gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd } = + editor.renderedDOM.getByTestId('grid-child').style + expect({ gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd }).toEqual({ + gridColumnEnd: '10', + gridColumnStart: '7', + gridRowEnd: '3', + gridRowStart: '2', + }) + } + }) + }) + + describe('row-start', () => { + it('can resize element', async () => { + const editor = await renderTestEditorWithCode(ProjectCode, 'await-first-dom-report') + + await runCellResizeTest( + editor, + 'row-start', + gridCellTargetId(EP.fromString('sb/scene/grid'), 1, 6), + ) + + { + const { gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd } = + editor.renderedDOM.getByTestId('grid-child').style + expect({ gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd }).toEqual({ + gridColumnEnd: '10', + gridColumnStart: '7', + gridRowEnd: '3', + gridRowStart: '1', + }) + } + + { + await runCellResizeTest( + editor, + 'row-start', + gridCellTargetId(EP.fromString('sb/scene/grid'), 2, 8), + ) + + const { gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd } = + editor.renderedDOM.getByTestId('grid-child').style + expect({ gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd }).toEqual({ + gridColumnEnd: '10', + gridColumnStart: '7', + gridRowEnd: '3', + gridRowStart: '2', + }) + } + }) + }) +}) + +const ProjectCode = `import * as React from 'react' +import { Scene, Storyboard, Placeholder } from 'utopia-api' + +export var storyboard = ( + + +
+
+ + +
+
+ + +) +` diff --git a/editor/src/components/canvas/canvas-strategies/strategies/grid-resize-element-strategy.ts b/editor/src/components/canvas/canvas-strategies/strategies/grid-resize-element-strategy.ts new file mode 100644 index 000000000000..ed7f3139c487 --- /dev/null +++ b/editor/src/components/canvas/canvas-strategies/strategies/grid-resize-element-strategy.ts @@ -0,0 +1,179 @@ +import { MetadataUtils } from '../../../../core/model/element-metadata-utils' +import * as EP from '../../../../core/shared/element-path' +import type { GridElementProperties, GridPosition } from '../../../../core/shared/element-template' +import { offsetPoint } from '../../../../core/shared/math-utils' +import { assertNever } from '../../../../core/shared/utils' +import { GridControls, GridResizeControls } from '../../controls/grid-controls' +import { canvasPointToWindowPoint } from '../../dom-lookup' +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' +import { getGridCellUnderMouse, setGridPropsCommands } from './grid-helpers' + +export const gridResizeElementStrategy: CanvasStrategyFactory = ( + canvasState: InteractionCanvasState, + interactionSession: InteractionSession | null, + customState, +) => { + const selectedElements = getTargetPathsFromInteractionTarget(canvasState.interactionTarget) + if (selectedElements.length !== 1) { + return null + } + + const selectedElement = selectedElements[0] + const isElementInsideGrid = MetadataUtils.isGridCell( + canvasState.startingMetadata, + selectedElement, + ) + if (!isElementInsideGrid) { + return null + } + + return { + id: 'GRID-CELL-RESIZE-STRATEGY', + name: 'Resize Grid Cell', + descriptiveLabel: 'Resize Grid Cell', + icon: { + category: 'tools', + type: 'pointer', + }, + controlsToRender: [ + { + control: GridResizeControls, + props: { target: selectedElement }, + key: `grid-resize-controls-${EP.toString(selectedElement)}`, + show: 'always-visible', + }, + { + control: GridControls, + props: {}, + key: `grid-controls-${EP.toString(selectedElement)}`, + show: 'always-visible', + }, + ], + fitness: onlyFitWhenDraggingThisControl(interactionSession, 'GRID_RESIZE_HANDLE', 1), + apply: () => { + if ( + interactionSession == null || + interactionSession.interactionData.type !== 'DRAG' || + interactionSession.interactionData.drag == null || + interactionSession.activeControl.type !== 'GRID_RESIZE_HANDLE' + ) { + return emptyStrategyApplicationResult + } + + const mouseWindowPoint = canvasPointToWindowPoint( + offsetPoint( + interactionSession.interactionData.dragStart, + interactionSession.interactionData.drag, + ), + canvasState.scale, + canvasState.canvasOffset, + ) + + let targetCell = customState.grid.targetCell + const cellUnderMouse = getGridCellUnderMouse(mouseWindowPoint) + if (cellUnderMouse != null) { + targetCell = cellUnderMouse.coordinates + } + + if (targetCell == null) { + return emptyStrategyApplicationResult + } + + let gridProps: GridElementProperties = MetadataUtils.findElementByElementPath( + canvasState.startingMetadata, + selectedElement, + )?.specialSizeMeasurements.elementGridProperties ?? { + gridColumnEnd: { numericalPosition: 0 }, + gridColumnStart: { numericalPosition: 0 }, + gridRowEnd: { numericalPosition: 0 }, + gridRowStart: { numericalPosition: 0 }, + } + + switch (interactionSession.activeControl.edge) { + case 'column-start': + gridProps = { + ...gridProps, + gridColumnStart: { numericalPosition: targetCell.column }, + } + break + case 'column-end': + gridProps = { + ...gridProps, + gridColumnEnd: { numericalPosition: targetCell.column + 1 }, + } + break + case 'row-end': + gridProps = { + ...gridProps, + gridRowEnd: { numericalPosition: targetCell.row + 1 }, + } + break + case 'row-start': + gridProps = { + ...gridProps, + gridRowStart: { numericalPosition: targetCell.row }, + } + break + default: + assertNever(interactionSession.activeControl.edge) + } + + return strategyApplicationResult( + setGridPropsCommands(selectedElement, gridPropsWithDragOver(gridProps)), + { + grid: { ...customState.grid, targetCell }, + }, + ) + }, + } +} + +function orderedGridPositions({ + start, + end, +}: { + start: GridPosition | null + end: GridPosition | null +}): { + start: GridPosition | null + end: GridPosition | null +} { + if ( + start == null || + start === 'auto' || + start.numericalPosition == null || + end == null || + end === 'auto' || + end.numericalPosition == null + ) { + return { start, end } + } + + return start.numericalPosition < end.numericalPosition + ? { start, end } + : { + start: { numericalPosition: end.numericalPosition - 1 }, + end: { numericalPosition: start.numericalPosition + 1 }, + } +} + +function gridPropsWithDragOver(props: GridElementProperties): GridElementProperties { + const { start: gridColumnStart, end: gridColumnEnd } = orderedGridPositions({ + start: props.gridColumnStart, + end: props.gridColumnEnd, + }) + const { start: gridRowStart, end: gridRowEnd } = orderedGridPositions({ + start: props.gridRowStart, + end: props.gridRowEnd, + }) + + return { gridRowStart, gridRowEnd, gridColumnStart, gridColumnEnd } +} 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 index ecd46c2d6fd2..b6f7d54c5bb7 100644 --- 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 @@ -1,23 +1,16 @@ 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 type { ElementInstanceMetadata } 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' @@ -29,6 +22,7 @@ import { strategyApplicationResult, } from '../canvas-strategy-types' import type { InteractionSession } from '../interaction-state' +import { setGridPropsCommands } from './grid-helpers' export const rearrangeGridSwapStrategy: CanvasStrategyFactory = ( canvasState: InteractionCanvasState, @@ -132,38 +126,6 @@ const GridPositioningProps: Array = [ '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, @@ -206,11 +168,11 @@ function swapChildrenCommands({ grabbedElement.elementPath, GridPositioningProps.map((p) => create('style', p)), ), - ...setGridProps( + ...setGridPropsCommands( grabbedElement.elementPath, swapToElement.specialSizeMeasurements.elementGridProperties, ), - ...setGridProps( + ...setGridPropsCommands( swapToElement.elementPath, grabbedElement.specialSizeMeasurements.elementGridProperties, ), diff --git a/editor/src/components/canvas/controls/grid-controls.tsx b/editor/src/components/canvas/controls/grid-controls.tsx index 83d2164ff922..5dfc27863986 100644 --- a/editor/src/components/canvas/controls/grid-controls.tsx +++ b/editor/src/components/canvas/controls/grid-controls.tsx @@ -18,10 +18,12 @@ import { distance, getRectCenter, isFiniteRectangle, + isInfinityRectangle, offsetPoint, pointDifference, pointsEqual, windowPoint, + zeroRectIfNullOrInfinity, } from '../../../core/shared/math-utils' import { fromArrayIndex, @@ -39,15 +41,27 @@ import { Substores, useEditorState, useRefEditorState } from '../../editor/store import { useRollYourOwnFeatures } from '../../navigator/left-pane/roll-your-own-pane' import CanvasActions from '../canvas-actions' import { controlForStrategyMemoized } from '../canvas-strategies/canvas-strategy-types' +import type { GridResizeEdge } from '../canvas-strategies/interaction-state' import { + GridResizeEdges, createInteractionViaMouse, gridAxisHandle, gridCellHandle, + gridResizeHandle, } from '../canvas-strategies/interaction-state' import { windowToCanvasCoordinates } from '../dom-lookup' import { CanvasOffsetWrapper } from './canvas-offset-wrapper' import { useColorTheme } from '../../../uuiui' import { gridCellTargetId } from '../canvas-strategies/strategies/grid-helpers' +import { resizeBoundingBoxFromSide } from '../canvas-strategies/strategies/resize-helpers' +import type { EdgePosition } from '../canvas-types' +import { + CSSCursor, + EdgePositionBottom, + EdgePositionLeft, + EdgePositionRight, + EdgePositionTop, +} from '../canvas-types' import { useCanvasAnimation } from '../ui-jsx-canvas-renderer/animation-context' import { CanvasLabel } from './select-mode/controls-common' import { optionalMap } from '../../../core/shared/optional-utils' @@ -884,3 +898,224 @@ function useMouseMove(activelyDraggingOrResizingCell: string | null) { return { hoveringStart, mouseCanvasPosition } } + +export const GridResizeEdgeTestId = (edge: GridResizeEdge) => `grid-resize-edge-${edge}` + +interface GridResizeControlProps { + target: ElementPath +} + +export const GridResizeControls = controlForStrategyMemoized( + ({ target }) => { + const element = useEditorState( + Substores.metadata, + (store) => MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, target), + 'GridResizeShadow element', + ) + + const dispatch = useDispatch() + const canvasOffsetRef = useRefEditorState((store) => store.editor.canvas.roundedCanvasOffset) + const scale = useEditorState( + Substores.canvas, + (store) => store.editor.canvas.scale, + 'GridResizingControl scale', + ) + + const resizeControlRef = useRefEditorState((store) => + store.editor.canvas.interactionSession?.activeControl.type !== 'GRID_RESIZE_HANDLE' + ? null + : store.editor.canvas.interactionSession.activeControl, + ) + + const dragRef = useRefEditorState((store) => + store.editor.canvas.interactionSession?.interactionData.type !== 'DRAG' + ? null + : store.editor.canvas.interactionSession?.interactionData.drag, + ) + + const [startingBounds, setStartingBounds] = React.useState(null) + const [bounds, setBounds] = React.useState(null) + const onMouseMove = React.useCallback(() => { + if (resizeControlRef.current == null || dragRef.current == null) { + return + } + + if (startingBounds == null) { + return + } + + setBounds( + resizeBoundingBoxFromSide( + startingBounds, + dragRef.current, + gridEdgeToEdgePosition(resizeControlRef.current.edge), + 'non-center-based', + null, + ), + ) + }, [dragRef, resizeControlRef, startingBounds]) + + const onMouseUp = React.useCallback(() => { + setBounds(null) + setStartingBounds(null) + }, []) + + React.useEffect(() => { + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + return () => { + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', onMouseUp) + } + }, [onMouseMove, onMouseUp]) + + const startResizeInteraction = React.useCallback( + (uid: string, edge: GridResizeEdge) => (event: React.MouseEvent) => { + event.stopPropagation() + const frame = zeroRectIfNullOrInfinity(element?.globalFrame ?? null) + setBounds(frame) + setStartingBounds(frame) + const start = windowToCanvasCoordinates( + scale, + canvasOffsetRef.current, + windowPoint({ x: event.nativeEvent.x, y: event.nativeEvent.y }), + ) + dispatch([ + CanvasActions.createInteractionSession( + createInteractionViaMouse( + start.canvasPositionRounded, + Modifier.modifiersForEvent(event), + gridResizeHandle(uid, edge), + 'zero-drag-not-permitted', + ), + ), + ]) + }, + [canvasOffsetRef, dispatch, element?.globalFrame, scale], + ) + + if ( + element == null || + element.globalFrame == null || + isInfinityRectangle(element.globalFrame) + ) { + return null + } + + return ( + +
+ {GridResizeEdges.map((edge) => ( +
+
+
+ ))} +
+ + ) + }, +) + +function gridEdgeToGridArea(edge: GridResizeEdge): string { + switch (edge) { + case 'column-end': + return 'ce' + case 'column-start': + return 'cs' + case 'row-end': + return 're' + case 'row-start': + return 'rs' + default: + assertNever(edge) + } +} + +function gridEdgeToEdgePosition(edge: GridResizeEdge): EdgePosition { + switch (edge) { + case 'column-end': + return EdgePositionRight + case 'column-start': + return EdgePositionLeft + case 'row-end': + return EdgePositionBottom + case 'row-start': + return EdgePositionTop + default: + assertNever(edge) + } +} + +function gridEdgeToCSSCursor(edge: GridResizeEdge): CSSCursor { + switch (edge) { + case 'column-end': + case 'column-start': + return CSSCursor.ColResize + case 'row-end': + case 'row-start': + return CSSCursor.RowResize + default: + assertNever(edge) + } +} + +function gridEdgeToWidthHeight( + edge: GridResizeEdge, + scale: number, +): { + width: number + height: number + borderRadius: number +} { + const LONG_EDGE = 24 / scale + const SHORT_EDGE = 4 / scale + + switch (edge) { + case 'column-end': + case 'column-start': + return { width: SHORT_EDGE, height: LONG_EDGE, borderRadius: SHORT_EDGE / 2 } + case 'row-end': + case 'row-start': + return { width: LONG_EDGE, height: SHORT_EDGE, borderRadius: SHORT_EDGE / 2 } + default: + assertNever(edge) + } +} diff --git a/editor/src/components/editor/store/store-deep-equality-instances.ts b/editor/src/components/editor/store/store-deep-equality-instances.ts index 7bae85303aa3..74275546523f 100644 --- a/editor/src/components/editor/store/store-deep-equality-instances.ts +++ b/editor/src/components/editor/store/store-deep-equality-instances.ts @@ -455,6 +455,8 @@ import type { ZeroDragPermitted, GridCellHandle, GridAxisHandle, + GridResizeHandle, + GridResizeEdge, } from '../../canvas/canvas-strategies/interaction-state' import { boundingArea, @@ -465,6 +467,7 @@ import { resizeHandle, gridCellHandle, gridAxisHandle, + gridResizeHandle, } from '../../canvas/canvas-strategies/interaction-state' import type { Modifiers } from '../../../utils/modifiers' import type { @@ -2871,6 +2874,15 @@ export const GridAxisHandleKeepDeepEquality: KeepDeepEqualityCall = + combine2EqualityCalls( + (handle) => handle.id, + createCallWithTripleEquals(), + (handle) => handle.edge, + createCallWithTripleEquals(), + gridResizeHandle, + ) + export const CanvasControlTypeKeepDeepEquality: KeepDeepEqualityCall = ( oldValue, newValue, @@ -2921,6 +2933,11 @@ export const CanvasControlTypeKeepDeepEquality: KeepDeepEqualityCall