Skip to content

Commit

Permalink
Rearrange grid with keyboard (#6298)
Browse files Browse the repository at this point in the history
**Problem:**

It should be possible to rearrange elements in a grid using the keyboard
arrows in addition to just the mouse.

**Fix:**

Add a new strategy that does that. It will enforce the movement to be
inside the available grid space, so it's not possible to overflow the
boundaries of the grid template.

![Kapture 2024-09-03 at 15 49
23](https://github.com/user-attachments/assets/954b13cb-32a8-4bb9-9626-e7bfd3513454)


Fixes #6296
  • Loading branch information
ruggi authored and liady committed Dec 13, 2024
1 parent 41e5b4e commit c700849
Show file tree
Hide file tree
Showing 3 changed files with 238 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -110,6 +111,7 @@ const moveOrReorderStrategies: MetaCanvasStrategy = (
gridRearrangeMoveStrategy,
rearrangeGridSwapStrategy,
gridRearrangeMoveDuplicateStrategy,
gridRearrangeKeyboardStrategy,
],
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
Expand All @@ -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 }
}
}
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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,
}
}
Original file line number Diff line number Diff line change
@@ -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<GridElementProperties> = {
...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 }
}

0 comments on commit c700849

Please sign in to comment.