Skip to content

Commit

Permalink
feat(grid): controls on inspector hover (#6302)
Browse files Browse the repository at this point in the history
This PR displays subdued Grid controls on inspector hover, according to
the axis

<video
src="https://github.com/user-attachments/assets/6db0c006-f4ba-44d1-a025-99fffe719a78"></video>

**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
  • Loading branch information
liady authored Sep 4, 2024
1 parent 3abe36b commit 8e90d63
Show file tree
Hide file tree
Showing 2 changed files with 234 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import React from 'react'
import { useColorTheme } from '../../../../uuiui'
import { Substores, useEditorState, useRefEditorState } from '../../../editor/store/store-hook'
import { useBoundingBox } from '../bounding-box-hooks'
import { CanvasOffsetWrapper } from '../canvas-offset-wrapper'
import type { Axis } from '../../gap-utils'
import { gridGapControlBoundsFromMetadata, maybeGridGapData } from '../../gap-utils'
import type { ElementPath } from 'utopia-shared/src/types'
import { useGridData } from '../grid-controls'
import { fallbackEmptyValue } from './controls-common'
import type { CanvasRectangle } from '../../../../core/shared/math-utils'
import type { CSSNumber } from '../../../../components/inspector/common/css-utils'

export interface SubduedGridGapControlProps {
hoveredOrFocused: 'hovered' | 'focused'
axis: Axis | 'both'
}

export const SubduedGridGapControl = React.memo<SubduedGridGapControlProps>((props) => {
const { hoveredOrFocused, axis } = props
const targets = useEditorState(
Substores.selectedViews,
(store) => store.editor.selectedViews,
'SubduedGridGapControl selectedViews',
)
const scale = useEditorState(
Substores.canvas,
(store) => store.editor.canvas.scale,
'GridGapControl scale',
)
const metadata = useRefEditorState((store) => store.editor.jsxMetadata)
const selectedElement = targets.at(0)

const gridRowColumnInfo = useGridData(targets)
const selectedGrid = gridRowColumnInfo.at(0)

const filteredGaps = React.useMemo(() => {
if (selectedElement == null || selectedGrid == null) {
return []
}
const gridGap = maybeGridGapData(metadata.current, selectedElement)
if (gridGap == null) {
return []
}

const gridGapRow = gridGap.row
const gridGapColumn = gridGap.column

const controlBounds = gridGapControlBoundsFromMetadata(
selectedElement,
selectedGrid,
{
row: fallbackEmptyValue(gridGapRow),
column: fallbackEmptyValue(gridGapColumn),
},
scale,
)
return controlBounds.gaps.filter((gap) => gap.axis === axis || axis === 'both')
}, [axis, metadata, scale, selectedElement, selectedGrid])

if (filteredGaps.length === 0 || selectedElement == null) {
return null
}

return (
<>
{filteredGaps.map((gap) => (
<GridGapControl
key={gap.gapId}
targets={targets}
selectedElement={selectedElement}
hoveredOrFocused={hoveredOrFocused}
gap={gap}
/>
))}
</>
)
})

function GridGapControl({
targets,
selectedElement,
hoveredOrFocused,
gap,
}: {
targets: Array<ElementPath>
selectedElement: ElementPath
hoveredOrFocused: 'hovered' | 'focused'
gap: {
bounds: CanvasRectangle
gapId: string
gap: CSSNumber
axis: Axis
}
}) {
const metadata = useRefEditorState((store) => store.editor.jsxMetadata)
const scale = useEditorState(
Substores.canvas,
(store) => store.editor.canvas.scale,
'GridGapControl scale',
)
const gridRowColumnInfo = useGridData([selectedElement])

const sideRef = useBoundingBox([selectedElement], (ref, parentBoundingBox) => {
const gridGap = maybeGridGapData(metadata.current, selectedElement)
const selectedGrid = gridRowColumnInfo.at(0)
if (gridGap == null || selectedGrid == null) {
return
}

const controlBounds = gridGapControlBoundsFromMetadata(
selectedElement,
selectedGrid,
{
row: fallbackEmptyValue(gridGap.row),
column: fallbackEmptyValue(gridGap.column),
},
scale,
)

const bound = controlBounds.gaps.find((updatedGap) => updatedGap.gapId === gap.gapId)
if (bound == null) {
return
}

ref.current.style.display = 'block'
ref.current.style.left = `${bound.bounds.x + parentBoundingBox.x}px`
ref.current.style.top = `${bound.bounds.y + parentBoundingBox.y}px`
ref.current.style.height = numberToPxValue(bound.bounds.height)
ref.current.style.width = numberToPxValue(bound.bounds.width)
})

const color = useColorTheme().brandNeonPink.value

const solidOrDashed = hoveredOrFocused === 'focused' ? 'solid' : 'dashed'

return (
<CanvasOffsetWrapper>
<div
ref={sideRef}
style={{
position: 'absolute',
border: `1px ${solidOrDashed} ${color}`,
}}
data-testid={getSubduedGridGaplTestID(hoveredOrFocused)}
/>
</CanvasOffsetWrapper>
)
}

export function getSubduedGridGaplTestID(hoveredOrFocused: 'hovered' | 'focused'): string {
return `SubduedGridGapControl-${hoveredOrFocused}`
}

const numberToPxValue = (n: number) => n + 'px'
81 changes: 79 additions & 2 deletions editor/src/components/inspector/flex-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ import { NumberOrKeywordControl } from '../../uuiui/inputs/number-or-keyword-con
import { optionalMap } from '../../core/shared/optional-utils'
import { cssNumberEqual } from '../canvas/controls/select-mode/controls-common'
import type { EditorAction } from '../editor/action-types'
import type { CanvasControlWithProps } from './common/inspector-atoms'
import type { SubduedGridGapControlProps } from '../canvas/controls/select-mode/subdued-grid-gap-controls'
import { SubduedGridGapControl } from '../canvas/controls/select-mode/subdued-grid-gap-controls'
import {
useSetFocusedControlsHandlers,
useSetHoveredControlsHandlers,
} from '../canvas/controls/select-mode/select-mode-hooks'
import type { Axis } from '../canvas/gap-utils'

const axisDropdownMenuButton = 'axisDropdownMenuButton'

Expand Down Expand Up @@ -656,6 +664,22 @@ function serializeValue(v: CSSNumber) {

type GridGapControlSplitState = 'unified' | 'split'

function getGridGapControlsForHoverAndFocused(
hoveredOrFocused: 'hovered' | 'focused',
axis: Axis | 'both',
): Array<CanvasControlWithProps<SubduedGridGapControlProps>> {
return [
{
control: SubduedGridGapControl,
props: {
hoveredOrFocused: hoveredOrFocused,
axis: axis,
},
key: `subdued-grid-gap-${axis}-control-${hoveredOrFocused}`,
},
]
}

const GapRowColumnControl = React.memo(() => {
const dispatch = useDispatch()

Expand All @@ -681,6 +705,47 @@ const GapRowColumnControl = React.memo(() => {
const columnGap = useInspectorLayoutInfo('columnGap')
const rowGap = useInspectorLayoutInfo('rowGap')

const { onMouseEnter, onMouseLeave } = useSetHoveredControlsHandlers<SubduedGridGapControlProps>()

const onMouseEnterBothAxesWithGridGapControls = React.useCallback(
() => onMouseEnter(getGridGapControlsForHoverAndFocused('hovered', 'both')),
[onMouseEnter],
)
const onMouseEnterRowAxisWithGridGapControls = React.useCallback(
() => onMouseEnter(getGridGapControlsForHoverAndFocused('hovered', 'row')),
[onMouseEnter],
)
const onMouseEnterColumnAxisWithGridGapControls = React.useCallback(
() => onMouseEnter(getGridGapControlsForHoverAndFocused('hovered', 'column')),
[onMouseEnter],
)

const { onFocus, onBlur } = useSetFocusedControlsHandlers<SubduedGridGapControlProps>()

const bothAxesInputProps = React.useMemo(
() => ({
onFocus: () => onFocus(getGridGapControlsForHoverAndFocused('focused', 'both')),
onBlur: onBlur,
}),
[onFocus, onBlur],
)

const rowAxisInputProps = React.useMemo(
() => ({
onFocus: () => onFocus(getGridGapControlsForHoverAndFocused('focused', 'row')),
onBlur: onBlur,
}),
[onBlur, onFocus],
)

const columnAxisInputProps = React.useMemo(
() => ({
onFocus: () => onFocus(getGridGapControlsForHoverAndFocused('focused', 'column')),
onBlur: onBlur,
}),
[onBlur, onFocus],
)

const [controlSplitState, setControlSplitState] = React.useState<GridGapControlSplitState>(
cssNumberEqual(columnGap.value, rowGap.value) ? 'unified' : 'split',
)
Expand Down Expand Up @@ -798,7 +863,12 @@ const GapRowColumnControl = React.memo(() => {
<FlexRow style={{ justifyContent: 'space-between', gap: 8 }}>
{when(
controlSplitState === 'unified',
<UIGridRow padded={false} variant='<--1fr--><--1fr-->'>
<UIGridRow
padded={false}
variant='<--1fr--><--1fr-->'
onMouseEnter={onMouseEnterBothAxesWithGridGapControls}
onMouseLeave={onMouseLeave}
>
<NumberInput
value={columnGap.value}
numberType={'Length'}
Expand All @@ -807,13 +877,14 @@ const GapRowColumnControl = React.memo(() => {
onForcedSubmitValue={onSubmitUnifiedValue}
defaultUnitToHide={'px'}
testId={'grid-column-gap'}
inputProps={bothAxesInputProps}
innerLabel={<Icons.GapHorizontal color='on-highlight-secondary' />}
/>
</UIGridRow>,
)}
{when(
controlSplitState === 'split',
<UIGridRow padded={false} variant='<--1fr--><--1fr-->'>
<UIGridRow padded={false} variant='<--1fr--><--1fr-->' onMouseLeave={onMouseLeave}>
<NumberInput
value={columnGap.value}
numberType={'Length'}
Expand All @@ -822,6 +893,9 @@ const GapRowColumnControl = React.memo(() => {
onForcedSubmitValue={onSubmitSplitValue('columnGap')}
defaultUnitToHide={'px'}
testId={'grid-column-gap'}
inputProps={columnAxisInputProps}
onMouseEnter={onMouseEnterColumnAxisWithGridGapControls}
onMouseLeave={onMouseLeave}
innerLabel={<Icons.GapHorizontal color='on-highlight-secondary' />}
/>
<NumberInput
Expand All @@ -832,6 +906,9 @@ const GapRowColumnControl = React.memo(() => {
onForcedSubmitValue={onSubmitSplitValue('rowGap')}
defaultUnitToHide={'px'}
testId={'grid-row-gap'}
inputProps={rowAxisInputProps}
onMouseEnter={onMouseEnterRowAxisWithGridGapControls}
onMouseLeave={onMouseLeave}
innerLabel={<Icons.GapVertical color='on-highlight-secondary' />}
/>
</UIGridRow>,
Expand Down

0 comments on commit 8e90d63

Please sign in to comment.