diff --git a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx index e6113afb22e1..e3c443c0446c 100644 --- a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx +++ b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx @@ -80,6 +80,7 @@ import { wrapInContainerCommand } from '../commands/wrap-in-container-command' import type { ElementPath } from 'utopia-shared/src/types' import { reparentSubjectsForInteractionTarget } from './strategies/reparent-helpers/reparent-strategy-helpers' import { getReparentTargetUnified } from './strategies/reparent-helpers/reparent-strategy-parent-lookup' +import { gridRearrangeKeyboardStrategy } from './strategies/grid-rearrange-keyboard-strategy' export type CanvasStrategyFactory = ( canvasState: InteractionCanvasState, @@ -110,6 +111,7 @@ const moveOrReorderStrategies: MetaCanvasStrategy = ( gridRearrangeMoveStrategy, rearrangeGridSwapStrategy, gridRearrangeMoveDuplicateStrategy, + gridRearrangeKeyboardStrategy, ], ) } 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 a6360bbc3962..1d3b5d27451e 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/grid-helpers.ts +++ b/editor/src/components/canvas/canvas-strategies/strategies/grid-helpers.ts @@ -10,8 +10,10 @@ import { } from '../../../../core/shared/element-template' import type { CanvasVector, WindowRectangle } from '../../../../core/shared/math-utils' import { + isInfinityRectangle, offsetPoint, rectContainsPoint, + windowPoint, windowRectangle, type WindowPoint, } from '../../../../core/shared/math-utils' @@ -49,8 +51,8 @@ function isGridCellTargetId(id: string): boolean { return id.startsWith(gridCellTargetIdPrefix) } -function getGridCellAtPoint( - windowPoint: WindowPoint, +export function getGridCellAtPoint( + point: WindowPoint, duplicating: boolean, ): { id: string; coordinates: GridCellCoordinates; cellWindowRectangle: WindowRectangle } | null { function maybeRecursivelyFindCellAtPoint( @@ -61,7 +63,7 @@ function getGridCellAtPoint( if (isGridCellTargetId(element.id)) { const domRect = element.getBoundingClientRect() const windowRect = windowRectangle(domRect) - if (rectContainsPoint(windowRect, windowPoint)) { + if (rectContainsPoint(windowRect, point)) { return { element: element, cellWindowRectangle: windowRect } } } @@ -78,7 +80,7 @@ function getGridCellAtPoint( } const cellUnderMouse = maybeRecursivelyFindCellAtPoint( - document.elementsFromPoint(windowPoint.x, windowPoint.y), + document.elementsFromPoint(point.x, point.y), ) if (cellUnderMouse == null) { return null @@ -87,6 +89,7 @@ function getGridCellAtPoint( const { element, cellWindowRectangle } = cellUnderMouse const row = element.getAttribute('data-grid-row') const column = element.getAttribute('data-grid-column') + return { id: element.id, cellWindowRectangle: cellWindowRectangle, @@ -400,3 +403,53 @@ function asMaybeNamedAreaOrValue( } return value } + +const GRID_BOUNDS_TOLERANCE = 5 // px + +export function getGridCellBoundsFromCanvas( + cell: ElementInstanceMetadata, + canvasScale: number, + canvasOffset: CanvasVector, +) { + const cellFrame = cell.globalFrame + if (cellFrame == null || isInfinityRectangle(cellFrame)) { + return null + } + + const canvasFrameWidth = cellFrame.width * canvasScale + const canvasFrameHeight = cellFrame.height * canvasScale + + const cellOriginPoint = offsetPoint( + canvasPointToWindowPoint(cellFrame, canvasScale, canvasOffset), + windowPoint({ x: GRID_BOUNDS_TOLERANCE, y: GRID_BOUNDS_TOLERANCE }), + ) + const cellOrigin = getGridCellAtPoint(cellOriginPoint, true) + if (cellOrigin == null) { + return null + } + + const cellEndPoint = offsetPoint( + cellOriginPoint, + windowPoint({ + x: canvasFrameWidth - GRID_BOUNDS_TOLERANCE, + y: canvasFrameHeight - GRID_BOUNDS_TOLERANCE, + }), + ) + const cellEnd = getGridCellAtPoint(cellEndPoint, true) + if (cellEnd == null) { + return null + } + + const cellOriginCoords = cellOrigin.coordinates + const cellEndCoords = cellEnd.coordinates + + const cellWidth = cellEndCoords.column - cellOriginCoords.column + 1 + const cellHeight = cellEndCoords.row - cellOriginCoords.row + 1 + + return { + originCell: cellOriginCoords, + endCell: cellEndCoords, + width: cellWidth, + height: cellHeight, + } +} diff --git a/editor/src/components/canvas/canvas-strategies/strategies/grid-rearrange-keyboard-strategy.ts b/editor/src/components/canvas/canvas-strategies/strategies/grid-rearrange-keyboard-strategy.ts new file mode 100644 index 000000000000..9e345d92db1d --- /dev/null +++ b/editor/src/components/canvas/canvas-strategies/strategies/grid-rearrange-keyboard-strategy.ts @@ -0,0 +1,179 @@ +import { MetadataUtils } from '../../../../core/model/element-metadata-utils' +import * as EP from '../../../../core/shared/element-path' +import { + gridPositionValue, + type GridElementProperties, +} from '../../../../core/shared/element-template' +import { assertNever } from '../../../../core/shared/utils' +import { emptyModifiers, Modifier } from '../../../../utils/modifiers' +import { GridControls, GridControlsKey } from '../../controls/grid-controls' +import type { CanvasStrategy, InteractionCanvasState } from '../canvas-strategy-types' +import { + emptyStrategyApplicationResult, + getTargetPathsFromInteractionTarget, + strategyApplicationResult, +} from '../canvas-strategy-types' +import type { InteractionSession, KeyState } from '../interaction-state' +import { getGridCellBoundsFromCanvas, setGridPropsCommands } from './grid-helpers' +import { accumulatePresses } from './shared-keyboard-strategy-helpers' + +export function gridRearrangeKeyboardStrategy( + canvasState: InteractionCanvasState, + interactionSession: InteractionSession | null, +): CanvasStrategy | null { + if (interactionSession?.activeControl.type !== 'KEYBOARD_CATCHER_CONTROL') { + return null + } + + const selectedElements = getTargetPathsFromInteractionTarget(canvasState.interactionTarget) + if (selectedElements.length !== 1) { + return null + } + + const target = selectedElements[0] + + if (!MetadataUtils.isGridCell(canvasState.startingMetadata, target)) { + return null + } + + const cell = MetadataUtils.findElementByElementPath(canvasState.startingMetadata, target) + if (cell == null) { + return null + } + + const parentGridPath = EP.parentPath(target) + + const grid = MetadataUtils.findElementByElementPath(canvasState.startingMetadata, parentGridPath) + if (grid == null) { + return null + } + const gridTemplate = grid.specialSizeMeasurements.containerGridProperties + + const cellBounds = getGridCellBoundsFromCanvas(cell, canvasState.scale, canvasState.canvasOffset) + if (cellBounds == null) { + return null + } + + return { + id: 'GRID_KEYBOARD_REARRANGE', + name: 'Grid rearrange', + descriptiveLabel: 'Grid rearrange', + icon: { + category: 'modalities', + type: 'reorder-large', + }, + controlsToRender: [ + { + control: GridControls, + props: { targets: [parentGridPath] }, + key: GridControlsKey(parentGridPath), + show: 'always-visible', + priority: 'bottom', + }, + ], + fitness: fitness(interactionSession), + apply: () => { + if (interactionSession == null || interactionSession.interactionData.type !== 'KEYBOARD') { + return emptyStrategyApplicationResult + } + if ( + gridTemplate.gridTemplateColumns?.type !== 'DIMENSIONS' || + gridTemplate.gridTemplateRows?.type !== 'DIMENSIONS' + ) { + return emptyStrategyApplicationResult + } + + const interactionData = interactionSession.interactionData + + const horizontalDelta = getKeysDelta(interactionData.keyStates, 'horizontal') + const verticalDelta = getKeysDelta(interactionData.keyStates, 'vertical') + + let gridProps: Partial = { + ...cell.specialSizeMeasurements.elementGridProperties, + } + + if (horizontalDelta !== 0) { + const { from, to } = getNewBounds( + cellBounds.originCell.column + horizontalDelta, + gridTemplate.gridTemplateColumns.dimensions.length, + cellBounds.width, + ) + gridProps.gridColumnStart = gridPositionValue(from) + gridProps.gridColumnEnd = gridPositionValue(to) + } + + if (verticalDelta !== 0) { + const { from, to } = getNewBounds( + cellBounds.originCell.row + verticalDelta, + gridTemplate.gridTemplateRows.dimensions.length, + cellBounds.height, + ) + gridProps.gridRowStart = gridPositionValue(from) + gridProps.gridRowEnd = gridPositionValue(to) + } + + return strategyApplicationResult(setGridPropsCommands(target, gridTemplate, gridProps)) + }, + } +} + +function fitness(interactionSession: InteractionSession | null): number { + if (interactionSession == null || interactionSession.interactionData.type !== 'KEYBOARD') { + return 0 + } + + const accumulatedPresses = accumulatePresses(interactionSession.interactionData.keyStates) + const matches = accumulatedPresses.some( + (accumulatedPress) => + Array.from(accumulatedPress.keysPressed).some( + (key) => key === 'left' || key === 'right' || key === 'up' || key === 'down', + ) && Modifier.equal(accumulatedPress.modifiers, emptyModifiers), + ) + + return matches ? 1 : 0 +} + +function getKeysDelta(keyStates: KeyState[], direction: 'vertical' | 'horizontal'): number { + return keyStates.reduce((total, cur) => { + let presses = 0 + cur.keysPressed.forEach((key) => { + switch (direction) { + case 'horizontal': + presses += key === 'left' ? -1 : key === 'right' ? 1 : 0 + break + case 'vertical': + presses += key === 'up' ? -1 : key === 'down' ? 1 : 0 + break + default: + assertNever(direction) + } + }) + return total + presses + }, 0) +} + +function getNewBounds( + start: number, + cellsCount: number, + size: number, +): { + from: number + to: number +} { + const lowerLimit = 1 + const upperLimit = cellsCount + 1 + + let from = start + let to = start + size + + if (to > upperLimit) { + to = upperLimit + from = to - size + } + if (from < lowerLimit) { + from = lowerLimit + to = from + size + } + + return { from: from, to: to } +}