From c918705fc6de5affa03054954be47ec8e541d0ec Mon Sep 17 00:00:00 2001 From: Sean Parsons <217400+seanparsons@users.noreply.github.com> Date: Tue, 2 Jul 2024 05:40:14 -0400 Subject: [PATCH] Resize A Grid (#6032) - Added `resizeGridStrategy` to `resizeStrategies`. - Added `GridAxisHandle` to `CanvasControlType`. - Implemented `resizeGridStrategy`. - Added `GridControls`. --- .../canvas-strategies/canvas-strategies.tsx | 4 +- .../canvas-strategies/interaction-state.ts | 15 + .../resize-grid-strategy.spec.browser2.tsx | 324 ++++++++++++++++++ .../strategies/resize-grid-strategy.ts | 138 ++++++++ .../canvas/controls/grid-controls.tsx | 271 +++++++++++++++ editor/src/components/canvas/dom-walker.ts | 5 +- .../store/store-deep-equality-instances.ts | 16 + .../components/inspector/common/css-utils.ts | 1 + editor/src/core/shared/element-template.ts | 1 + 9 files changed, 772 insertions(+), 3 deletions(-) create mode 100644 editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.spec.browser2.tsx create mode 100644 editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.ts create mode 100644 editor/src/components/canvas/controls/grid-controls.tsx diff --git a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx index e6fe7a6c43b0..b603dade300b 100644 --- a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx +++ b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx @@ -63,6 +63,7 @@ import type { InsertionSubject, InsertionSubjectWrapper } from '../../editor/edi import { generateUidWithExistingComponents } from '../../../core/model/element-template-utils' import { retargetStrategyToChildrenOfFragmentLikeElements } from './strategies/fragment-like-helpers' import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import { resizeGridStrategy } from './strategies/resize-grid-strategy' export type CanvasStrategyFactory = ( canvasState: InteractionCanvasState, @@ -100,13 +101,14 @@ const resizeStrategies: MetaCanvasStrategy = ( customStrategyState: CustomStrategyState, ): Array => { return mapDropNulls( - (factory) => factory(canvasState, interactionSession), + (factory) => factory(canvasState, interactionSession, customStrategyState), [ keyboardAbsoluteResizeStrategy, absoluteResizeBoundingBoxStrategy, flexResizeBasicStrategy, flexResizeStrategy, basicResizeStrategy, + resizeGridStrategy, ], ) } diff --git a/editor/src/components/canvas/canvas-strategies/interaction-state.ts b/editor/src/components/canvas/canvas-strategies/interaction-state.ts index eb28af93f218..238bf05bd1d8 100644 --- a/editor/src/components/canvas/canvas-strategies/interaction-state.ts +++ b/editor/src/components/canvas/canvas-strategies/interaction-state.ts @@ -559,6 +559,20 @@ export function flexGapHandle(): FlexGapHandle { } } +export interface GridAxisHandle { + type: 'GRID_AXIS_HANDLE' + axis: 'column' | 'row' + columnOrRow: number +} + +export function gridAxisHandle(axis: 'column' | 'row', columnOrRow: number): GridAxisHandle { + return { + type: 'GRID_AXIS_HANDLE', + axis: axis, + columnOrRow: columnOrRow, + } +} + export interface PaddingResizeHandle { type: 'PADDING_RESIZE_HANDLE' edgePiece: EdgePiece @@ -610,6 +624,7 @@ export type CanvasControlType = | KeyboardCatcherControl | ReorderSlider | BorderRadiusResizeHandle + | GridAxisHandle export function isDragToPan( interaction: InteractionSession | null, diff --git a/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.spec.browser2.tsx b/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.spec.browser2.tsx new file mode 100644 index 000000000000..1a504e059a11 --- /dev/null +++ b/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.spec.browser2.tsx @@ -0,0 +1,324 @@ +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 } from '../../event-helpers.test-utils' +import { canvasPoint } from '../../../../core/shared/math-utils' + +const testProject = ` +import * as React from 'react' +import { Storyboard } from 'utopia-api' + +export var storyboard = ( + +
+
+
+
+
+
+
+
+
+
+
+ +) +` + +describe('resize a grid', () => { + it('update a fractionally sized column', async () => { + const renderResult = await renderTestEditorWithCode(testProject, 'await-first-dom-report') + const target = EP.fromString(`sb/grid/row-1-column-2`) + await renderResult.dispatch(selectComponents([target], false), true) + await renderResult.getDispatchFollowUpActionsFinished() + const canvasControlsLayer = renderResult.renderedDOM.getByTestId(CanvasControlsContainerID) + const resizeControl = renderResult.renderedDOM.getByTestId(`grid-column-handle-1`) + const resizeControlRect = resizeControl.getBoundingClientRect() + const startPoint = canvasPoint({ + x: resizeControlRect.x + resizeControlRect.width / 2, + y: resizeControlRect.y + resizeControlRect.height / 2, + }) + const endPoint = canvasPoint({ + x: startPoint.x + 20, + y: startPoint.y, + }) + await mouseMoveToPoint(resizeControl, startPoint) + await mouseDownAtPoint(resizeControl, startPoint) + await mouseMoveToPoint(canvasControlsLayer, endPoint) + 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 = ( + +
+
+
+
+
+
+
+
+
+
+
+ +) +`) + }) + + it('update a pixel sized row', async () => { + const renderResult = await renderTestEditorWithCode(testProject, 'await-first-dom-report') + const target = EP.fromString(`sb/grid/row-1-column-2`) + await renderResult.dispatch(selectComponents([target], false), true) + await renderResult.getDispatchFollowUpActionsFinished() + const canvasControlsLayer = renderResult.renderedDOM.getByTestId(CanvasControlsContainerID) + const resizeControl = renderResult.renderedDOM.getByTestId(`grid-row-handle-1`) + const resizeControlRect = resizeControl.getBoundingClientRect() + const startPoint = canvasPoint({ + x: resizeControlRect.x + resizeControlRect.width / 2, + y: resizeControlRect.y + resizeControlRect.height / 2, + }) + const endPoint = canvasPoint({ + x: startPoint.x, + y: startPoint.y + 20, + }) + await mouseMoveToPoint(resizeControl, startPoint) + await mouseDownAtPoint(resizeControl, startPoint) + await mouseMoveToPoint(canvasControlsLayer, endPoint) + 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/resize-grid-strategy.ts b/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.ts new file mode 100644 index 000000000000..914bf68d1894 --- /dev/null +++ b/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.ts @@ -0,0 +1,138 @@ +import { + filtered, + fromArrayIndex, + fromField, + notNull, +} from '../../../../core/shared/optics/optic-creators' +import { MetadataUtils } from '../../../../core/model/element-metadata-utils' +import * as EP from '../../../../core/shared/element-path' +import * as PP from '../../../../core/shared/property-path' +import { setProperty } from '../../commands/set-property-command' +import { GridControls } from '../../controls/grid-controls' +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 type { CSSNumber, GridCSSNumber } from '../../../../components/inspector/common/css-utils' +import { printArrayCSSNumber } from '../../../../components/inspector/common/css-utils' +import { any, anyBy, modify, toFirst } from '../../../../core/shared/optics/optic-utilities' +import { setElementsToRerenderCommand } from '../../commands/set-elements-to-rerender-command' +import { isRight } from '../../../../core/shared/either' +import { roundToNearestWhole } from '../../../../core/shared/math-utils' + +export const resizeGridStrategy: CanvasStrategyFactory = ( + canvasState: InteractionCanvasState, + interactionSession: InteractionSession | null, +) => { + const selectedElements = getTargetPathsFromInteractionTarget(canvasState.interactionTarget) + if (selectedElements.length !== 1) { + return null + } + + const selectedElement = selectedElements[0] + const parentPath = EP.parentPath(selectedElement) + const ok = MetadataUtils.isGridLayoutedContainer( + MetadataUtils.findElementByElementPath(canvasState.startingMetadata, parentPath), + ) + if (!ok) { + return null + } + + return { + id: 'resize-grid-strategy', + name: 'Resize Grid', + descriptiveLabel: 'Resize Grid', + icon: { + category: 'tools', + type: 'pointer', + }, + controlsToRender: [ + { + control: GridControls, + props: {}, + key: `grid-controls-${EP.toString(selectedElement)}`, + show: 'always-visible', + }, + ], + fitness: onlyFitWhenDraggingThisControl(interactionSession, 'GRID_AXIS_HANDLE', 1), + apply: () => { + if ( + interactionSession == null || + interactionSession.interactionData.type !== 'DRAG' || + interactionSession.interactionData.drag == null || + interactionSession.activeControl.type !== 'GRID_AXIS_HANDLE' + ) { + return emptyStrategyApplicationResult + } + + const control = interactionSession.activeControl + const drag = interactionSession.interactionData.drag + const dragAmount = control.axis === 'column' ? drag.x : drag.y + const parentSpecialSizeMeasurements = + canvasState.startingMetadata[EP.toString(parentPath)].specialSizeMeasurements + const originalValues = + control.axis === 'column' + ? parentSpecialSizeMeasurements.containerGridPropertiesFromProps.gridTemplateColumns + : parentSpecialSizeMeasurements.containerGridPropertiesFromProps.gridTemplateRows + const calculatedValues = + control.axis === 'column' + ? parentSpecialSizeMeasurements.containerGridProperties.gridTemplateColumns + : parentSpecialSizeMeasurements.containerGridProperties.gridTemplateRows + + if ( + calculatedValues == null || + calculatedValues.type !== 'DIMENSIONS' || + originalValues == null || + originalValues.type !== 'DIMENSIONS' + ) { + return emptyStrategyApplicationResult + } + const unitOptic = fromArrayIndex(control.columnOrRow) + .compose(fromField('unit')) + .compose(notNull()) + const valueOptic = fromArrayIndex(control.columnOrRow).compose( + fromField('value'), + ) + const isFractional = anyBy(unitOptic, (unit) => unit === 'fr', originalValues.dimensions) + let newSetting: Array + const originalDimensions = originalValues.dimensions + if (isFractional) { + const possibleOriginalFractionalValue = toFirst(valueOptic, originalValues.dimensions) + const possibleCalculatedValue = toFirst(valueOptic, calculatedValues.dimensions) + if (isRight(possibleOriginalFractionalValue) && isRight(possibleCalculatedValue)) { + const originalFractionalValue = possibleOriginalFractionalValue.value + const calculatedValue = possibleCalculatedValue.value + const perPointOne = + originalFractionalValue == 0 ? 10 : (calculatedValue / originalFractionalValue) * 0.1 + const newValue = roundToNearestWhole((dragAmount / perPointOne) * 10) / 10 + newSetting = modify(valueOptic, (current) => current + newValue, originalDimensions) + } else { + throw new Error(`Somehow we cannot identify the right dimensions.`) + } + } else { + newSetting = modify(valueOptic, (current) => current + dragAmount, originalDimensions) + } + const propertyValueAsString = printArrayCSSNumber(newSetting) + + const commands = [ + setProperty( + 'always', + parentPath, + PP.create( + 'style', + control.axis === 'column' ? 'gridTemplateColumns' : 'gridTemplateRows', + ), + propertyValueAsString, + ), + setElementsToRerenderCommand([parentPath]), + ] + + return strategyApplicationResult(commands) + }, + } +} diff --git a/editor/src/components/canvas/controls/grid-controls.tsx b/editor/src/components/canvas/controls/grid-controls.tsx new file mode 100644 index 000000000000..ffbccd32495b --- /dev/null +++ b/editor/src/components/canvas/controls/grid-controls.tsx @@ -0,0 +1,271 @@ +import React from 'react' +import { CanvasOffsetWrapper } from './canvas-offset-wrapper' +import { Substores, useEditorState, useRefEditorState } from '../../editor/store/store-hook' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import * as EP from '../../../core/shared/element-path' +import { mapDropNulls } from '../../../core/shared/array-utils' +import { isFiniteRectangle, windowPoint } from '../../../core/shared/math-utils' +import { controlForStrategyMemoized } from '../canvas-strategies/canvas-strategy-types' +import { useDispatch } from '../../editor/store/dispatch-context' +import { createInteractionViaMouse, gridAxisHandle } from '../canvas-strategies/interaction-state' +import CanvasActions from '../canvas-actions' +import { Modifier } from '../../../utils/modifiers' +import { windowToCanvasCoordinates } from '../dom-lookup' +import { + isGridAutoOrTemplateDimensions, + type GridAutoOrTemplateBase, +} from '../../../core/shared/element-template' +import { assertNever } from '../../../core/shared/utils' +import type { GridCSSNumber } from '../../../components/inspector/common/css-utils' +import type { Optic } from '../../../core/shared/optics/optics' +import { + fromArrayIndex, + fromField, + fromTypeGuard, + notNull, +} from '../../../core/shared/optics/optic-creators' +import { toFirst } from '../../../core/shared/optics/optic-utilities' +import { defaultEither } from '../../../core/shared/either' + +function getSillyCellsCount(template: GridAutoOrTemplateBase | null): number { + if (template == null) { + return 0 + } else { + switch (template.type) { + case 'DIMENSIONS': + return template.dimensions.length + case 'FALLBACK': + return 0 + default: + assertNever(template) + } + } +} + +function getFromPropsOptic(index: number): Optic { + return notNull() + .compose(fromTypeGuard(isGridAutoOrTemplateDimensions)) + .compose(fromField('dimensions')) + .compose(fromArrayIndex(index)) +} + +function gridCSSNumberToLabel(gridCSSNumber: GridCSSNumber): string { + return `${gridCSSNumber.value}${gridCSSNumber.unit ?? ''}` +} + +function getLabelForAxis( + fromDOM: GridCSSNumber, + index: number, + fromProps: GridAutoOrTemplateBase | null, +): string { + const fromPropsAtIndex = toFirst(getFromPropsOptic(index), fromProps) + return gridCSSNumberToLabel(defaultEither(fromDOM, fromPropsAtIndex)) +} + +export const GridControls = controlForStrategyMemoized(() => { + const jsxMetadata = useEditorState( + Substores.fullStore, + (store) => store.editor.canvas.interactionSession?.latestMetadata ?? store.editor.jsxMetadata, + 'GridControls jsxMetadata', + ) + + const grids = useEditorState( + Substores.metadataAndPropertyControlsInfo, + (store) => { + return mapDropNulls((view) => { + const element = MetadataUtils.findElementByElementPath(jsxMetadata, view) + const parent = MetadataUtils.findElementByElementPath(jsxMetadata, EP.parentPath(view)) + + const targetGridContainer = MetadataUtils.isGridLayoutedContainer(element) + ? element + : MetadataUtils.isGridLayoutedContainer(parent) + ? parent + : null + + if ( + targetGridContainer == null || + targetGridContainer.globalFrame == null || + !isFiniteRectangle(targetGridContainer.globalFrame) + ) { + return null + } + + const gap = targetGridContainer.specialSizeMeasurements.gap + const padding = targetGridContainer.specialSizeMeasurements.padding + const gridTemplateColumns = + targetGridContainer.specialSizeMeasurements.containerGridProperties.gridTemplateColumns + const gridTemplateRows = + targetGridContainer.specialSizeMeasurements.containerGridProperties.gridTemplateRows + const gridTemplateColumnsFromProps = + targetGridContainer.specialSizeMeasurements.containerGridPropertiesFromProps + .gridTemplateColumns + const gridTemplateRowsFromProps = + targetGridContainer.specialSizeMeasurements.containerGridPropertiesFromProps + .gridTemplateRows + + const columns = getSillyCellsCount( + targetGridContainer.specialSizeMeasurements.containerGridProperties.gridTemplateColumns, + ) + const rows = getSillyCellsCount( + targetGridContainer.specialSizeMeasurements.containerGridProperties.gridTemplateRows, + ) + + return { + elementPath: targetGridContainer.elementPath, + frame: targetGridContainer.globalFrame, + gridTemplateColumns: gridTemplateColumns, + gridTemplateRows: gridTemplateRows, + gridTemplateColumnsFromProps: gridTemplateColumnsFromProps, + gridTemplateRowsFromProps: gridTemplateRowsFromProps, + gap: gap, + padding: padding, + rows: rows, + columns: columns, + cells: rows * columns, + } + }, store.editor.selectedViews) + }, + 'GridControls grids', + ) + + const dispatch = useDispatch() + + const canvasOffsetRef = useRefEditorState((store) => store.editor.canvas.roundedCanvasOffset) + const scaleRef = useRefEditorState((store) => store.editor.canvas.scale) + + return ( + + + {grids.flatMap((grid) => { + if (grid.gridTemplateColumns == null) { + return [] + } else { + switch (grid.gridTemplateColumns.type) { + case 'DIMENSIONS': + let workingPrefix: number = grid.frame.x + return grid.gridTemplateColumns.dimensions.flatMap((dimension, dimensionIndex) => { + // Assumes pixels currently. + workingPrefix += dimension.value + if (dimensionIndex !== 0) { + workingPrefix += grid.gap ?? 0 + } + function mouseDownHandler(event: React.MouseEvent): void { + const start = windowToCanvasCoordinates( + scaleRef.current, + canvasOffsetRef.current, + windowPoint({ x: event.nativeEvent.x, y: event.nativeEvent.y }), + ) + + dispatch([ + CanvasActions.createInteractionSession( + createInteractionViaMouse( + start.canvasPositionRounded, + Modifier.modifiersForEvent(event), + gridAxisHandle('column', dimensionIndex), + 'zero-drag-not-permitted', + ), + ), + ]) + event.stopPropagation() + event.preventDefault() + } + + return ( +
+ {getLabelForAxis( + dimension, + dimensionIndex, + grid.gridTemplateColumnsFromProps, + )} +
+ ) + }) + case 'FALLBACK': + return [] + default: + assertNever(grid.gridTemplateColumns) + return [] + } + } + })} + {grids.flatMap((grid) => { + if (grid.gridTemplateRows == null) { + return [] + } else { + switch (grid.gridTemplateRows.type) { + case 'DIMENSIONS': + let workingPrefix: number = grid.frame.y + return grid.gridTemplateRows.dimensions.flatMap((dimension, dimensionIndex) => { + // Assumes pixels currently. + workingPrefix += dimension.value + if (dimensionIndex !== 0) { + workingPrefix += grid.gap ?? 0 + } + function mouseDownHandler(event: React.MouseEvent): void { + const start = windowToCanvasCoordinates( + scaleRef.current, + canvasOffsetRef.current, + windowPoint({ x: event.nativeEvent.x, y: event.nativeEvent.y }), + ) + + dispatch([ + CanvasActions.createInteractionSession( + createInteractionViaMouse( + start.canvasPositionRounded, + Modifier.modifiersForEvent(event), + gridAxisHandle('row', dimensionIndex), + 'zero-drag-not-permitted', + ), + ), + ]) + event.stopPropagation() + event.preventDefault() + } + return ( +
+ {getLabelForAxis(dimension, dimensionIndex, grid.gridTemplateRowsFromProps)} +
+ ) + }) + case 'FALLBACK': + return [] + default: + assertNever(grid.gridTemplateRows) + return [] + } + } + })} +
+
+ ) +}) diff --git a/editor/src/components/canvas/dom-walker.ts b/editor/src/components/canvas/dom-walker.ts index 31a631a1de8e..24a0d35061ae 100644 --- a/editor/src/components/canvas/dom-walker.ts +++ b/editor/src/components/canvas/dom-walker.ts @@ -10,7 +10,7 @@ import type { StyleAttributeMetadata, ElementInstanceMetadataMap, GridContainerProperties, - GridElementProperties as ElementGridProperties, + GridElementProperties, } from '../../core/shared/element-template' import { elementInstanceMetadata, @@ -94,6 +94,7 @@ import { pick } from '../../core/shared/object-utils' import { getFlexAlignment, getFlexJustifyContent, MaxContent } from '../inspector/inspector-common' import type { EditorDispatch } from '../editor/action-types' import { runDOMWalker } from '../editor/actions/action-creators' +import { parseString } from '../../utils/value-parser-utils' export const ResizeObserver = window.ResizeObserver ?? ResizeObserverSyntheticDefault.default ?? ResizeObserverSyntheticDefault @@ -914,7 +915,7 @@ function getGridContainerProperties(elementStyle: CSSStyleDeclaration): GridCont ) } -function getGridElementProperties(elementStyle: CSSStyleDeclaration): ElementGridProperties { +function getGridElementProperties(elementStyle: CSSStyleDeclaration): GridElementProperties { const gridColumn = defaultEither(null, parseGridRange(elementStyle.gridColumn)) const gridColumnStart = defaultEither(null, parseGridPosition(elementStyle.gridColumnStart)) ?? 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 8ba573b85ff4..8da384999639 100644 --- a/editor/src/components/editor/store/store-deep-equality-instances.ts +++ b/editor/src/components/editor/store/store-deep-equality-instances.ts @@ -453,6 +453,7 @@ import type { ResizeHandle, BorderRadiusResizeHandle, ZeroDragPermitted, + GridAxisHandle, } from '../../canvas/canvas-strategies/interaction-state' import { boundingArea, @@ -461,6 +462,7 @@ import { interactionSession, keyboardCatcherControl, resizeHandle, + gridAxisHandle, } from '../../canvas/canvas-strategies/interaction-state' import type { Modifiers } from '../../../utils/modifiers' import type { @@ -2850,6 +2852,15 @@ export const BorderRadiusResizeHandleKeepDeepEquality: KeepDeepEqualityCall< return keepDeepEqualityResult(oldValue, true) } +export const GridAxisHandleKeepDeepEquality: KeepDeepEqualityCall = + combine2EqualityCalls( + (handle) => handle.axis, + createCallWithTripleEquals(), + (handle) => handle.columnOrRow, + createCallWithTripleEquals(), + gridAxisHandle, + ) + export const CanvasControlTypeKeepDeepEquality: KeepDeepEqualityCall = ( oldValue, newValue, @@ -2890,6 +2901,11 @@ export const CanvasControlTypeKeepDeepEquality: KeepDeepEqualityCall