diff --git a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx index 1d4baaed6017..e6113afb22e1 100644 --- a/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx +++ b/editor/src/components/canvas/canvas-strategies/canvas-strategies.tsx @@ -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' @@ -139,7 +140,7 @@ const propertyControlStrategies: MetaCanvasStrategy = ( ): Array => { return mapDropNulls( (factory) => factory(canvasState, interactionSession, customStrategyState), - [setPaddingStrategy, setFlexGapStrategy, setBorderRadiusStrategy], + [setPaddingStrategy, setFlexGapStrategy, setGridGapStrategy, setBorderRadiusStrategy], ) } diff --git a/editor/src/components/canvas/canvas-strategies/interaction-state.ts b/editor/src/components/canvas/canvas-strategies/interaction-state.ts index 9d29c777ede0..8be4107e03b3 100644 --- a/editor/src/components/canvas/canvas-strategies/interaction-state.ts +++ b/editor/src/components/canvas/canvas-strategies/interaction-state.ts @@ -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' @@ -576,11 +577,11 @@ 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, @@ -588,6 +589,18 @@ export function gridAxisHandle(axis: 'column' | 'row', columnOrRow: number): Gri } } +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 @@ -680,6 +693,7 @@ export type CanvasControlType = | BoundingArea | ResizeHandle | FlexGapHandle + | GridGapHandle | PaddingResizeHandle | KeyboardCatcherControl | ReorderSlider 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 new file mode 100644 index 000000000000..5474206a7155 --- /dev/null +++ b/editor/src/components/canvas/canvas-strategies/strategies/set-grid-gap-strategy.tsx @@ -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' + ) +} diff --git a/editor/src/components/canvas/controls/grid-controls.tsx b/editor/src/components/canvas/controls/grid-controls.tsx index bc12cb6b5276..082408906c36 100644 --- a/editor/src/components/canvas/controls/grid-controls.tsx +++ b/editor/src/components/canvas/controls/grid-controls.tsx @@ -79,6 +79,7 @@ import { useCanvasAnimation } from '../ui-jsx-canvas-renderer/animation-context' import { CanvasLabel } from './select-mode/controls-common' import { optionalMap } from '../../../core/shared/optional-utils' import type { Sides } from 'utopia-api/core' +import type { Axis } from '../gap-utils' import { useMaybeHighlightElement } from './select-mode/select-mode-hooks' const CELL_ANIMATION_DURATION = 0.15 // seconds @@ -106,7 +107,7 @@ function getCellsCount(template: GridAutoOrTemplateBase | null): number { } } -function getNullableAutoOrTemplateBaseString( +export function getNullableAutoOrTemplateBaseString( template: GridAutoOrTemplateBase | null, ): string | undefined { if (template == null) { @@ -143,7 +144,7 @@ const GRID_RESIZE_HANDLE_SIZE = 15 // px export interface GridResizingControlProps { dimension: GridDimension dimensionIndex: number - axis: 'row' | 'column' + axis: Axis containingFrame: CanvasRectangle fromPropsAxisValues: GridAutoOrTemplateBase | null padding: number | null @@ -300,7 +301,7 @@ export interface GridResizingProps { axisValues: GridAutoOrTemplateBase | null fromPropsAxisValues: GridAutoOrTemplateBase | null containingFrame: CanvasRectangle - axis: 'row' | 'column' + axis: Axis gap: number | null padding: Sides | null } @@ -372,7 +373,22 @@ export const GridResizing = React.memo((props: GridResizingProps) => { }) GridResizing.displayName = 'GridResizing' -function useGridData(elementPaths: ElementPath[]) { +export type GridData = { + elementPath: ElementPath + frame: CanvasRectangle + gridTemplateColumns: GridAutoOrTemplateBase | null + gridTemplateRows: GridAutoOrTemplateBase | null + gridTemplateColumnsFromProps: GridAutoOrTemplateBase | null + gridTemplateRowsFromProps: GridAutoOrTemplateBase | null + gap: number | null + rowGap: number | null + columnGap: number | null + padding: Sides + rows: number + columns: number + cells: number +} +export function useGridData(elementPaths: ElementPath[]): GridData[] { const grids = useEditorState( Substores.metadata, (store) => { @@ -760,7 +776,11 @@ export const GridControls = controlForStrategyMemoized(({ tar } return ( -
+
{placeholders.map((cell) => { const countedRow = Math.floor(cell / grid.columns) + 1 const countedColumn = Math.floor(cell % grid.columns) + 1 @@ -1311,3 +1331,11 @@ function gridEdgeToWidthHeight(props: GridResizeEdgeProperties, scale: number): bottom: props.isEnd ? 0 : undefined, } } + +function gridKeyFromPath(path: ElementPath): string { + return `grid-${EP.toString(path)}` +} + +export function getGridPlaceholderDomElement(elementPath: ElementPath): HTMLElement | null { + return document.getElementById(gridKeyFromPath(elementPath)) +} 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 new file mode 100644 index 000000000000..52302bf64533 --- /dev/null +++ b/editor/src/components/canvas/controls/select-mode/grid-gap-control.tsx @@ -0,0 +1,450 @@ +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' + +interface GridGapControlProps { + selectedElement: ElementPath + updatedGapValueRow: CSSNumberWithRenderedValue | null + updatedGapValueColumn: CSSNumberWithRenderedValue | null +} + +export const GridGapControlTestId = 'grid-gap-control' +export const GridGapControlHandleTestId = 'grid-gap-control-handle' + +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 [rowBackgroundShown, setRowBackgroundShown] = React.useState(false) + const [columnBackgroundShown, setColumnBackgroundShown] = React.useState(false) + + const [rowControlHoverStart, rowControlHoverEnd] = useHoverWithDelay(0, setRowBackgroundShown) + const [columnControlHoverStart, columnControlHoverEnd] = useHoverWithDelay( + 0, + setColumnBackgroundShown, + ) + + 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( + selectedElement, + gridRowColumnInfo[0], + { + row: fallbackEmptyValue(gridGapRow), + column: fallbackEmptyValue(gridGapColumn), + }, + scale, + ) + + 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 + hoverStart: React.MouseEventHandler + hoverEnd: 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 { + hoverStart, + hoverEnd, + bounds, + isDragging, + accentColor: accentColor, + scale, + gapId, + backgroundShown, + axis, + handles, + internalGrid, + } = props + + const [indicatorShown, setIndicatorShown] = React.useState(null) + + const { dragBorderWidth } = gapControlSizeConstants(DefaultGapControlSizeConstants, scale) + + const handleHoverStartInner = React.useCallback((indicatorIndex: number) => { + setIndicatorShown(indicatorIndex) + }, []) + + const handleHoverEndInner = React.useCallback( + (e: React.MouseEvent) => { + hoverEnd(e) + setIndicatorShown(null) + }, + [hoverEnd], + ) + + const shouldShowBackground = !isDragging && backgroundShown + + // Invert the direction for the handle. + const segmentFlexDirection = axis === 'row' ? 'column' : 'row' + + return ( +
+
+ {createArrayWithLength(handles, (i) => ( + + ))} +
+
+ ) +}) + +type GridGapHandlerProps = { + gapId: string + index: number + scale: number + gapValue: CSSNumber + axis: Axis + onMouseDown: React.MouseEventHandler + isDragging: boolean + handleHoverStartInner: (index: number) => void + indicatorShown: number | null + elementHovered: boolean +} +function GridGapHandler({ + gapId, + index, + scale, + gapValue, + axis, + onMouseDown, + handleHoverStartInner, + isDragging, + indicatorShown, + elementHovered, +}: GridGapHandlerProps) { + const { width, height } = handleDimensions(axis, scale) + const { hitAreaPadding, paddingIndicatorOffset, borderWidth } = gapControlSizeConstants( + DefaultGapControlSizeConstants, + scale, + ) + const colorTheme = useColorTheme() + const shouldShowIndicator = !isDragging && indicatorShown === index + const shouldShowHandle = !isDragging && elementHovered + + const handleHoverStart = React.useCallback(() => { + handleHoverStartInner(index) + }, [handleHoverStartInner, 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', + ), + ), + ]) + } +} diff --git a/editor/src/components/canvas/gap-utils.ts b/editor/src/components/canvas/gap-utils.ts index af62aec3813a..42fece845244 100644 --- a/editor/src/components/canvas/gap-utils.ts +++ b/editor/src/components/canvas/gap-utils.ts @@ -1,5 +1,10 @@ import { MetadataUtils } from '../../core/model/element-metadata-utils' -import { reverse, stripNulls } from '../../core/shared/array-utils' +import { + createArrayWithLength, + matrixGetter, + reverse, + stripNulls, +} from '../../core/shared/array-utils' import { getLayoutProperty } from '../../core/layout/getLayoutProperty' import { defaultEither, isLeft, mapEither, right } from '../../core/shared/either' import type { @@ -7,7 +12,7 @@ import type { ElementInstanceMetadataMap, } from '../../core/shared/element-template' import { isJSXElement } from '../../core/shared/element-template' -import type { CanvasRectangle, CanvasVector } from '../../core/shared/math-utils' +import type { CanvasRectangle, CanvasVector, Size } from '../../core/shared/math-utils' import { canvasRectangle, isInfinityRectangle } from '../../core/shared/math-utils' import type { ElementPath } from '../../core/shared/project-file-types' import { assertNever } from '../../core/shared/utils' @@ -22,6 +27,11 @@ import { isReversedFlexDirection } from '../../core/model/flex-utils' import * as EP from '../../core/shared/element-path' import { treatElementAsFragmentLike } from './canvas-strategies/strategies/fragment-like-helpers' import type { AllElementProps } from '../editor/store/editor-state' +import type { GridData } from './controls/grid-controls' +import { + getGridPlaceholderDomElement, + getNullableAutoOrTemplateBaseString, +} from './controls/grid-controls' export interface PathWithBounds { bounds: CanvasRectangle @@ -56,6 +66,17 @@ export function cursorFromFlexDirection(direction: FlexDirection): CSSCursor { } } +export function cursorFromAxis(axis: Axis): CSSCursor { + switch (axis) { + case 'column': + return CSSCursor.GapEW + case 'row': + return CSSCursor.GapNS + default: + assertNever(axis) + } +} + export function gapControlBounds( parentBounds: CanvasRectangle, bounds: CanvasRectangle, @@ -153,6 +174,155 @@ export function gapControlBoundsFromMetadata( ) } +export function gridGapControlBoundsFromMetadata( + parentPath: ElementPath, + gridRowColumnInfo: GridData, + gapValues: { row: CSSNumber; column: CSSNumber }, + scale: number, +): { + gaps: Array<{ + bounds: CanvasRectangle + gapId: string + gap: CSSNumber + axis: Axis + }> + rows: number + columns: number + cellBounds: CanvasRectangle + gapValues: { row: CSSNumber; column: CSSNumber } + gridTemplateRows: string + gridTemplateColumns: string +} { + const parentGrid = getGridPlaceholderDomElement(parentPath) + if (parentGrid == null) { + return { + rows: 0, + columns: 0, + gaps: [], + cellBounds: canvasRectangle({ x: 0, y: 0, width: 0, height: 0 }), + gapValues: gapValues, + gridTemplateRows: '1fr', + gridTemplateColumns: '1fr', + } + } + const parentGridBounds = parentGrid?.getBoundingClientRect() + const gridRows = gridRowColumnInfo.rows + const gridColumns = gridRowColumnInfo.columns + const gridTemplateRows = getNullableAutoOrTemplateBaseString(gridRowColumnInfo.gridTemplateRows) + const gridTemplateColumns = getNullableAutoOrTemplateBaseString( + gridRowColumnInfo.gridTemplateColumns, + ) + const cell = matrixGetter(Array.from(parentGrid?.children ?? []), gridColumns) + // the actual rectangle that surrounds the cell placeholders + const cellBounds = canvasRectangle({ + x: cell(0, 0).getBoundingClientRect().x - parentGridBounds.x, + y: cell(0, 0).getBoundingClientRect().y - parentGridBounds.y, + width: + cell(0, gridColumns - 1).getBoundingClientRect().right - cell(0, 0).getBoundingClientRect().x, + height: + cell(gridRows - 1, 0).getBoundingClientRect().bottom - cell(0, 0).getBoundingClientRect().y, + }) + + // row gaps array + const rowGaps = createArrayWithLength(gridRows - 1, (i) => { + // cell i represents the gap between child [i * gridColumns] and child [(i+1) * gridColumns] + const firstChildBounds = cell(i, 0).getBoundingClientRect() + const secondChildBounds = cell(i + 1, 0).getBoundingClientRect() + return { + gapId: `${EP.toString(parentPath)}-row-gap-${i}`, + bounds: adjustToScale( + canvasRectangle({ + x: cellBounds.x, + y: firstChildBounds.bottom - parentGridBounds.y, + width: cellBounds.width, + height: secondChildBounds.top - firstChildBounds.bottom, + }), + scale, + ), + gap: gapValues.row, + axis: 'row' as Axis, + } + }) + + // column gaps array + const columnGaps = createArrayWithLength(gridColumns - 1, (i) => { + // cell i represents the gap between child [i] and child [i + 1] + const firstChildBounds = cell(0, i).getBoundingClientRect() + const secondChildBounds = cell(0, i + 1).getBoundingClientRect() + return { + gapId: `${EP.toString(parentPath)}-column-gap-${i}`, + bounds: adjustToScale( + canvasRectangle({ + x: firstChildBounds.right - parentGridBounds.x, + y: cellBounds.y, + width: secondChildBounds.left - firstChildBounds.right, + height: cellBounds.height, + }), + scale, + ), + gap: gapValues.column, + axis: 'column' as Axis, + } + }) + + return { + gaps: rowGaps.concat(columnGaps), + rows: gridRows, + columns: gridColumns, + gridTemplateRows: gridTemplateRows ?? '', + gridTemplateColumns: gridTemplateColumns ?? '', + cellBounds: cellBounds, + gapValues: gapValues, + } +} + +function adjustToScale(rectangle: CanvasRectangle, scale: number): CanvasRectangle { + return canvasRectangle({ + x: rectangle.x / scale, + y: rectangle.y / scale, + width: rectangle.width / scale, + height: rectangle.height / scale, + }) +} + +export interface GridGapData { + row: CSSNumberWithRenderedValue + column: CSSNumberWithRenderedValue +} + +export function maybeGridGapData( + metadata: ElementInstanceMetadataMap, + elementPath: ElementPath, +): GridGapData | null { + const element = MetadataUtils.findElementByElementPath(metadata, elementPath) + if ( + element == null || + element.specialSizeMeasurements.display !== 'grid' || + isLeft(element.element) || + !isJSXElement(element.element.value) + ) { + return null + } + + const rowGap = element.specialSizeMeasurements.rowGap ?? element.specialSizeMeasurements.gap ?? 0 + const rowGapFromProps: CSSNumber | undefined = defaultEither( + undefined, + getLayoutProperty('rowGap', right(element.element.value.props), styleStringInArray), + ) + + const columnGap = + element.specialSizeMeasurements.columnGap ?? element.specialSizeMeasurements.gap ?? 0 + const columnGapFromProps: CSSNumber | undefined = defaultEither( + undefined, + getLayoutProperty('columnGap', right(element.element.value.props), styleStringInArray), + ) + + return { + row: { renderedValuePx: rowGap, value: rowGapFromProps ?? null }, + column: { renderedValuePx: columnGap, value: columnGapFromProps ?? null }, + } +} + export interface FlexGapData { value: CSSNumberWithRenderedValue direction: FlexDirection @@ -231,3 +401,5 @@ export function recurseIntoChildrenOfMapOrFragment( return [instance] }) } + +export type Axis = 'row' | 'column' diff --git a/editor/src/components/editor/store/store-deep-equality-instances.ts b/editor/src/components/editor/store/store-deep-equality-instances.ts index 09dbdd72ad30..afb4b58b27c6 100644 --- a/editor/src/components/editor/store/store-deep-equality-instances.ts +++ b/editor/src/components/editor/store/store-deep-equality-instances.ts @@ -451,6 +451,7 @@ import type { GridAxisHandle, GridResizeHandle, GridResizeEdge, + GridGapHandle, } from '../../canvas/canvas-strategies/interaction-state' import { boundingArea, @@ -462,6 +463,7 @@ import { gridCellHandle, gridAxisHandle, gridResizeHandle, + gridGapHandle, } from '../../canvas/canvas-strategies/interaction-state' import type { Modifiers } from '../../../utils/modifiers' import type { @@ -639,6 +641,7 @@ import type { ComponentDescriptorBounds, ComponentDescriptorPropertiesBounds, } from '../../../core/property-controls/component-descriptor-parser' +import type { Axis } from '../../../components/canvas/gap-utils' export function ElementPropertyPathKeepDeepEquality(): KeepDeepEqualityCall { return combine2EqualityCalls( @@ -2907,6 +2910,9 @@ export const GridResizeHandleKeepDeepEquality: KeepDeepEqualityCall = + combine1EqualityCall((handle) => handle.axis, createCallWithTripleEquals(), gridGapHandle) + export const CanvasControlTypeKeepDeepEquality: KeepDeepEqualityCall = ( oldValue, newValue, @@ -2962,6 +2968,11 @@ export const CanvasControlTypeKeepDeepEquality: KeepDeepEqualityCall( export function valueOrArrayToArray(ts: T | T[]): T[] { return Array.isArray(ts) ? ts : [ts] } + +export function createArrayWithLength(length: number, value: (index: number) => T): T[] { + return Array.from({ length }, (_, index) => { + // see issue https://github.com/microsoft/TypeScript/issues/37750 + return value instanceof Function ? value(index) : value + }) +} + +export function matrixGetter(array: T[], width: number): (row: number, column: number) => T { + return (row, column) => { + return array[row * width + column] + } +}