From 9d74ac4ddb40009e194d68eff623b1dd9c1c256f Mon Sep 17 00:00:00 2001 From: Balazs Bajorics <2226774+balazsbajorics@users.noreply.github.com> Date: Tue, 15 Oct 2024 21:00:55 +0200 Subject: [PATCH] Grid Gap Control 2 (#6538) **Problem:** The orange grid gap controls are sometimes 1px off, they are one frame behind, they show weird delays when the canvas zooms. **Fix:** A lot of the problem came down to `gridGapControlBoundsFromMetadata` trying to calculate the bounding boxes of where the gaps are in the selected Grid. However this is error prone because a lot of these boxes will fall on fractional pixel values, and we were not rounding them the same way Chrome was rounding the real grid. Instead of trying to fix the math, I had an idea to replicate our hack for the grid cell outline controls: take the ComputedStyle.gridTemplateRows / Columns, and create a helper grid which has the correct position and sizing for our gap outlines. The trick is that we want to take the original grid's gap value, and turn those into grid tracks too, setting the helper grid's gap to zero. So if my original grid's ComputedStyle has tracks [5px 10px 15px 5px] and a gap of 13px, we would create a helper grid with the tracks [5px 13px 10px 13px 15px 13px 5px]! Now the only job is to fill it with the elements and make sure to only draw anything in the tracks that correspond to gaps in the original grid (they are always the odd numbered tracks). This means we can offload the entire positioning and rendering to Chrome, and makes our life much simpler going forward. **Commit Details:** - Fixed Hot Reload by creating a new file `grid-gap-control-component.tsx` - Brand new `GridGapControlComponent`, `GridPaddingOutlineForDimension` and `GridRowHighlight` components. - `GridPaddingOutlineForDimension` creates the helper grid with the inlined gaps as tracks - `GridRowHighlight` is responsible for the visual lines, and it recreates the original grid in the given dimension so it can have a handle for each original row - `GridGapHandle` mostly unchanged --------- Co-authored-by: Federico Ruggi <1081051+ruggi@users.noreply.github.com> --- .../strategies/set-grid-gap-strategy.tsx | 2 +- .../canvas/controls/grid-controls-helpers.ts | 44 ++ .../canvas/controls/grid-controls.tsx | 48 +- .../controls/select-mode/controls-common.tsx | 29 +- .../controls/select-mode/flex-gap-control.tsx | 2 +- .../grid-gap-control-component.tsx | 477 +++++++++++++++++ .../select-mode/grid-gap-control-helpers.tsx | 35 ++ .../controls/select-mode/grid-gap-control.tsx | 505 +----------------- editor/src/core/shared/array-utils.ts | 13 + 9 files changed, 597 insertions(+), 558 deletions(-) create mode 100644 editor/src/components/canvas/controls/grid-controls-helpers.ts create mode 100644 editor/src/components/canvas/controls/select-mode/grid-gap-control-component.tsx create mode 100644 editor/src/components/canvas/controls/select-mode/grid-gap-control-helpers.tsx diff --git a/editor/src/components/canvas/canvas-strategies/strategies/set-grid-gap-strategy.tsx b/editor/src/components/canvas/canvas-strategies/strategies/set-grid-gap-strategy.tsx index f7f1fb13ad98..9339b388a790 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/set-grid-gap-strategy.tsx +++ b/editor/src/components/canvas/canvas-strategies/strategies/set-grid-gap-strategy.tsx @@ -40,7 +40,7 @@ import { import type { InteractionSession } from '../interaction-state' import { colorTheme } from '../../../../uuiui' import { activeFrameTargetPath, setActiveFrames } from '../../commands/set-active-frames-command' -import type { GridGapControlProps } from '../../controls/select-mode/grid-gap-control' +import type { GridGapControlProps } from '../../controls/select-mode/grid-gap-control-component' import { GridGapControl } from '../../controls/select-mode/grid-gap-control' import type { GridControlsProps } from '../../controls/grid-controls-for-strategies' import { controlsForGridPlaceholders } from '../../controls/grid-controls-for-strategies' diff --git a/editor/src/components/canvas/controls/grid-controls-helpers.ts b/editor/src/components/canvas/controls/grid-controls-helpers.ts new file mode 100644 index 000000000000..3ba65cc81a61 --- /dev/null +++ b/editor/src/components/canvas/controls/grid-controls-helpers.ts @@ -0,0 +1,44 @@ +import type { CSSProperties } from 'react' +import type { GridMeasurementHelperData } from './grid-controls-for-strategies' +import { getNullableAutoOrTemplateBaseString } from './grid-controls-for-strategies' + +export function getGridHelperStyleMatchingTargetGrid( + grid: GridMeasurementHelperData, +): CSSProperties { + let style: CSSProperties = { + position: 'absolute', + top: grid.frame.y, + left: grid.frame.x, + width: grid.frame.width, + height: grid.frame.height, + display: 'grid', + gridTemplateColumns: getNullableAutoOrTemplateBaseString(grid.gridTemplateColumns), + gridTemplateRows: getNullableAutoOrTemplateBaseString(grid.gridTemplateRows), + justifyContent: grid.justifyContent ?? 'initial', + alignContent: grid.alignContent ?? 'initial', + pointerEvents: 'none', + padding: + grid.padding == null + ? 0 + : `${grid.padding.top}px ${grid.padding.right}px ${grid.padding.bottom}px ${grid.padding.left}px`, + } + + // Gap needs to be set only if the other two are not present or we'll have rendering issues + // due to how measurements are calculated. + if (grid.rowGap != null && grid.columnGap != null) { + style.rowGap = grid.rowGap + style.columnGap = grid.columnGap + } else { + if (grid.gap != null) { + style.gap = grid.gap + } + if (grid.rowGap != null) { + style.rowGap = grid.rowGap + } + if (grid.columnGap != null) { + style.columnGap = grid.columnGap + } + } + + return style +} diff --git a/editor/src/components/canvas/controls/grid-controls.tsx b/editor/src/components/canvas/controls/grid-controls.tsx index 9393c96963bb..685a0cf2d6fd 100644 --- a/editor/src/components/canvas/controls/grid-controls.tsx +++ b/editor/src/components/canvas/controls/grid-controls.tsx @@ -92,6 +92,7 @@ import { } from './grid-controls-for-strategies' import { useMaybeHighlightElement } from './select-mode/select-mode-hooks' import { useResizeEdges } from './select-mode/use-resize-edges' +import { getGridHelperStyleMatchingTargetGrid } from './grid-controls-helpers' const CELL_ANIMATION_DURATION = 0.15 // seconds @@ -774,9 +775,9 @@ const GridControl = React.memo(({ grid }) => { }) const placeholders = range(0, grid.cells) - - const style: CSSProperties = { - ...getGridControlBaseStyle(grid), + const baseStyle = getGridHelperStyleMatchingTargetGrid(grid) + const style = { + ...baseStyle, backgroundColor: activelyDraggingOrResizingCell != null ? colorTheme.primary10.value : 'transparent', outline: `1px solid ${ @@ -940,7 +941,7 @@ const GridMeasurementHelper = React.memo<{ elementPath: ElementPath }>(({ elemen const placeholders = range(0, gridData.cells) const style: CSSProperties = { - ...getGridControlBaseStyle(gridData), + ...getGridHelperStyleMatchingTargetGrid(gridData), opacity: 1, } @@ -1643,42 +1644,3 @@ function useAllGrids(metadata: ElementInstanceMetadataMap) { return MetadataUtils.getAllGrids(metadata) }, [metadata]) } - -function getGridControlBaseStyle(gridData: GridMeasurementHelperData) { - let style: CSSProperties = { - position: 'absolute', - top: gridData.frame.y, - left: gridData.frame.x, - width: gridData.frame.width, - height: gridData.frame.height, - display: 'grid', - gridTemplateColumns: getNullableAutoOrTemplateBaseString(gridData.gridTemplateColumns), - gridTemplateRows: getNullableAutoOrTemplateBaseString(gridData.gridTemplateRows), - justifyContent: gridData.justifyContent ?? 'initial', - alignContent: gridData.alignContent ?? 'initial', - pointerEvents: 'none', - padding: - gridData.padding == null - ? 0 - : `${gridData.padding.top}px ${gridData.padding.right}px ${gridData.padding.bottom}px ${gridData.padding.left}px`, - } - - // Gap needs to be set only if the other two are not present or we'll have rendering issues - // due to how measurements are calculated. - if (gridData.rowGap != null && gridData.columnGap != null) { - style.rowGap = gridData.rowGap - style.columnGap = gridData.columnGap - } else { - if (gridData.gap != null) { - style.gap = gridData.gap - } - if (gridData.rowGap != null) { - style.rowGap = gridData.rowGap - } - if (gridData.columnGap != null) { - style.columnGap = gridData.columnGap - } - } - - return style -} diff --git a/editor/src/components/canvas/controls/select-mode/controls-common.tsx b/editor/src/components/canvas/controls/select-mode/controls-common.tsx index 7010cde16fe0..befbf9c125c0 100644 --- a/editor/src/components/canvas/controls/select-mode/controls-common.tsx +++ b/editor/src/components/canvas/controls/select-mode/controls-common.tsx @@ -168,17 +168,26 @@ export function useHoverWithDelay( ): [React.MouseEventHandler, React.MouseEventHandler] { const fadeInTimeout = React.useRef(null) - const onHoverEnd = () => { - if (fadeInTimeout.current != null) { - clearTimeout(fadeInTimeout.current) - } - fadeInTimeout.current = null - update(false) - } + const onHoverEnd = React.useCallback( + (e: React.MouseEvent) => { + if (fadeInTimeout.current != null) { + clearTimeout(fadeInTimeout.current) + } + fadeInTimeout.current = null + update(false) + }, + [update], + ) - const onHoverStart = () => { - fadeInTimeout.current = setTimeout(() => update(true), delay) - } + const onHoverStart = React.useCallback( + (e: React.MouseEvent) => { + if (fadeInTimeout.current != null) { + clearTimeout(fadeInTimeout.current) + } + fadeInTimeout.current = setTimeout(() => update(true), delay) + }, + [update, delay], + ) return [onHoverStart, onHoverEnd] } diff --git a/editor/src/components/canvas/controls/select-mode/flex-gap-control.tsx b/editor/src/components/canvas/controls/select-mode/flex-gap-control.tsx index 7cd5ce22b8be..950974fdf078 100644 --- a/editor/src/components/canvas/controls/select-mode/flex-gap-control.tsx +++ b/editor/src/components/canvas/controls/select-mode/flex-gap-control.tsx @@ -427,7 +427,7 @@ const GapControlSegment = React.memo((props) => { function handleDimensions(flexDirection: FlexDirection, scale: number): Size { if (flexDirection === 'row' || flexDirection === 'row-reverse') { - return size(3 / scale, 12 / scale) + return size(4 / scale, 12 / scale) } if (flexDirection === 'column' || flexDirection === 'column-reverse') { return size(12 / scale, 4 / scale) diff --git a/editor/src/components/canvas/controls/select-mode/grid-gap-control-component.tsx b/editor/src/components/canvas/controls/select-mode/grid-gap-control-component.tsx new file mode 100644 index 000000000000..9a754866e4ee --- /dev/null +++ b/editor/src/components/canvas/controls/select-mode/grid-gap-control-component.tsx @@ -0,0 +1,477 @@ +import type { CSSProperties } from 'react' +import React from 'react' +import { createArrayWithLength, interleaveArray } from '../../../../core/shared/array-utils' +import type { GridAutoOrTemplateBase } from '../../../../core/shared/element-template' +import type { CanvasVector, Size } from '../../../../core/shared/math-utils' +import { size, windowPoint } from '../../../../core/shared/math-utils' +import type { ElementPath } from '../../../../core/shared/project-file-types' +import { assertNever } from '../../../../core/shared/utils' +import { Modifier } from '../../../../utils/modifiers' +import { when } from '../../../../utils/react-conditionals' +import { useColorTheme, UtopiaStyles } from '../../../../uuiui' +import { CSSCursor } from '../../../../uuiui-deps' +import type { EditorDispatch } from '../../../editor/action-types' +import { useDispatch } from '../../../editor/store/dispatch-context' +import { Substores, useEditorState, useRefEditorState } from '../../../editor/store/store-hook' +import { + cssNumber, + printCSSNumber, + stringifyGridDimension, +} from '../../../inspector/common/css-utils' +import CanvasActions from '../../canvas-actions' +import { createInteractionViaMouse, gridGapHandle } from '../../canvas-strategies/interaction-state' +import { windowToCanvasCoordinates } from '../../dom-lookup' +import type { Axis } from '../../gap-utils' +import { maybeGridGapData } from '../../gap-utils' +import { CanvasOffsetWrapper } from '../canvas-offset-wrapper' +import type { GridData } from '../grid-controls-for-strategies' +import { getNullableAutoOrTemplateBaseString, useGridData } from '../grid-controls-for-strategies' +import { getGridHelperStyleMatchingTargetGrid } from '../grid-controls-helpers' +import type { CSSNumberWithRenderedValue } from './controls-common' +import { CanvasLabel, PillHandle, useHoverWithDelay } from './controls-common' +import { startGapControlInteraction } from './grid-gap-control-helpers' + +export interface GridGapControlProps { + selectedElement: ElementPath + updatedGapValueRow: CSSNumberWithRenderedValue | null + updatedGapValueColumn: CSSNumberWithRenderedValue | null +} + +export const GridGapControlTestId = 'grid-gap-control' +export const GridGapControlHandleTestId = 'grid-gap-control-handle' +// background delay when hovering the gap +export const GridGapBackgroundHoverDelay = 1500 +// background delay when hovering the handle itself +const GapHandleBackgroundHoverDelay = 750 +// px threshold for showing the gap handles even without hovering the gap itself +// (for narrow gaps) +const GapHandleGapWidthThreshold = 10 + +const DefaultGapControlSizeConstants: GapControlSizeConstants = { + borderWidth: 1, + paddingIndicatorOffset: 10, + hitAreaPadding: 5, +} + +export const GridGapControlComponent = React.memo((props) => { + const { selectedElement } = props + + const dispatch = useDispatch() + const scale = useEditorState( + Substores.canvas, + (store) => store.editor.canvas.scale, + 'GridGapControlComponent scale', + ) + + const elementHovered = + useEditorState( + Substores.highlightedHoveredViews, + (store) => store.editor.hoveredViews.includes(selectedElement), + 'GridGapControlComponent elementHovered', + ) ?? false + + const grid = useGridData([selectedElement]).at(0) + + const activeDraggingAxis = useEditorState( + Substores.canvas, + (store) => + store.editor.canvas.interactionSession?.activeControl.type === 'GRID_GAP_HANDLE' + ? store.editor.canvas.interactionSession?.activeControl.axis + : null, + 'GridGapControl isDragging', + ) + + const canvasOffset = useRefEditorState((store) => store.editor.canvas.roundedCanvasOffset) + + const axisMouseDownHandler = React.useCallback( + (e: React.MouseEvent, axis: Axis) => { + startGapControlInteraction(e, dispatch, canvasOffset.current, scale, axis) + }, + [canvasOffset, dispatch, scale], + ) + const rowMouseDownHandler = React.useCallback( + (e: React.MouseEvent) => axisMouseDownHandler(e, 'row'), + [axisMouseDownHandler], + ) + + const columnMouseDownHandler = React.useCallback( + (e: React.MouseEvent) => axisMouseDownHandler(e, 'column'), + [axisMouseDownHandler], + ) + + const [hoveredAxis, setHoveredAxis] = React.useState<'row' | 'column'>('row') + const onMouseOverRow = React.useCallback(() => setHoveredAxis('row'), []) + const onMouseOverColumn = React.useCallback(() => setHoveredAxis('column'), []) + + const gridGap = useEditorState( + Substores.metadata, + (store) => maybeGridGapData(store.editor.jsxMetadata, selectedElement), + 'GridGapControlComponent gridGap', + ) + + if (grid == null || gridGap == null) { + return null + } + + return ( + + + + + ) +}) + +const GridPaddingOutlineForDimension = (props: { + grid: GridData + dimension: 'rows' | 'columns' + onMouseDown: (e: React.MouseEvent) => void + beingDragged: boolean + onMouseOver: () => void + zIndexPriority: boolean + gridGap: CSSNumberWithRenderedValue + elementHovered: boolean +}) => { + const { + grid, + gridGap, + dimension, + onMouseDown, + beingDragged, + onMouseOver, + zIndexPriority, + elementHovered, + } = props + + let style: CSSProperties = { + ...getGridHelperStyleMatchingTargetGrid(grid), + zIndex: zIndexPriority ? 1 : undefined, + gap: undefined, + rowGap: undefined, + columnGap: undefined, + gridTemplateRows: + dimension === 'rows' + ? tweakTrackListByInsertingGap(grid.gridTemplateRows, grid.rowGap ?? grid.gap) + : '1fr', + gridTemplateColumns: + dimension === 'columns' + ? tweakTrackListByInsertingGap(grid.gridTemplateColumns, grid.columnGap ?? grid.gap) + : '1fr', + overflow: 'hidden', + } + + const length = 2 * (dimension === 'rows' ? grid.rows : grid.columns) - 1 + + return ( +
+ {createArrayWithLength(length, (index) => { + const hide = index === 0 || index === length - 1 || index % 2 === 0 + return ( + + ) + })} +
+ ) +} + +const GridRowHighlight = (props: { + gapId: string + onMouseDown: React.MouseEventHandler + hide: boolean + axis: 'row' | 'column' + template: string | undefined + numberOfHandles: number + gap: number | null + gapValue: CSSNumberWithRenderedValue | null + beingDragged: boolean + onMouseOver: () => void + elementHovered: boolean +}) => { + const { + gapId, + onMouseDown, + hide, + axis, + template, + gap, + gapValue, + numberOfHandles, + beingDragged, + onMouseOver, + elementHovered, + } = props + + const colorTheme = useColorTheme() + const canvasScale = useEditorState( + Substores.canvas, + (store) => store.editor.canvas.scale, + 'GridRowHighlight canvasScale', + ) + + const lineWidth = 1 / canvasScale + + const outlineColor = beingDragged ? colorTheme.brandNeonOrange.value : 'transparent' + + const [backgroundShown, setBackgroundShown] = React.useState(false) + + const [gapIsHovered, setGapIsHovered] = React.useState(false) + const [handleIsHovered, setHandleIsHovered] = React.useState(null) + const [hoverStart, hoverEnd] = useHoverWithDelay(GridGapBackgroundHoverDelay, setBackgroundShown) + + const onGapHover = React.useCallback( + (e: React.MouseEvent) => { + onMouseOver() + setGapIsHovered(true) + }, + [onMouseOver], + ) + + const onHandleHover = React.useCallback( + (e: React.MouseEvent, index: number) => { + hoverStart(e) + setHandleIsHovered(index) + }, + [hoverStart], + ) + + const onGapHoverEnd = React.useCallback( + (e: React.MouseEvent) => { + hoverEnd(e) + setGapIsHovered(false) + setHandleIsHovered(null) + }, + [hoverEnd], + ) + + const shouldShowBackground = !beingDragged && backgroundShown + + if (gapValue == null) { + return null + } + + return ( +
+ {createArrayWithLength(numberOfHandles, (i) => ( + + ))} +
+ ) +} + +function tweakTrackListByInsertingGap( + trackList: GridAutoOrTemplateBase | null, + gap: number | null, +): string | undefined { + if (trackList == null) { + return undefined + } + + if (trackList.type === 'FALLBACK') { + throw new Error('Cannot insert gap into fallback') + } + + const gapTrack = gap == null ? `0px` : `${gap}px` + + return interleaveArray(trackList.dimensions.map(stringifyGridDimension), gapTrack).join(' ') +} + +interface GapControlSizeConstants { + paddingIndicatorOffset: number + hitAreaPadding: number + borderWidth: number +} + +const gapControlSizeConstants = ( + constants: GapControlSizeConstants, + scale: number, +): GapControlSizeConstants => ({ + borderWidth: constants.borderWidth / scale, + paddingIndicatorOffset: constants.paddingIndicatorOffset / scale, + hitAreaPadding: constants.hitAreaPadding / scale, +}) + +type GridGapHandleProps = { + gapId: string + index: number + scale: number + gapValue: CSSNumberWithRenderedValue + axis: Axis + onMouseDown: React.MouseEventHandler + isDragging: boolean + onHandleHoverStartInner: (e: React.MouseEvent, index: number) => void + indicatorShown: number | null + elementHovered: boolean + gapIsHovered: boolean + backgroundShown: boolean +} +export function GridGapHandle({ + gapId, + index, + scale, + gapValue, + axis, + onMouseDown, + onHandleHoverStartInner, + isDragging, + indicatorShown, + elementHovered, + gapIsHovered, + backgroundShown, +}: GridGapHandleProps) { + const { width, height } = handleDimensions(axis, scale) + const { hitAreaPadding, paddingIndicatorOffset, borderWidth } = gapControlSizeConstants( + DefaultGapControlSizeConstants, + scale, + ) + const colorTheme = useColorTheme() + const shouldShowIndicator = !isDragging && indicatorShown === index + let shouldShowHandle = !isDragging && gapIsHovered + // show the handle also if the gap is too narrow to hover + if (!gapIsHovered && !backgroundShown) { + shouldShowHandle = elementHovered && gapValue.renderedValuePx <= GapHandleGapWidthThreshold + } + const handleOpacity = gapIsHovered ? 1 : 0.3 + + const onHandleHoverStart = React.useCallback( + (e: React.MouseEvent) => { + onHandleHoverStartInner(e, index) + }, + [onHandleHoverStartInner, index], + ) + + const rowGapStyles = + axis === 'row' + ? ({ + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)', + position: 'absolute', + gridArea: `1/${index + 1}/2/${index + 2}`, + } as const) + : {} + return ( +
+
+ {when( + shouldShowIndicator, + , + )} +
+ +
+ ) +} + +function handleDimensions(axis: Axis, scale: number): Size { + if (axis === 'row') { + return size(12 / scale, 4 / scale) + } + if (axis === 'column') { + return size(4 / scale, 12 / scale) + } + assertNever(axis) +} diff --git a/editor/src/components/canvas/controls/select-mode/grid-gap-control-helpers.tsx b/editor/src/components/canvas/controls/select-mode/grid-gap-control-helpers.tsx new file mode 100644 index 000000000000..296b24d5090e --- /dev/null +++ b/editor/src/components/canvas/controls/select-mode/grid-gap-control-helpers.tsx @@ -0,0 +1,35 @@ +import type { CanvasVector } from '../../../../core/shared/math-utils' +import { windowPoint } from '../../../../core/shared/math-utils' +import { Modifier } from '../../../../utils/modifiers' +import type { EditorDispatch } from '../../../editor/action-types' +import CanvasActions from '../../canvas-actions' +import { createInteractionViaMouse, gridGapHandle } from '../../canvas-strategies/interaction-state' +import { windowToCanvasCoordinates } from '../../dom-lookup' +import type { Axis } from '../../gap-utils' + +export function startGapControlInteraction( + event: React.MouseEvent, + dispatch: EditorDispatch, + canvasOffset: CanvasVector, + scale: number, + axis: Axis, +) { + if (event.buttons === 1 && event.button !== 2) { + event.stopPropagation() + const canvasPositions = windowToCanvasCoordinates( + scale, + canvasOffset, + windowPoint({ x: event.nativeEvent.x, y: event.nativeEvent.y }), + ) + dispatch([ + CanvasActions.createInteractionSession( + createInteractionViaMouse( + canvasPositions.canvasPositionRaw, + Modifier.modifiersForEvent(event), + gridGapHandle(axis), + 'zero-drag-not-permitted', + ), + ), + ]) + } +} diff --git a/editor/src/components/canvas/controls/select-mode/grid-gap-control.tsx b/editor/src/components/canvas/controls/select-mode/grid-gap-control.tsx index b25565d931dd..6cb1b9031ceb 100644 --- a/editor/src/components/canvas/controls/select-mode/grid-gap-control.tsx +++ b/editor/src/components/canvas/controls/select-mode/grid-gap-control.tsx @@ -1,505 +1,4 @@ -import React, { useState } from 'react' -import type { CanvasRectangle, CanvasVector, Size } from '../../../../core/shared/math-utils' -import { size, windowPoint } from '../../../../core/shared/math-utils' -import type { ElementPath } from '../../../../core/shared/project-file-types' -import { assertNever } from '../../../../core/shared/utils' -import { Modifier } from '../../../../utils/modifiers' -import { when } from '../../../../utils/react-conditionals' -import { useColorTheme, UtopiaStyles } from '../../../../uuiui' -import type { EditorDispatch } from '../../../editor/action-types' -import { useDispatch } from '../../../editor/store/dispatch-context' -import { Substores, useEditorState, useRefEditorState } from '../../../editor/store/store-hook' -import type { CSSNumber } from '../../../inspector/common/css-utils' -import { printCSSNumber } from '../../../inspector/common/css-utils' -import CanvasActions from '../../canvas-actions' import { controlForStrategyMemoized } from '../../canvas-strategies/canvas-strategy-types' -import { createInteractionViaMouse, gridGapHandle } from '../../canvas-strategies/interaction-state' -import { windowToCanvasCoordinates } from '../../dom-lookup' -import type { Axis } from '../../gap-utils' -import { maybeGridGapData, gridGapControlBoundsFromMetadata } from '../../gap-utils' -import { CanvasOffsetWrapper } from '../canvas-offset-wrapper' -import type { CSSNumberWithRenderedValue } from './controls-common' -import { CanvasLabel, fallbackEmptyValue, PillHandle, useHoverWithDelay } from './controls-common' -import { CSSCursor } from '../../../../uuiui-deps' -import { useBoundingBox } from '../bounding-box-hooks' -import { isZeroSizedElement } from '../outline-utils' -import { createArrayWithLength } from '../../../../core/shared/array-utils' -import { useGridData } from '../grid-controls-for-strategies' +import { GridGapControlComponent } from './grid-gap-control-component' -export interface GridGapControlProps { - selectedElement: ElementPath - updatedGapValueRow: CSSNumberWithRenderedValue | null - updatedGapValueColumn: CSSNumberWithRenderedValue | null -} - -export const GridGapControlTestId = 'grid-gap-control' -export const GridGapControlHandleTestId = 'grid-gap-control-handle' -// background delay when hovering the gap -const GridGapBackgroundHoverDelay = 1500 -// background delay when hovering the handle itself -const GapHandleBackgroundHoverDelay = 750 -// px threshold for showing the gap handles even without hovering the gap itself -// (for narrow gaps) -const GapHandleGapWidthThreshold = 10 - -export const GridGapControl = controlForStrategyMemoized((props) => { - const { selectedElement, updatedGapValueRow, updatedGapValueColumn } = props - const colorTheme = useColorTheme() - const accentColor = colorTheme.gapControlsBg.value - - const hoveredViews = useEditorState( - Substores.highlightedHoveredViews, - (store) => store.editor.hoveredViews, - 'GridGapControl hoveredViews', - ) - - const [elementHovered, setElementHovered] = useState(false) - const [rowAxisHandleHovererd, setRowAxisHandleHovered] = useState(false) - const [columnAxisHandleHovererd, setColumnAxisHandleHovered] = useState(false) - - const [rowBackgroundShown, setRowBackgroundShown] = React.useState(false) - const [columnBackgroundShown, setColumnBackgroundShown] = React.useState(false) - - const [rowControlHoverStart, rowControlHoverEnd] = useHoverWithDelay( - GridGapBackgroundHoverDelay, - setRowBackgroundShown, - ) - const [columnControlHoverStart, columnControlHoverEnd] = useHoverWithDelay( - GridGapBackgroundHoverDelay, - setColumnBackgroundShown, - ) - - const [rowAxisHandleHoverStart, rowAxisHandleHoverEnd] = useHoverWithDelay( - GapHandleBackgroundHoverDelay, - setRowAxisHandleHovered, - ) - - const [columnAxisHandleHoverStart, columnAxisHandleHoverEnd] = useHoverWithDelay( - GapHandleBackgroundHoverDelay, - setColumnAxisHandleHovered, - ) - - const timeoutRef = React.useRef(null) - React.useEffect(() => { - const timeoutHandle = timeoutRef.current - if (timeoutHandle != null) { - clearTimeout(timeoutHandle) - } - - if (hoveredViews.includes(selectedElement)) { - timeoutRef.current = setTimeout(() => setElementHovered(true), 200) - } else { - setElementHovered(false) - } - }, [hoveredViews, selectedElement]) - - const dispatch = useDispatch() - const scale = useEditorState( - Substores.canvas, - (store) => store.editor.canvas.scale, - 'GridGapControl scale', - ) - const metadata = useEditorState( - Substores.metadata, - (store) => store.editor.jsxMetadata, - 'GridGapControl metadata', - ) - - const isDragging = useEditorState( - Substores.canvas, - (store) => store.editor.canvas.interactionSession?.activeControl.type === 'GRID_GAP_HANDLE', - 'GridGapControl isDragging', - ) - - const canvasOffset = useRefEditorState((store) => store.editor.canvas.roundedCanvasOffset) - - const axisMouseDownHandler = React.useCallback( - (e: React.MouseEvent, axis: Axis) => { - startInteraction(e, dispatch, canvasOffset.current, scale, axis) - }, - [canvasOffset, dispatch, scale], - ) - const rowMouseDownHandler = React.useCallback( - (e: React.MouseEvent) => axisMouseDownHandler(e, 'row'), - [axisMouseDownHandler], - ) - - const columnMouseDownHandler = React.useCallback( - (e: React.MouseEvent) => axisMouseDownHandler(e, 'column'), - [axisMouseDownHandler], - ) - - const gridGap = maybeGridGapData(metadata, selectedElement) - if (gridGap == null) { - return null - } - - const controlRef = useBoundingBox( - [selectedElement], - (ref, safeGappedBoundingBox, realBoundingBox) => { - if (isZeroSizedElement(realBoundingBox)) { - ref.current.style.display = 'none' - } else { - ref.current.style.display = 'block' - ref.current.style.left = safeGappedBoundingBox.x + 'px' - ref.current.style.top = safeGappedBoundingBox.y + 'px' - ref.current.style.width = safeGappedBoundingBox.width + 'px' - ref.current.style.height = safeGappedBoundingBox.height + 'px' - } - }, - ) - - const gridGapRow = updatedGapValueRow ?? gridGap.row - const gridGapColumn = updatedGapValueColumn ?? gridGap.column - - const gridRowColumnInfo = useGridData([selectedElement]) - - const controlBounds = gridGapControlBoundsFromMetadata(gridRowColumnInfo[0], { - row: fallbackEmptyValue(gridGapRow), - column: fallbackEmptyValue(gridGapColumn), - }) - - return ( - -
- {controlBounds.gaps.map(({ gap, bounds, axis, gapId }) => { - const gapControlProps = { - mouseDownHandler: axisMouseDownHandler, - gapId: gapId, - bounds: bounds, - accentColor: accentColor, - scale: scale, - isDragging: isDragging, - axis: axis, - gapValue: gap, - internalGrid: { - gridTemplateRows: controlBounds.gridTemplateRows, - gridTemplateColumns: controlBounds.gridTemplateColumns, - gap: axis === 'row' ? controlBounds.gapValues.column : controlBounds.gapValues.row, - }, - elementHovered: elementHovered, - handles: axis === 'row' ? controlBounds.columns : controlBounds.rows, - } - if (axis === 'row') { - return ( - - ) - } - return ( - - ) - })} -
-
- ) -}) - -interface GapControlSizeConstants { - dragBorderWidth: number - paddingIndicatorOffset: number - hitAreaPadding: number - borderWidth: number -} - -const DefaultGapControlSizeConstants: GapControlSizeConstants = { - dragBorderWidth: 1, - borderWidth: 1, - paddingIndicatorOffset: 10, - hitAreaPadding: 5, -} - -const gapControlSizeConstants = ( - constants: GapControlSizeConstants, - scale: number, -): GapControlSizeConstants => ({ - dragBorderWidth: constants.dragBorderWidth / scale, - borderWidth: constants.borderWidth / scale, - paddingIndicatorOffset: constants.paddingIndicatorOffset / scale, - hitAreaPadding: constants.hitAreaPadding / scale, -}) - -interface GridGapControlSegmentProps { - onMouseDown: React.MouseEventHandler - gapHoverStart: React.MouseEventHandler - gapHoverEnd: React.MouseEventHandler - onHandleHoverStart: React.MouseEventHandler - onHandleHoverEnd: React.MouseEventHandler - bounds: CanvasRectangle - axis: Axis - gapValue: CSSNumber - elementHovered: boolean - gapId: string - accentColor: string - scale: number - isDragging: boolean - backgroundShown: boolean - handles: number - internalGrid: { - gridTemplateRows: string - gridTemplateColumns: string - gap: CSSNumber - } -} - -const GapControlSegment = React.memo((props) => { - const { - gapHoverStart, - gapHoverEnd, - onHandleHoverStart, - onHandleHoverEnd, - bounds, - isDragging, - accentColor: accentColor, - scale, - gapId, - backgroundShown, - axis, - handles, - internalGrid, - } = props - - const [indicatorShown, setIndicatorShown] = React.useState(null) - const [gapIsHovered, setGapIsHovered] = React.useState(false) - - const { dragBorderWidth } = gapControlSizeConstants(DefaultGapControlSizeConstants, scale) - - const onHandleHoverStartInner = React.useCallback( - (e: React.MouseEvent, indicatorIndex: number) => { - setIndicatorShown(indicatorIndex) - onHandleHoverStart(e) - }, - [onHandleHoverStart], - ) - - const onGapHover = React.useCallback( - (e: React.MouseEvent) => { - setGapIsHovered(true) - gapHoverStart(e) - }, - [gapHoverStart], - ) - - const onHandleHoverEndInner = React.useCallback( - (e: React.MouseEvent) => { - setGapIsHovered(false) - gapHoverEnd(e) - setIndicatorShown(null) - onHandleHoverEnd(e) - }, - [onHandleHoverEnd, gapHoverEnd], - ) - - const shouldShowBackground = !isDragging && backgroundShown - - // Invert the direction for the handle. - const segmentFlexDirection = axis === 'row' ? 'column' : 'row' - - return ( -
-
- {createArrayWithLength(handles, (i) => ( - - ))} -
-
- ) -}) - -type GridGapHandleProps = { - gapId: string - index: number - scale: number - gapValue: CSSNumber - axis: Axis - onMouseDown: React.MouseEventHandler - isDragging: boolean - onHandleHoverStartInner: (e: React.MouseEvent, index: number) => void - indicatorShown: number | null - elementHovered: boolean - gapIsHovered: boolean - backgroundShown: boolean -} -function GridGapHandle({ - gapId, - index, - scale, - gapValue, - axis, - onMouseDown, - onHandleHoverStartInner, - isDragging, - indicatorShown, - elementHovered, - gapIsHovered, - backgroundShown, -}: GridGapHandleProps) { - const { width, height } = handleDimensions(axis, scale) - const { hitAreaPadding, paddingIndicatorOffset, borderWidth } = gapControlSizeConstants( - DefaultGapControlSizeConstants, - scale, - ) - const colorTheme = useColorTheme() - const shouldShowIndicator = !isDragging && indicatorShown === index - let shouldShowHandle = !isDragging && gapIsHovered - // show the handle also if the gap is too narrow to hover - if (!gapIsHovered && !backgroundShown) { - shouldShowHandle = elementHovered && gapValue.value <= GapHandleGapWidthThreshold - } - const handleOpacity = gapIsHovered ? 1 : 0.3 - - const onHandleHoverStart = React.useCallback( - (e: React.MouseEvent) => { - onHandleHoverStartInner(e, index) - }, - [onHandleHoverStartInner, index], - ) - - const rowGapStyles = - axis === 'row' - ? ({ - left: '50%', - top: '50%', - transform: 'translate(-50%, -50%)', - position: 'absolute', - gridArea: `1/${index + 1}/2/${index + 2}`, - } as const) - : {} - return ( -
-
- {when( - shouldShowIndicator, - , - )} -
- -
- ) -} - -function handleDimensions(axis: Axis, scale: number): Size { - if (axis === 'row') { - return size(12 / scale, 4 / scale) - } - if (axis === 'column') { - return size(3 / scale, 12 / scale) - } - assertNever(axis) -} - -function startInteraction( - event: React.MouseEvent, - dispatch: EditorDispatch, - canvasOffset: CanvasVector, - scale: number, - axis: Axis, -) { - if (event.buttons === 1 && event.button !== 2) { - event.stopPropagation() - const canvasPositions = windowToCanvasCoordinates( - scale, - canvasOffset, - windowPoint({ x: event.nativeEvent.x, y: event.nativeEvent.y }), - ) - dispatch([ - CanvasActions.createInteractionSession( - createInteractionViaMouse( - canvasPositions.canvasPositionRaw, - Modifier.modifiersForEvent(event), - gridGapHandle(axis), - 'zero-drag-not-permitted', - ), - ), - ]) - } -} +export const GridGapControl = controlForStrategyMemoized(GridGapControlComponent) diff --git a/editor/src/core/shared/array-utils.ts b/editor/src/core/shared/array-utils.ts index 4c52ed009c96..ebaadea6b50b 100644 --- a/editor/src/core/shared/array-utils.ts +++ b/editor/src/core/shared/array-utils.ts @@ -584,3 +584,16 @@ export function sortArrayByAndReturnPermutation( export function revertArrayOrder(array: T[], permutation: number[]): T[] { return array.map((_, index) => array[permutation.indexOf(index)]) } + +// From https://stackoverflow.com/a/31879739 +export function interleaveArray(array: T[], elem: T): T[] { + const newArray = [] + let i = 0 + if (i < array.length) { + newArray.push(array[i++]) + } + while (i < array.length) { + newArray.push(elem, array[i++]) + } + return newArray +}