Skip to content

Commit

Permalink
feat(canvas): grid gap controls (#6160)
Browse files Browse the repository at this point in the history
**Problem:**
We currently don't have inline grid gap handlers for the canvas

**Fix:**
This PR adds the inline gap handlers, calculating them according to the
grid that is used to display the placeholder cells.

<video
src="https://github.com/user-attachments/assets/eb219d35-5c37-4d1c-898d-73ab67ce5354"></video>

- [X] I opened a hydrogen project and it loaded
- [X] I could navigate to various routes in Preview mode

Fixes #6163
  • Loading branch information
liady authored Aug 28, 2024
1 parent 454cc9a commit 3a013be
Show file tree
Hide file tree
Showing 8 changed files with 967 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ 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'
import { setGridGapStrategy } from './strategies/set-grid-gap-strategy'
import type { CanvasCommand } from '../commands/commands'
import { foldAndApplyCommandsInner } from '../commands/commands'
import { updateFunctionCommand } from '../commands/update-function-command'
Expand Down Expand Up @@ -139,7 +140,7 @@ const propertyControlStrategies: MetaCanvasStrategy = (
): Array<CanvasStrategy> => {
return mapDropNulls(
(factory) => factory(canvasState, interactionSession, customStrategyState),
[setPaddingStrategy, setFlexGapStrategy, setBorderRadiusStrategy],
[setPaddingStrategy, setFlexGapStrategy, setGridGapStrategy, setBorderRadiusStrategy],
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
} from './canvas-strategy-types'
import { defaultCustomStrategyState } from './canvas-strategy-types'
import type { VariablesInScope } from '../ui-jsx-canvas'
import type { Axis } from '../gap-utils'

export type ZeroDragPermitted = 'zero-drag-permitted' | 'zero-drag-not-permitted'

Expand Down Expand Up @@ -576,18 +577,30 @@ export function flexGapHandle(): FlexGapHandle {

export interface GridAxisHandle {
type: 'GRID_AXIS_HANDLE'
axis: 'column' | 'row'
axis: Axis
columnOrRow: number
}

export function gridAxisHandle(axis: 'column' | 'row', columnOrRow: number): GridAxisHandle {
export function gridAxisHandle(axis: Axis, columnOrRow: number): GridAxisHandle {
return {
type: 'GRID_AXIS_HANDLE',
axis: axis,
columnOrRow: columnOrRow,
}
}

export interface GridGapHandle {
type: 'GRID_GAP_HANDLE'
axis: Axis
}

export function gridGapHandle(axis: Axis): GridGapHandle {
return {
type: 'GRID_GAP_HANDLE',
axis: axis,
}
}

export interface PaddingResizeHandle {
type: 'PADDING_RESIZE_HANDLE'
edgePiece: EdgePiece
Expand Down Expand Up @@ -680,6 +693,7 @@ export type CanvasControlType =
| BoundingArea
| ResizeHandle
| FlexGapHandle
| GridGapHandle
| PaddingResizeHandle
| KeyboardCatcherControl
| ReorderSlider
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import { styleStringInArray } from '../../../../utils/common-constants'
import { MetadataUtils } from '../../../../core/model/element-metadata-utils'
import type { CanvasVector } from '../../../../core/shared/math-utils'
import {
canvasPoint,
zeroRectIfNullOrInfinity,
canvasVector,
} from '../../../../core/shared/math-utils'
import { optionalMap } from '../../../../core/shared/optional-utils'
import type { Modifiers } from '../../../../utils/modifiers'
import { printCSSNumber } from '../../../inspector/common/css-utils'
import { stylePropPathMappingFn } from '../../../inspector/common/property-path-hooks'
import { deleteProperties } from '../../commands/delete-properties-command'
import { setCursorCommand } from '../../commands/set-cursor-command'
import { setElementsToRerenderCommand } from '../../commands/set-elements-to-rerender-command'
import { setProperty } from '../../commands/set-property-command'
import {
fallbackEmptyValue,
indicatorMessage,
offsetMeasurementByDelta,
precisionFromModifiers,
} from '../../controls/select-mode/controls-common'
import type { FloatingIndicatorProps } from '../../controls/select-mode/floating-number-indicator'
import { FloatingIndicator } from '../../controls/select-mode/floating-number-indicator'
import type { GridGapData } from '../../gap-utils'
import {
cursorFromAxis,
maybeGridGapData,
recurseIntoChildrenOfMapOrFragment,
} from '../../gap-utils'
import type { CanvasStrategyFactory } from '../canvas-strategies'
import { onlyFitWhenDraggingThisControl } from '../canvas-strategies'
import type { InteractionCanvasState } from '../canvas-strategy-types'
import {
controlWithProps,
emptyStrategyApplicationResult,
getTargetPathsFromInteractionTarget,
strategyApplicationResult,
} from '../canvas-strategy-types'
import type { InteractionSession } from '../interaction-state'
import { colorTheme } from '../../../../uuiui'
import { activeFrameTargetPath, setActiveFrames } from '../../commands/set-active-frames-command'
import { GridGapControl } from '../../controls/select-mode/grid-gap-control'

const SetGridGapStrategyId = 'SET_GRID_GAP_STRATEGY'

const StyleGapProp = stylePropPathMappingFn('gap', styleStringInArray)
const StyleRowGapProp = stylePropPathMappingFn('rowGap', styleStringInArray)
const StyleColumnGapProp = stylePropPathMappingFn('columnGap', styleStringInArray)

export const GridGapTearThreshold: number = -25

export const setGridGapStrategy: CanvasStrategyFactory = (
canvasState: InteractionCanvasState,
interactionSession: InteractionSession | null,
) => {
const selectedElements = getTargetPathsFromInteractionTarget(canvasState.interactionTarget)
if (selectedElements.length !== 1) {
return null
}

const selectedElement = selectedElements[0]
if (
!MetadataUtils.isGridLayoutedContainer(
MetadataUtils.findElementByElementPath(canvasState.startingMetadata, selectedElement),
)
) {
return null
}

const children = recurseIntoChildrenOfMapOrFragment(
canvasState.startingMetadata,
canvasState.startingAllElementProps,
canvasState.startingElementPathTree,
selectedElement,
)

if (children.length < 2) {
return null
}

const gridGap = maybeGridGapData(canvasState.startingMetadata, selectedElement)
if (gridGap == null) {
return null
}

const drag = dragFromInteractionSession(interactionSession) ?? canvasVector({ x: 0, y: 0 })

const dragDelta = {
x: Math.max(-gridGap.column.renderedValuePx, drag.x),
y: Math.max(-gridGap.row.renderedValuePx, drag.y),
}

const shouldTearOffGap = {
x: isDragOverThreshold({ gapPx: gridGap.column.renderedValuePx, deltaPx: drag.x }),
y: isDragOverThreshold({ gapPx: gridGap.row.renderedValuePx, deltaPx: drag.y }),
}

const adjustPrecision =
optionalMap(precisionFromModifiers, modifiersFromInteractionSession(interactionSession)) ??
'precise'

const updatedGridGapMeasurement = {
row: offsetMeasurementByDelta(gridGap.row, dragDelta.y, adjustPrecision),
column: offsetMeasurementByDelta(gridGap.column, dragDelta.x, adjustPrecision),
}

const resizeControl = controlWithProps({
control: GridGapControl,
props: {
selectedElement: selectedElement,
updatedGapValueRow: isDragOngoing(interactionSession) ? updatedGridGapMeasurement.row : null,
updatedGapValueColumn: isDragOngoing(interactionSession)
? updatedGridGapMeasurement.column
: null,
},
key: 'grid-gap-resize-control',
show: 'visible-except-when-other-strategy-is-active',
})

const maybeIndicatorProps = gridGapValueIndicatorProps(interactionSession, gridGap)

const controlsToRender = optionalMap(
(props) => [
resizeControl,
controlWithProps({
control: FloatingIndicator,
props: {
...props,
color: colorTheme.brandNeonPink.value,
},
key: 'padding-value-indicator-control',
show: 'visible-except-when-other-strategy-is-active',
}),
],
maybeIndicatorProps,
) ?? [resizeControl]

return {
id: SetGridGapStrategyId,
name: 'Set grid gap',
descriptiveLabel: 'Changing Grid Gap',
icon: {
category: 'tools',
type: 'pointer',
},
controlsToRender: controlsToRender,
fitness: onlyFitWhenDraggingThisControl(interactionSession, 'GRID_GAP_HANDLE', 1),
apply: () => {
if (
interactionSession == null ||
interactionSession.interactionData.type !== 'DRAG' ||
interactionSession.activeControl.type !== 'GRID_GAP_HANDLE'
) {
return emptyStrategyApplicationResult
}

const axis = interactionSession.activeControl.axis
const shouldTearOffGapByAxis = axis === 'row' ? shouldTearOffGap.y : shouldTearOffGap.x
const axisStyleProp = axis === 'row' ? StyleRowGapProp : StyleColumnGapProp
const gridGapMeasurement =
axis === 'row' ? updatedGridGapMeasurement.row : updatedGridGapMeasurement.column

if (shouldTearOffGapByAxis) {
return strategyApplicationResult([
deleteProperties('always', selectedElement, [axisStyleProp]),
])
}

return strategyApplicationResult([
setProperty(
'always',
selectedElement,
axisStyleProp,
printCSSNumber(fallbackEmptyValue(gridGapMeasurement), null),
),
setCursorCommand(cursorFromAxis(axis)),
setElementsToRerenderCommand([...selectedElements, ...children.map((c) => c.elementPath)]),
setActiveFrames([
{
action: 'set-gap',
target: activeFrameTargetPath(selectedElement),
source: zeroRectIfNullOrInfinity(
MetadataUtils.getFrameInCanvasCoords(selectedElement, canvasState.startingMetadata),
),
},
]),
])
},
}
}

function dragFromInteractionSession(
interactionSession: InteractionSession | null,
): CanvasVector | null {
if (interactionSession != null && interactionSession.interactionData.type === 'DRAG') {
return interactionSession.interactionData.drag
}
return null
}

function modifiersFromInteractionSession(
interactionSession: InteractionSession | null,
): Modifiers | null {
if (interactionSession != null && interactionSession.interactionData.type === 'DRAG') {
return interactionSession.interactionData.modifiers
}
return null
}

function isDragOverThreshold({ gapPx, deltaPx }: { gapPx: number; deltaPx: number }): boolean {
return deltaPx + gapPx < GridGapTearThreshold
}

function gridGapValueIndicatorProps(
interactionSession: InteractionSession | null,
gridGap: GridGapData,
): FloatingIndicatorProps | null {
if (
interactionSession == null ||
interactionSession.interactionData.type !== 'DRAG' ||
interactionSession.activeControl.type !== 'GRID_GAP_HANDLE' ||
interactionSession.interactionData.drag == null
) {
return null
}

const activeControlAxis = interactionSession.activeControl.axis

const { drag, dragStart } = interactionSession.interactionData

const rawDragDelta = activeControlAxis === 'row' ? drag.y : drag.x

const dragDelta = Math.max(-gridGap[activeControlAxis].renderedValuePx, rawDragDelta)

const rawGridGapMeasurement = offsetMeasurementByDelta(
gridGap[activeControlAxis],
rawDragDelta,
precisionFromModifiers(interactionSession.interactionData.modifiers),
)

const updatedGridGapMeasurement = offsetMeasurementByDelta(
gridGap[activeControlAxis],
dragDelta,
precisionFromModifiers(interactionSession.interactionData.modifiers),
)

const position =
activeControlAxis === 'row'
? canvasPoint({ x: dragStart.x, y: dragStart.y + drag.y })
: canvasPoint({ x: dragStart.x + drag.x, y: dragStart.y })

return {
value: indicatorMessage(
rawGridGapMeasurement.renderedValuePx > GridGapTearThreshold,
updatedGridGapMeasurement,
),
position: position,
}
}

function isDragOngoing(interactionSession: InteractionSession | null): boolean {
return (
interactionSession != null &&
interactionSession.activeControl.type === 'GRID_GAP_HANDLE' &&
interactionSession.interactionData.type === 'DRAG'
)
}
Loading

0 comments on commit 3a013be

Please sign in to comment.