diff --git a/editor/src/components/canvas/canvas-component-entry.tsx b/editor/src/components/canvas/canvas-component-entry.tsx index 52e1365dea3f..3643767b3f9a 100644 --- a/editor/src/components/canvas/canvas-component-entry.tsx +++ b/editor/src/components/canvas/canvas-component-entry.tsx @@ -83,7 +83,7 @@ const CanvasComponentEntryInner = React.memo((props: CanvasComponentEntryProps) clearRuntimeErrors() }, [clearRuntimeErrors]) - const containerRef = useApplyCanvasOffsetToStyle(true) + const containerRef = useApplyCanvasOffsetToStyle(true, 'xy') return ( <> diff --git a/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.ts b/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.ts index ae80e5b24252..ccf88f8ddbd5 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.ts +++ b/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.ts @@ -8,10 +8,7 @@ import { MetadataUtils } from '../../../../core/model/element-metadata-utils' import * as EP from '../../../../core/shared/element-path' import * as PP from '../../../../core/shared/property-path' import { setProperty } from '../../commands/set-property-command' -import { - controlsForGridPlaceholders, - GridRowColumnResizingControls, -} from '../../controls/grid-controls' +import { controlsForGridPlaceholders } from '../../controls/grid-controls' import type { CanvasStrategyFactory } from '../canvas-strategies' import { onlyFitWhenDraggingThisControl } from '../canvas-strategies' import type { InteractionCanvasState } from '../canvas-strategy-types' @@ -37,6 +34,7 @@ import type { GridAutoOrTemplateBase } from '../../../../core/shared/element-tem import { expandGridDimensions, replaceGridTemplateDimensionAtIndex } from './grid-helpers' import { setCursorCommand } from '../../commands/set-cursor-command' import { CSSCursor } from '../../canvas-types' +import { GridRowColumnResizingControls } from '../../controls/grid-controls-ruler' export const resizeGridStrategy: CanvasStrategyFactory = ( canvasState: InteractionCanvasState, 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 484d19455ef5..f2be4ea58db0 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 @@ -115,6 +115,7 @@ export const setGridGapStrategy: CanvasStrategyFactory = ( }, key: 'grid-gap-resize-control', show: 'visible-except-when-other-strategy-is-active', + priority: 'top', }) const maybeIndicatorProps = gridGapValueIndicatorProps(interactionSession, gridGap) diff --git a/editor/src/components/canvas/controls/canvas-offset-wrapper.tsx b/editor/src/components/canvas/controls/canvas-offset-wrapper.tsx index a4fb60951eb6..d021cd5ca3c3 100644 --- a/editor/src/components/canvas/controls/canvas-offset-wrapper.tsx +++ b/editor/src/components/canvas/controls/canvas-offset-wrapper.tsx @@ -10,8 +10,11 @@ import { isFollowMode } from '../../editor/editor-modes' import { liveblocksThrottle } from '../../../../liveblocks.config' export const CanvasOffsetWrapper = React.memo( - (props: { children?: React.ReactNode; setScaleToo?: boolean }) => { - const elementRef = useApplyCanvasOffsetToStyle(props.setScaleToo ?? false) + (props: { children?: React.ReactNode; setScaleToo?: boolean; limitAxis?: 'x' | 'y' | 'xy' }) => { + const elementRef = useApplyCanvasOffsetToStyle( + props.setScaleToo ?? false, + props.limitAxis ?? 'xy', + ) return (
@@ -21,7 +24,10 @@ export const CanvasOffsetWrapper = React.memo( }, ) -export function useApplyCanvasOffsetToStyle(setScaleToo: boolean): React.RefObject { +export function useApplyCanvasOffsetToStyle( + setScaleToo: boolean, + limitAxis: 'x' | 'y' | 'xy', +): React.RefObject { const elementRef = React.useRef(null) const canvasOffsetRef = useRefEditorState((store) => store.editor.canvas.roundedCanvasOffset) const scaleRef = useRefEditorState((store) => store.editor.canvas.scale) @@ -37,11 +43,18 @@ export function useApplyCanvasOffsetToStyle(setScaleToo: boolean): React.RefObje const applyCanvasOffset = React.useCallback( (roundedCanvasOffset: CanvasVector) => { + const limitedCanvasOffset = + limitAxis === 'x' + ? { x: roundedCanvasOffset.x, y: 0 } + : limitAxis === 'y' + ? { x: 0, y: roundedCanvasOffset.y } + : roundedCanvasOffset + if (elementRef.current != null) { elementRef.current.style.setProperty( 'transform', (setScaleToo && scaleRef.current < 1 ? `scale(${scaleRef.current})` : '') + - ` translate3d(${roundedCanvasOffset.x}px, ${roundedCanvasOffset.y}px, 0)`, + ` translate3d(${limitedCanvasOffset.x}px, ${limitedCanvasOffset.y}px, 0)`, ) elementRef.current.style.setProperty( 'zoom', @@ -56,7 +69,7 @@ export function useApplyCanvasOffsetToStyle(setScaleToo: boolean): React.RefObje } } }, - [setScaleToo, scaleRef, isScrollAnimationActiveRef, mode], + [setScaleToo, scaleRef, isScrollAnimationActiveRef, mode, limitAxis], ) useSelectorWithCallback( diff --git a/editor/src/components/canvas/controls/grid-controls-ruler-2.tsx b/editor/src/components/canvas/controls/grid-controls-ruler-2.tsx new file mode 100644 index 000000000000..2e1ce0eadc13 --- /dev/null +++ b/editor/src/components/canvas/controls/grid-controls-ruler-2.tsx @@ -0,0 +1,815 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import type { AnimationControls } from 'framer-motion' +import { motion, useAnimationControls } from 'framer-motion' +import type { CSSProperties } from 'react' +import React from 'react' +import type { Sides } from 'utopia-api/core' +import type { ElementPath } from 'utopia-shared/src/types' +import type { + GridDimension, + GridDiscreteDimension, +} from '../../../components/inspector/common/css-utils' +import { + isCSSKeyword, + isDynamicGridRepeat, + isGridCSSRepeat, + isStaticGridRepeat, + printGridAutoOrTemplateBase, + printGridCSSNumber, +} from '../../../components/inspector/common/css-utils' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import { mapDropNulls, stripNulls, uniqBy } from '../../../core/shared/array-utils' +import { defaultEither } from '../../../core/shared/either' +import * as EP from '../../../core/shared/element-path' +import type { + ElementInstanceMetadata, + GridAutoOrTemplateDimensions, +} from '../../../core/shared/element-template' +import { + isGridAutoOrTemplateDimensions, + type GridAutoOrTemplateBase, +} from '../../../core/shared/element-template' +import type { CanvasPoint, CanvasRectangle } from '../../../core/shared/math-utils' +import { + canvasPoint, + isFiniteRectangle, + isInfinityRectangle, + nullIfInfinity, + pointsEqual, + scaleRect, + windowPoint, + zeroRectangle, + zeroRectIfNullOrInfinity, +} from '../../../core/shared/math-utils' +import { + fromArrayIndex, + fromField, + fromTypeGuard, + notNull, +} from '../../../core/shared/optics/optic-creators' +import { toFirst } from '../../../core/shared/optics/optic-utilities' +import type { Optic } from '../../../core/shared/optics/optics' +import { optionalMap } from '../../../core/shared/optional-utils' +import { assertNever } from '../../../core/shared/utils' +import { Modifier } from '../../../utils/modifiers' +import { when } from '../../../utils/react-conditionals' +import { useColorTheme, UtopiaStyles } from '../../../uuiui' +import { useDispatch } from '../../editor/store/dispatch-context' +import { Substores, useEditorState, useRefEditorState } from '../../editor/store/store-hook' +import CanvasActions from '../canvas-actions' +import type { + ControlWithProps, + WhenToShowControl, +} from '../canvas-strategies/canvas-strategy-types' +import { controlForStrategyMemoized } from '../canvas-strategies/canvas-strategy-types' +import type { + GridResizeEdge, + GridResizeEdgeProperties, +} from '../canvas-strategies/interaction-state' +import { + createInteractionViaMouse, + gridAxisHandle, + gridCellHandle, + gridResizeEdgeProperties, + GridResizeEdges, + gridResizeHandle, +} from '../canvas-strategies/interaction-state' +import { resizeBoundingBoxFromSide } from '../canvas-strategies/strategies/resize-helpers' +import type { EdgePosition } from '../canvas-types' +import { + CSSCursor, + EdgePositionBottom, + EdgePositionLeft, + EdgePositionRight, + EdgePositionTop, +} from '../canvas-types' +import { windowToCanvasCoordinates } from '../dom-lookup' +import type { Axis } from '../gap-utils' +import { useCanvasAnimation } from '../ui-jsx-canvas-renderer/animation-context' +import { CanvasOffsetWrapper } from './canvas-offset-wrapper' +import { CanvasLabel } from './select-mode/controls-common' +import { useMaybeHighlightElement } from './select-mode/select-mode-hooks' +import type { GridCellCoordinates } from '../canvas-strategies/strategies/grid-cell-bounds' +import { gridCellTargetId } from '../canvas-strategies/strategies/grid-cell-bounds' +import { + getGlobalFrameOfGridCell, + getGridRelatedIndexes, +} from '../canvas-strategies/strategies/grid-helpers' +import { canResizeGridTemplate } from '../canvas-strategies/strategies/resize-grid-strategy' +import type { GridData } from './grid-controls' +import { + getNullableAutoOrTemplateBaseString, + GRID_RESIZE_HANDLE_CONTAINER_SIZE, + GRID_RESIZE_HANDLE_SIZE, + GridCellTestId, + gridEdgeToCSSCursor, + gridEdgeToEdgePosition, + gridEdgeToWidthHeight, + GridResizeEdgeTestId, + GridResizingControl, + useGridData, +} from './grid-controls' + +const CELL_ANIMATION_DURATION = 0.15 // seconds + +export interface GridControlProps { + grid: GridData +} + +export const GridControl = React.memo(({ grid }) => { + const dispatch = useDispatch() + const controls = useAnimationControls() + const colorTheme = useColorTheme() + + const editorMetadata = useEditorState( + Substores.metadata, + (store) => store.editor.jsxMetadata, + 'GridControl editorMetadata', + ) + + const interactionLatestMetadata = useEditorState( + Substores.canvas, + (store) => + store.editor.canvas.interactionSession?.interactionData.type === 'DRAG' + ? store.editor.canvas.interactionSession.latestMetadata + : null, + 'GridControl interactionLatestMetadata', + ) + + const jsxMetadata = React.useMemo( + () => interactionLatestMetadata ?? editorMetadata, + [interactionLatestMetadata, editorMetadata], + ) + + const activelyDraggingOrResizingCell = useEditorState( + Substores.canvas, + (store) => + store.editor.canvas.interactionSession != null && + store.editor.canvas.interactionSession.activeControl.type === 'GRID_CELL_HANDLE' && + store.editor.canvas.interactionSession?.interactionData.type === 'DRAG' && + store.editor.canvas.interactionSession?.interactionData.modifiers.cmd !== true && + store.editor.canvas.interactionSession?.interactionData.drag != null + ? store.editor.canvas.interactionSession.activeControl.id + : null, + 'GridControl activelyDraggingOrResizingCell', + ) + + const currentHoveredCell = useEditorState( + Substores.canvas, + (store) => store.editor.canvas.controls.gridControlData?.targetCell ?? null, + 'GridControl currentHoveredCell', + ) + + const targetsAreCellsWithPositioning = useEditorState( + Substores.metadata, + (store) => + store.editor.selectedViews.every((elementPath) => + MetadataUtils.isGridCellWithPositioning(store.editor.jsxMetadata, elementPath), + ), + 'GridControl targetsAreCellsWithPositioning', + ) + + const anyTargetAbsolute = useEditorState( + Substores.metadata, + (store) => + store.editor.selectedViews.some((elementPath) => + MetadataUtils.isPositionAbsolute( + MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, elementPath), + ), + ), + 'GridControl anyTargetAbsolute', + ) + + const scale = useEditorState( + Substores.canvas, + (store) => store.editor.canvas.scale, + 'GridControl scale', + ) + + const canvasOffsetRef = useRefEditorState((store) => store.editor.canvas.roundedCanvasOffset) + + const startInteractionWithUid = React.useCallback( + (params: { uid: string; row: number; column: number; frame: CanvasRectangle }) => + (event: React.MouseEvent) => { + setInitialShadowFrame(params.frame) + + const start = windowToCanvasCoordinates( + scale, + canvasOffsetRef.current, + windowPoint({ x: event.nativeEvent.x, y: event.nativeEvent.y }), + ) + + dispatch([ + CanvasActions.createInteractionSession( + createInteractionViaMouse( + start.canvasPositionRounded, + Modifier.modifiersForEvent(event), + gridCellHandle({ id: params.uid }), + 'zero-drag-not-permitted', + ), + ), + ]) + }, + [canvasOffsetRef, dispatch, scale], + ) + + const cells = React.useMemo(() => { + const children = MetadataUtils.getChildrenUnordered(jsxMetadata, grid.elementPath) + return mapDropNulls((cell, index) => { + if (cell == null || cell.globalFrame == null || !isFiniteRectangle(cell.globalFrame)) { + return null + } + const countedRow = Math.floor(index / grid.columns) + 1 + const countedColumn = Math.floor(index % grid.columns) + 1 + + const columnFromProps = cell.specialSizeMeasurements.elementGridProperties.gridColumnStart + const rowFromProps = cell.specialSizeMeasurements.elementGridProperties.gridRowStart + return { + elementPath: cell.elementPath, + globalFrame: cell.globalFrame, + borderRadius: cell.specialSizeMeasurements.borderRadius, + column: + columnFromProps == null + ? countedColumn + : isCSSKeyword(columnFromProps) + ? countedColumn + : columnFromProps.numericalPosition ?? countedColumn, + row: + rowFromProps == null + ? countedRow + : isCSSKeyword(rowFromProps) + ? countedRow + : rowFromProps.numericalPosition ?? countedRow, + index: index, + } + }, children) + }, [grid, jsxMetadata]) + + const dragging = useEditorState( + Substores.canvas, + (store) => + store.editor.canvas.interactionSession != null && + store.editor.canvas.interactionSession.activeControl.type === 'GRID_CELL_HANDLE' + ? store.editor.canvas.interactionSession.activeControl.id + : null, + 'GridControl dragging', + ) + + const shadow = React.useMemo(() => { + return cells.find((cell) => EP.toUid(cell.elementPath) === dragging) ?? null + }, [cells, dragging]) + + const [initialShadowFrame, setInitialShadowFrame] = React.useState( + shadow?.globalFrame ?? null, + ) + + const interactionData = useEditorState( + Substores.canvas, + (store) => + store.editor.canvas.interactionSession?.interactionData.type === 'DRAG' + ? store.editor.canvas.interactionSession.interactionData + : null, + 'GridControl interactionData', + ) + + const { hoveringStart } = useMouseMove(activelyDraggingOrResizingCell) + + // NOTE: this stuff is meant to be temporary, until we settle on the set of interaction pieces we like. + // After that, we should get rid of this. + const shadowPosition = React.useMemo(() => { + const drag = interactionData?.drag + const dragStart = interactionData?.dragStart + if ( + initialShadowFrame == null || + interactionData == null || + drag == null || + dragStart == null || + hoveringStart == null || + shadow == null + ) { + return null + } + + const getCoord = (axis: 'x' | 'y', dimension: 'width' | 'height') => { + return ( + shadow.globalFrame[axis] + + drag[axis] - + (shadow.globalFrame[axis] - dragStart[axis]) - + shadow.globalFrame[dimension] * + ((dragStart[axis] - initialShadowFrame[axis]) / initialShadowFrame[dimension]) + ) + } + + // make sure the shadow is displayed only inside the grid container bounds + function wrapCoord(c: number, min: number, max: number, shadowSize: number) { + return Math.min(Math.max(c, min), max - shadowSize) + } + + return { + x: wrapCoord( + getCoord('x', 'width') ?? 0, + grid.frame.x, + grid.frame.x + grid.frame.width, + shadow.globalFrame.width, + ), + y: wrapCoord( + getCoord('y', 'height') ?? 0, + grid.frame.y, + grid.frame.y + grid.frame.height, + shadow.globalFrame.height, + ), + } + }, [ + interactionData, + initialShadowFrame, + hoveringStart, + shadow, + grid.frame.x, + grid.frame.width, + grid.frame.y, + grid.frame.height, + ]) + + const gridPath = optionalMap(EP.parentPath, shadow?.elementPath) + + const targetRootCell = useEditorState( + Substores.canvas, + (store) => store.editor.canvas.controls.gridControlData?.rootCell ?? null, + 'GridControl targetRootCell', + ) + + useCellAnimation({ + disabled: anyTargetAbsolute, + targetRootCell: targetRootCell, + controls: controls, + shadowFrame: initialShadowFrame, + gridPath: gridPath, + }) + + const placeholders = Array.from(Array(grid.cells).keys()) + let style: CSSProperties = { + position: 'absolute', + top: grid.frame.y - 1, // account for border! + left: grid.frame.x - 1, // account for border! + width: grid.frame.width, + height: grid.frame.height, + display: 'grid', + gridTemplateColumns: getNullableAutoOrTemplateBaseString(grid.gridTemplateColumns), + gridTemplateRows: getNullableAutoOrTemplateBaseString(grid.gridTemplateRows), + backgroundColor: + activelyDraggingOrResizingCell != null ? colorTheme.primary10.value : 'transparent', + border: `1px solid ${ + activelyDraggingOrResizingCell != null ? colorTheme.primary.value : 'transparent' + }`, + 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 ( + + {/* grid lines */} + + +
+ {placeholders.map((cell) => { + const countedRow = Math.floor(cell / grid.columns) + 1 + const countedColumn = Math.floor(cell % grid.columns) + 1 + const id = gridCellTargetId(grid.elementPath, countedRow, countedColumn) + const borderID = `${id}-border` + const dotgridColor = + activelyDraggingOrResizingCell != null + ? colorTheme.blackOpacity35.value + : 'transparent' + + const isActiveCell = + countedColumn === currentHoveredCell?.column && countedRow === currentHoveredCell?.row + + const borderColor = + isActiveCell && targetsAreCellsWithPositioning + ? colorTheme.brandNeonPink.value + : colorTheme.blackOpacity35.value + return ( +
+ +
= grid.rows || + (grid.rowGap != null && grid.rowGap > 0) + ? gridPlaceholderBorder(borderColor, scale) + : undefined, + borderRight: + isActiveCell || + countedColumn >= grid.columns || + (grid.columnGap != null && grid.columnGap > 0) + ? gridPlaceholderBorder(borderColor, scale) + : undefined, + }} + /> + + +
+ ) + })} +
+ {/* cell targets */} + {cells.map((cell) => { + return ( +
+ ) + })} + {/* shadow */} + {!anyTargetAbsolute && + shadow != null && + initialShadowFrame != null && + interactionData?.dragStart != null && + interactionData?.drag != null && + hoveringStart != null ? ( + + ) : null} + + + ) +}) +GridControl.displayName = 'GridControl' + +export const GridDotOverlay = React.memo(({ dotgridColor }: { dotgridColor: string }) => { + return ( + +
+
+
+
+
+
+
+ + ) +}) + +function gridKeyFromPath(path: ElementPath): string { + return `grid-${EP.toString(path)}` +} + +const placeholderBorderBaseWidth = 2 + +function gridPlaceholderBorder(color: string, scale: number): string { + return `${placeholderBorderBaseWidth / scale}px solid ${color}` +} + +function gridPlaceholderTopOrLeftPosition(scale: number): string { + return `${-placeholderBorderBaseWidth / scale}px` +} + +function gridPlaceholderWidthOrHeight(scale: number): string { + return `calc(100% + ${(placeholderBorderBaseWidth * 2) / scale}px)` +} + +function useCellAnimation(params: { + disabled: boolean + gridPath: ElementPath | null + shadowFrame: CanvasRectangle | null + targetRootCell: GridCellCoordinates | null + controls: AnimationControls +}) { + const { gridPath, targetRootCell, controls, shadowFrame, disabled } = params + + const [lastTargetRootCellId, setLastTargetRootCellId] = React.useState(targetRootCell) + const [lastSnapPoint, setLastSnapPoint] = React.useState(shadowFrame) + + const selectedViews = useEditorState( + Substores.selectedViews, + (store) => store.editor.selectedViews, + 'useSnapAnimation selectedViews', + ) + + const animate = useCanvasAnimation(selectedViews) + + const gridMetadata = useEditorState( + Substores.metadata, + (store) => MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, gridPath), + 'useCellAnimation gridMetadata', + ) + + const moveFromPoint = React.useMemo(() => { + return lastSnapPoint ?? shadowFrame + }, [lastSnapPoint, shadowFrame]) + + const snapPoint = React.useMemo(() => { + if (gridMetadata == null || targetRootCell == null) { + return null + } + + return getGlobalFrameOfGridCell(gridMetadata, targetRootCell) + }, [gridMetadata, targetRootCell]) + + React.useEffect(() => { + if (disabled) { + return + } + + if (targetRootCell != null && snapPoint != null && moveFromPoint != null) { + const snapPointsDiffer = lastSnapPoint == null || !pointsEqual(snapPoint, lastSnapPoint) + const hasMovedToANewCell = lastTargetRootCellId != null + const shouldAnimate = snapPointsDiffer && hasMovedToANewCell + if (shouldAnimate) { + void animate( + { + scale: [0.97, 1.02, 1], // a very subtle boop + x: [moveFromPoint.x - snapPoint.x, 0], + y: [moveFromPoint.y - snapPoint.y, 0], + }, + { + duration: CELL_ANIMATION_DURATION, + type: 'tween', + ease: 'easeInOut', + }, + ) + } + } + setLastSnapPoint(snapPoint) + setLastTargetRootCellId(targetRootCell) + }, [ + targetRootCell, + controls, + lastSnapPoint, + snapPoint, + animate, + moveFromPoint, + lastTargetRootCellId, + disabled, + ]) +} + +function useMouseMove(activelyDraggingOrResizingCell: string | null) { + const [hoveringStart, setHoveringStart] = React.useState<{ + point: CanvasPoint + } | null>(null) + const [mouseCanvasPosition, setMouseCanvasPosition] = React.useState( + canvasPoint({ x: 0, y: 0 }), + ) + + const canvasScale = useEditorState( + Substores.canvasOffset, + (store) => store.editor.canvas.scale, + 'useHoveringCell canvasScale', + ) + + const canvasOffset = useEditorState( + Substores.canvasOffset, + (store) => store.editor.canvas.roundedCanvasOffset, + 'useHoveringCell canvasOffset', + ) + + React.useEffect(() => { + function handleMouseMove(e: MouseEvent) { + if (activelyDraggingOrResizingCell == null) { + setHoveringStart(null) + return + } + + const newMouseCanvasPosition = windowToCanvasCoordinates( + canvasScale, + canvasOffset, + windowPoint({ x: e.clientX, y: e.clientY }), + ).canvasPositionRaw + setMouseCanvasPosition(newMouseCanvasPosition) + + setHoveringStart((start) => { + if (start == null) { + return { + point: canvasPoint(newMouseCanvasPosition), + } + } + return start + }) + } + window.addEventListener('mousemove', handleMouseMove) + return function () { + window.removeEventListener('mousemove', handleMouseMove) + } + }, [activelyDraggingOrResizingCell, canvasOffset, canvasScale]) + + return { hoveringStart, mouseCanvasPosition } +} + +export const GridTrackIndicators = React.memo(({ grid }: { grid: GridData }) => { + const targetPath = grid.elementPath + const metadata = useEditorState( + Substores.metadata, + (store) => MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, targetPath), + 'GridTrackIndicators metadata', + ) + const columnPositions = mapDropNulls( + (cell) => ({ start: cell.x, length: cell.width }), + metadata?.specialSizeMeasurements.gridCellGlobalFrames?.[0] ?? [], // this is the first row + ) + const rowPositions = mapDropNulls( + (row) => ({ start: row[0].y, length: row[0].height }), // this is the first column + metadata?.specialSizeMeasurements.gridCellGlobalFrames ?? [], + ) + + return ( + + + {columnPositions.map((column, i) => { + return ( + +
+
+ + ) + })} + + + {rowPositions.map((row, i) => { + return ( + +
+
+ + ) + })} + + + ) +}) +GridTrackIndicators.displayName = 'GridTrackIndicators' diff --git a/editor/src/components/canvas/controls/grid-controls-ruler.tsx b/editor/src/components/canvas/controls/grid-controls-ruler.tsx new file mode 100644 index 000000000000..a1b69d614dca --- /dev/null +++ b/editor/src/components/canvas/controls/grid-controls-ruler.tsx @@ -0,0 +1,379 @@ +/** @jsxRuntime classic */ +/** @jsx jsx */ +import { jsx } from '@emotion/react' +import type { AnimationControls } from 'framer-motion' +import { motion, useAnimationControls } from 'framer-motion' +import type { CSSProperties } from 'react' +import React from 'react' +import type { Sides } from 'utopia-api/core' +import type { ElementPath } from 'utopia-shared/src/types' +import type { + GridDimension, + GridDiscreteDimension, +} from '../../../components/inspector/common/css-utils' +import { + isCSSKeyword, + isDynamicGridRepeat, + isGridCSSRepeat, + isStaticGridRepeat, + printGridAutoOrTemplateBase, + printGridCSSNumber, +} from '../../../components/inspector/common/css-utils' +import { MetadataUtils } from '../../../core/model/element-metadata-utils' +import { mapDropNulls, stripNulls, uniqBy } from '../../../core/shared/array-utils' +import { defaultEither } from '../../../core/shared/either' +import * as EP from '../../../core/shared/element-path' +import type { + ElementInstanceMetadata, + GridAutoOrTemplateDimensions, +} from '../../../core/shared/element-template' +import { + isGridAutoOrTemplateDimensions, + type GridAutoOrTemplateBase, +} from '../../../core/shared/element-template' +import type { CanvasPoint, CanvasRectangle } from '../../../core/shared/math-utils' +import { + canvasPoint, + isFiniteRectangle, + isInfinityRectangle, + nullIfInfinity, + pointsEqual, + scaleRect, + windowPoint, + zeroRectangle, + zeroRectIfNullOrInfinity, +} from '../../../core/shared/math-utils' +import { + fromArrayIndex, + fromField, + fromTypeGuard, + notNull, +} from '../../../core/shared/optics/optic-creators' +import { toFirst } from '../../../core/shared/optics/optic-utilities' +import type { Optic } from '../../../core/shared/optics/optics' +import { optionalMap } from '../../../core/shared/optional-utils' +import { assertNever } from '../../../core/shared/utils' +import { Modifier } from '../../../utils/modifiers' +import { when } from '../../../utils/react-conditionals' +import { useColorTheme, UtopiaStyles } from '../../../uuiui' +import { useDispatch } from '../../editor/store/dispatch-context' +import { Substores, useEditorState, useRefEditorState } from '../../editor/store/store-hook' +import CanvasActions from '../canvas-actions' +import type { + ControlWithProps, + WhenToShowControl, +} from '../canvas-strategies/canvas-strategy-types' +import { controlForStrategyMemoized } from '../canvas-strategies/canvas-strategy-types' +import type { + GridResizeEdge, + GridResizeEdgeProperties, +} from '../canvas-strategies/interaction-state' +import { + createInteractionViaMouse, + gridAxisHandle, + gridCellHandle, + gridResizeEdgeProperties, + GridResizeEdges, + gridResizeHandle, +} from '../canvas-strategies/interaction-state' +import { resizeBoundingBoxFromSide } from '../canvas-strategies/strategies/resize-helpers' +import type { EdgePosition } from '../canvas-types' +import { + CSSCursor, + EdgePositionBottom, + EdgePositionLeft, + EdgePositionRight, + EdgePositionTop, +} from '../canvas-types' +import { windowToCanvasCoordinates } from '../dom-lookup' +import type { Axis } from '../gap-utils' +import { useCanvasAnimation } from '../ui-jsx-canvas-renderer/animation-context' +import { CanvasOffsetWrapper } from './canvas-offset-wrapper' +import { CanvasLabel } from './select-mode/controls-common' +import { useMaybeHighlightElement } from './select-mode/select-mode-hooks' +import type { GridCellCoordinates } from '../canvas-strategies/strategies/grid-cell-bounds' +import { gridCellTargetId } from '../canvas-strategies/strategies/grid-cell-bounds' +import { + getGlobalFrameOfGridCell, + getGridRelatedIndexes, +} from '../canvas-strategies/strategies/grid-helpers' +import { canResizeGridTemplate } from '../canvas-strategies/strategies/resize-grid-strategy' +import { + GRID_RESIZE_HANDLE_CONTAINER_SIZE, + GRID_RESIZE_HANDLE_SIZE, + gridEdgeToCSSCursor, + gridEdgeToEdgePosition, + gridEdgeToWidthHeight, + GridResizeEdgeTestId, + GridResizingControl, + useGridData, +} from './grid-controls' + +export const GridControlsRuler = React.memo(() => { + const selectedElement = useEditorState( + Substores.selectedViews, + (store) => store.editor.selectedViews?.[0], + `GridControlsRuler selectedElement`, + ) + if (selectedElement == null) { + return null + } + return ( + +
+
+
+ ) +}) + +interface GridRowColumnResizingControlsProps { + target: ElementPath +} + +export const GridRowColumnResizingControls = + controlForStrategyMemoized(({ target }) => { + const grids = useGridData([target]) + + function getStripedAreaLength(template: GridAutoOrTemplateBase | null, gap: number) { + if (template?.type !== 'DIMENSIONS') { + return null + } + return template.dimensions.reduce((acc, curr, index) => { + if (curr.type === 'NUMBER') { + return acc + curr.value.value + (index > 0 ? gap : 0) + } + return acc + }, 0) + } + + const scale = useEditorState( + Substores.canvas, + (store) => store.editor.canvas.scale, + 'GridRowColumnResizingControls scale', + ) + + const gridsWithVisibleResizeControls = React.useMemo(() => { + return grids.filter((grid) => { + if ( + grid.gridTemplateColumns?.type !== 'DIMENSIONS' || + grid.gridTemplateRows?.type !== 'DIMENSIONS' + ) { + return false + } + + // returns whether the rendered dimensions are too crowded, as in there are two cols/rows that are closer than the handle sizes + function tooCrowded(dimensions: GridDimension[]): boolean { + const visualSizes = dimensions.map( + (dim) => (dim.type === 'NUMBER' ? dim.value.value : 0) * scale, + ) + return visualSizes.some((dim, index) => { + if (index < visualSizes.length - 1) { + const next = visualSizes[index + 1] + if (dim + next < GRID_RESIZE_HANDLE_SIZE * 2) { + return true + } + } + return false + }) + } + + return ( + !tooCrowded(grid.gridTemplateColumns.dimensions) && + !tooCrowded(grid.gridTemplateRows.dimensions) + ) + }) + }, [scale, grids]) + + return ( + + + {gridsWithVisibleResizeControls.flatMap((grid) => { + return ( + + ) + })} + + + {gridsWithVisibleResizeControls.flatMap((grid) => { + return ( + + ) + })} + + + ) + }) + +export interface GridResizingProps { + axisValues: GridAutoOrTemplateBase | null + fromPropsAxisValues: GridAutoOrTemplateBase | null + stripedAreaLength: number | null + containingFrame: CanvasRectangle + axis: Axis + gap: number | null + padding: Sides | null +} + +export const GridResizing = React.memo((props: GridResizingProps) => { + const canvasScale = useEditorState( + Substores.canvasOffset, + (store) => store.editor.canvas.scale, + 'GridResizing canvasScale', + ) + + const fromProps = React.useMemo((): GridAutoOrTemplateDimensions | null => { + if (props.fromPropsAxisValues?.type !== 'DIMENSIONS') { + return null + } + if (!canResizeGridTemplate(props.fromPropsAxisValues)) { + return null + } + return { + type: 'DIMENSIONS', + dimensions: props.fromPropsAxisValues.dimensions.reduce( + (acc, cur): GridDiscreteDimension[] => { + if (isGridCSSRepeat(cur)) { + if (isDynamicGridRepeat(cur)) { + return acc + } + let expanded: GridDiscreteDimension[] = [] + for (let i = 0; i < cur.times; i++) { + expanded.push(...cur.value.filter((v) => v.type !== 'REPEAT')) + } + return acc.concat(...expanded) + } else { + return acc.concat(cur) + } + }, + [] as GridDiscreteDimension[], + ), + } + }, [props.fromPropsAxisValues]) + + const resizeLocked = React.useMemo(() => { + return fromProps == null || !canResizeGridTemplate(fromProps) + }, [fromProps]) + + const [resizingIndex, setResizingIndex] = React.useState(null) + + // These are the indexes of the elements that will resize too alongside the one at the index of + // `resizingIndex`. + const coresizingIndexes: number[] = React.useMemo(() => { + if (props.fromPropsAxisValues?.type !== 'DIMENSIONS' || resizingIndex == null) { + return [] + } + return getGridRelatedIndexes({ + template: props.fromPropsAxisValues.dimensions, + index: resizingIndex, + }) + }, [props.fromPropsAxisValues, resizingIndex]) + + if (props.axisValues == null) { + return null + } + switch (props.axisValues.type) { + case 'DIMENSIONS': + const size = GRID_RESIZE_HANDLE_CONTAINER_SIZE / canvasScale + const dimensions = props.axisValues.dimensions + + return ( +
printGridCSSNumber(dim)).join(' ') + : undefined, + gridTemplateRows: + props.axis === 'row' + ? dimensions.map((dim) => printGridCSSNumber(dim)).join(' ') + : undefined, + gap: props.gap ?? 0, + paddingLeft: + props.axis === 'column' && props.padding != null + ? `${props.padding.left}px` + : undefined, + paddingTop: + props.axis === 'row' && props.padding != null ? `${props.padding.top}px` : undefined, + }} + > + {dimensions.flatMap((dimension, dimensionIndex) => { + return ( + + ) + })} +
+ ) + case 'FALLBACK': + return null + default: + assertNever(props.axisValues) + } +}) +GridResizing.displayName = 'GridResizing' diff --git a/editor/src/components/canvas/controls/grid-controls.tsx b/editor/src/components/canvas/controls/grid-controls.tsx index 0a2ab0de5350..b835aa15a7c6 100644 --- a/editor/src/components/canvas/controls/grid-controls.tsx +++ b/editor/src/components/canvas/controls/grid-controls.tsx @@ -98,8 +98,8 @@ import { getGridRelatedIndexes, } from '../canvas-strategies/strategies/grid-helpers' import { canResizeGridTemplate } from '../canvas-strategies/strategies/resize-grid-strategy' - -const CELL_ANIMATION_DURATION = 0.15 // seconds +import { GridControlsRuler } from './grid-controls-ruler' +import { GridControl } from './grid-controls-ruler-2' export const GridCellTestId = (elementPath: ElementPath) => `grid-cell-${EP.toString(elementPath)}` @@ -150,8 +150,8 @@ function getLabelForAxis( return gridCSSNumberToLabel(defaultEither(fromDOM, fromPropsAtIndex)) } -const GRID_RESIZE_HANDLE_CONTAINER_SIZE = 30 // px -const GRID_RESIZE_HANDLE_SIZE = 15 // px +export const GRID_RESIZE_HANDLE_CONTAINER_SIZE = 30 // px +export const GRID_RESIZE_HANDLE_SIZE = 15 // px export interface GridResizingControlProps { dimension: GridDimension @@ -554,560 +554,8 @@ interface GridRowColumnResizingControlsProps { target: ElementPath } -export const GridRowColumnResizingControls = - controlForStrategyMemoized(({ target }) => { - const grids = useGridData([target]) - - function getStripedAreaLength(template: GridAutoOrTemplateBase | null, gap: number) { - if (template?.type !== 'DIMENSIONS') { - return null - } - return template.dimensions.reduce((acc, curr, index) => { - if (curr.type === 'NUMBER') { - return acc + curr.value.value + (index > 0 ? gap : 0) - } - return acc - }, 0) - } - - const scale = useEditorState( - Substores.canvas, - (store) => store.editor.canvas.scale, - 'GridRowColumnResizingControls scale', - ) - - const gridsWithVisibleResizeControls = React.useMemo(() => { - return grids.filter((grid) => { - if ( - grid.gridTemplateColumns?.type !== 'DIMENSIONS' || - grid.gridTemplateRows?.type !== 'DIMENSIONS' - ) { - return false - } - - // returns whether the rendered dimensions are too crowded, as in there are two cols/rows that are closer than the handle sizes - function tooCrowded(dimensions: GridDimension[]): boolean { - const visualSizes = dimensions.map( - (dim) => (dim.type === 'NUMBER' ? dim.value.value : 0) * scale, - ) - return visualSizes.some((dim, index) => { - if (index < visualSizes.length - 1) { - const next = visualSizes[index + 1] - if (dim + next < GRID_RESIZE_HANDLE_SIZE * 2) { - return true - } - } - return false - }) - } - - return ( - !tooCrowded(grid.gridTemplateColumns.dimensions) && - !tooCrowded(grid.gridTemplateRows.dimensions) - ) - }) - }, [scale, grids]) - - return ( - - {gridsWithVisibleResizeControls.flatMap((grid) => { - return ( - - ) - })} - {gridsWithVisibleResizeControls.flatMap((grid) => { - return ( - - ) - })} - - ) - }) - export const GridControlsKey = (gridPath: ElementPath) => `grid-controls-${EP.toString(gridPath)}` -export interface GridControlProps { - grid: GridData -} - -export const GridControl = React.memo(({ grid }) => { - const dispatch = useDispatch() - const controls = useAnimationControls() - const colorTheme = useColorTheme() - - const editorMetadata = useEditorState( - Substores.metadata, - (store) => store.editor.jsxMetadata, - 'GridControl editorMetadata', - ) - - const interactionLatestMetadata = useEditorState( - Substores.canvas, - (store) => - store.editor.canvas.interactionSession?.interactionData.type === 'DRAG' - ? store.editor.canvas.interactionSession.latestMetadata - : null, - 'GridControl interactionLatestMetadata', - ) - - const jsxMetadata = React.useMemo( - () => interactionLatestMetadata ?? editorMetadata, - [interactionLatestMetadata, editorMetadata], - ) - - const activelyDraggingOrResizingCell = useEditorState( - Substores.canvas, - (store) => - store.editor.canvas.interactionSession != null && - store.editor.canvas.interactionSession.activeControl.type === 'GRID_CELL_HANDLE' && - store.editor.canvas.interactionSession?.interactionData.type === 'DRAG' && - store.editor.canvas.interactionSession?.interactionData.modifiers.cmd !== true && - store.editor.canvas.interactionSession?.interactionData.drag != null - ? store.editor.canvas.interactionSession.activeControl.id - : null, - 'GridControl activelyDraggingOrResizingCell', - ) - - const currentHoveredCell = useEditorState( - Substores.canvas, - (store) => store.editor.canvas.controls.gridControlData?.targetCell ?? null, - 'GridControl currentHoveredCell', - ) - - const targetsAreCellsWithPositioning = useEditorState( - Substores.metadata, - (store) => - store.editor.selectedViews.every((elementPath) => - MetadataUtils.isGridCellWithPositioning(store.editor.jsxMetadata, elementPath), - ), - 'GridControl targetsAreCellsWithPositioning', - ) - - const anyTargetAbsolute = useEditorState( - Substores.metadata, - (store) => - store.editor.selectedViews.some((elementPath) => - MetadataUtils.isPositionAbsolute( - MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, elementPath), - ), - ), - 'GridControl anyTargetAbsolute', - ) - - const scale = useEditorState( - Substores.canvas, - (store) => store.editor.canvas.scale, - 'GridControl scale', - ) - - const canvasOffsetRef = useRefEditorState((store) => store.editor.canvas.roundedCanvasOffset) - - const startInteractionWithUid = React.useCallback( - (params: { uid: string; row: number; column: number; frame: CanvasRectangle }) => - (event: React.MouseEvent) => { - setInitialShadowFrame(params.frame) - - const start = windowToCanvasCoordinates( - scale, - canvasOffsetRef.current, - windowPoint({ x: event.nativeEvent.x, y: event.nativeEvent.y }), - ) - - dispatch([ - CanvasActions.createInteractionSession( - createInteractionViaMouse( - start.canvasPositionRounded, - Modifier.modifiersForEvent(event), - gridCellHandle({ id: params.uid }), - 'zero-drag-not-permitted', - ), - ), - ]) - }, - [canvasOffsetRef, dispatch, scale], - ) - - const cells = React.useMemo(() => { - const children = MetadataUtils.getChildrenUnordered(jsxMetadata, grid.elementPath) - return mapDropNulls((cell, index) => { - if (cell == null || cell.globalFrame == null || !isFiniteRectangle(cell.globalFrame)) { - return null - } - const countedRow = Math.floor(index / grid.columns) + 1 - const countedColumn = Math.floor(index % grid.columns) + 1 - - const columnFromProps = cell.specialSizeMeasurements.elementGridProperties.gridColumnStart - const rowFromProps = cell.specialSizeMeasurements.elementGridProperties.gridRowStart - return { - elementPath: cell.elementPath, - globalFrame: cell.globalFrame, - borderRadius: cell.specialSizeMeasurements.borderRadius, - column: - columnFromProps == null - ? countedColumn - : isCSSKeyword(columnFromProps) - ? countedColumn - : columnFromProps.numericalPosition ?? countedColumn, - row: - rowFromProps == null - ? countedRow - : isCSSKeyword(rowFromProps) - ? countedRow - : rowFromProps.numericalPosition ?? countedRow, - index: index, - } - }, children) - }, [grid, jsxMetadata]) - - const dragging = useEditorState( - Substores.canvas, - (store) => - store.editor.canvas.interactionSession != null && - store.editor.canvas.interactionSession.activeControl.type === 'GRID_CELL_HANDLE' - ? store.editor.canvas.interactionSession.activeControl.id - : null, - 'GridControl dragging', - ) - - const shadow = React.useMemo(() => { - return cells.find((cell) => EP.toUid(cell.elementPath) === dragging) ?? null - }, [cells, dragging]) - - const [initialShadowFrame, setInitialShadowFrame] = React.useState( - shadow?.globalFrame ?? null, - ) - - const interactionData = useEditorState( - Substores.canvas, - (store) => - store.editor.canvas.interactionSession?.interactionData.type === 'DRAG' - ? store.editor.canvas.interactionSession.interactionData - : null, - 'GridControl interactionData', - ) - - const { hoveringStart } = useMouseMove(activelyDraggingOrResizingCell) - - // NOTE: this stuff is meant to be temporary, until we settle on the set of interaction pieces we like. - // After that, we should get rid of this. - const shadowPosition = React.useMemo(() => { - const drag = interactionData?.drag - const dragStart = interactionData?.dragStart - if ( - initialShadowFrame == null || - interactionData == null || - drag == null || - dragStart == null || - hoveringStart == null || - shadow == null - ) { - return null - } - - const getCoord = (axis: 'x' | 'y', dimension: 'width' | 'height') => { - return ( - shadow.globalFrame[axis] + - drag[axis] - - (shadow.globalFrame[axis] - dragStart[axis]) - - shadow.globalFrame[dimension] * - ((dragStart[axis] - initialShadowFrame[axis]) / initialShadowFrame[dimension]) - ) - } - - // make sure the shadow is displayed only inside the grid container bounds - function wrapCoord(c: number, min: number, max: number, shadowSize: number) { - return Math.min(Math.max(c, min), max - shadowSize) - } - - return { - x: wrapCoord( - getCoord('x', 'width') ?? 0, - grid.frame.x, - grid.frame.x + grid.frame.width, - shadow.globalFrame.width, - ), - y: wrapCoord( - getCoord('y', 'height') ?? 0, - grid.frame.y, - grid.frame.y + grid.frame.height, - shadow.globalFrame.height, - ), - } - }, [ - interactionData, - initialShadowFrame, - hoveringStart, - shadow, - grid.frame.x, - grid.frame.width, - grid.frame.y, - grid.frame.height, - ]) - - const gridPath = optionalMap(EP.parentPath, shadow?.elementPath) - - const targetRootCell = useEditorState( - Substores.canvas, - (store) => store.editor.canvas.controls.gridControlData?.rootCell ?? null, - 'GridControl targetRootCell', - ) - - useCellAnimation({ - disabled: anyTargetAbsolute, - targetRootCell: targetRootCell, - controls: controls, - shadowFrame: initialShadowFrame, - gridPath: gridPath, - }) - - const placeholders = Array.from(Array(grid.cells).keys()) - let style: CSSProperties = { - position: 'absolute', - top: grid.frame.y - 1, // account for border! - left: grid.frame.x - 1, // account for border! - width: grid.frame.width, - height: grid.frame.height, - display: 'grid', - gridTemplateColumns: getNullableAutoOrTemplateBaseString(grid.gridTemplateColumns), - gridTemplateRows: getNullableAutoOrTemplateBaseString(grid.gridTemplateRows), - backgroundColor: - activelyDraggingOrResizingCell != null ? colorTheme.primary10.value : 'transparent', - border: `1px solid ${ - activelyDraggingOrResizingCell != null ? colorTheme.primary.value : 'transparent' - }`, - 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 ( - - {/* grid lines */} -
- {placeholders.map((cell) => { - const countedRow = Math.floor(cell / grid.columns) + 1 - const countedColumn = Math.floor(cell % grid.columns) + 1 - const id = gridCellTargetId(grid.elementPath, countedRow, countedColumn) - const borderID = `${id}-border` - const dotgridColor = - activelyDraggingOrResizingCell != null ? colorTheme.blackOpacity35.value : 'transparent' - - const isActiveCell = - countedColumn === currentHoveredCell?.column && countedRow === currentHoveredCell?.row - - const borderColor = - isActiveCell && targetsAreCellsWithPositioning - ? colorTheme.brandNeonPink.value - : colorTheme.blackOpacity35.value - return ( -
- -
= grid.rows || - (grid.rowGap != null && grid.rowGap > 0) - ? gridPlaceholderBorder(borderColor, scale) - : undefined, - borderRight: - isActiveCell || - countedColumn >= grid.columns || - (grid.columnGap != null && grid.columnGap > 0) - ? gridPlaceholderBorder(borderColor, scale) - : undefined, - }} - /> - -
-
-
-
-
-
-
- - -
- ) - })} -
- {/* cell targets */} - {cells.map((cell) => { - return ( -
- ) - })} - {/* shadow */} - {!anyTargetAbsolute && - shadow != null && - initialShadowFrame != null && - interactionData?.dragStart != null && - interactionData?.drag != null && - hoveringStart != null ? ( - - ) : null} - - ) -}) -GridControl.displayName = 'GridControl' - export interface GridControlsProps { targets: ElementPath[] } @@ -1133,10 +581,11 @@ export const GridControls = controlForStrategyMemoized(({ tar return (
+ + {grids.map((grid) => { + return + })} - {grids.map((grid) => { - return - })}
@@ -1444,134 +893,6 @@ const AbsoluteDistanceIndicators = React.memo( }, ) -function useCellAnimation(params: { - disabled: boolean - gridPath: ElementPath | null - shadowFrame: CanvasRectangle | null - targetRootCell: GridCellCoordinates | null - controls: AnimationControls -}) { - const { gridPath, targetRootCell, controls, shadowFrame, disabled } = params - - const [lastTargetRootCellId, setLastTargetRootCellId] = React.useState(targetRootCell) - const [lastSnapPoint, setLastSnapPoint] = React.useState(shadowFrame) - - const selectedViews = useEditorState( - Substores.selectedViews, - (store) => store.editor.selectedViews, - 'useSnapAnimation selectedViews', - ) - - const animate = useCanvasAnimation(selectedViews) - - const gridMetadata = useEditorState( - Substores.metadata, - (store) => MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, gridPath), - 'useCellAnimation gridMetadata', - ) - - const moveFromPoint = React.useMemo(() => { - return lastSnapPoint ?? shadowFrame - }, [lastSnapPoint, shadowFrame]) - - const snapPoint = React.useMemo(() => { - if (gridMetadata == null || targetRootCell == null) { - return null - } - - return getGlobalFrameOfGridCell(gridMetadata, targetRootCell) - }, [gridMetadata, targetRootCell]) - - React.useEffect(() => { - if (disabled) { - return - } - - if (targetRootCell != null && snapPoint != null && moveFromPoint != null) { - const snapPointsDiffer = lastSnapPoint == null || !pointsEqual(snapPoint, lastSnapPoint) - const hasMovedToANewCell = lastTargetRootCellId != null - const shouldAnimate = snapPointsDiffer && hasMovedToANewCell - if (shouldAnimate) { - void animate( - { - scale: [0.97, 1.02, 1], // a very subtle boop - x: [moveFromPoint.x - snapPoint.x, 0], - y: [moveFromPoint.y - snapPoint.y, 0], - }, - { - duration: CELL_ANIMATION_DURATION, - type: 'tween', - ease: 'easeInOut', - }, - ) - } - } - setLastSnapPoint(snapPoint) - setLastTargetRootCellId(targetRootCell) - }, [ - targetRootCell, - controls, - lastSnapPoint, - snapPoint, - animate, - moveFromPoint, - lastTargetRootCellId, - disabled, - ]) -} - -function useMouseMove(activelyDraggingOrResizingCell: string | null) { - const [hoveringStart, setHoveringStart] = React.useState<{ - point: CanvasPoint - } | null>(null) - const [mouseCanvasPosition, setMouseCanvasPosition] = React.useState( - canvasPoint({ x: 0, y: 0 }), - ) - - const canvasScale = useEditorState( - Substores.canvasOffset, - (store) => store.editor.canvas.scale, - 'useHoveringCell canvasScale', - ) - - const canvasOffset = useEditorState( - Substores.canvasOffset, - (store) => store.editor.canvas.roundedCanvasOffset, - 'useHoveringCell canvasOffset', - ) - - React.useEffect(() => { - function handleMouseMove(e: MouseEvent) { - if (activelyDraggingOrResizingCell == null) { - setHoveringStart(null) - return - } - - const newMouseCanvasPosition = windowToCanvasCoordinates( - canvasScale, - canvasOffset, - windowPoint({ x: e.clientX, y: e.clientY }), - ).canvasPositionRaw - setMouseCanvasPosition(newMouseCanvasPosition) - - setHoveringStart((start) => { - if (start == null) { - return { - point: canvasPoint(newMouseCanvasPosition), - } - } - return start - }) - } - window.addEventListener('mousemove', handleMouseMove) - return function () { - window.removeEventListener('mousemove', handleMouseMove) - } - }, [activelyDraggingOrResizingCell, canvasOffset, canvasScale]) - - return { hoveringStart, mouseCanvasPosition } -} - export const GridResizeEdgeTestId = (edge: GridResizeEdge) => `grid-resize-edge-${edge}` interface GridResizeControlProps { @@ -1784,7 +1105,7 @@ export function gridEdgeToEdgePosition(edge: GridResizeEdge): EdgePosition { } } -function gridEdgeToCSSCursor(edge: GridResizeEdge): CSSCursor { +export function gridEdgeToCSSCursor(edge: GridResizeEdge): CSSCursor { switch (edge) { case 'column-end': case 'column-start': @@ -1797,7 +1118,10 @@ function gridEdgeToCSSCursor(edge: GridResizeEdge): CSSCursor { } } -function gridEdgeToWidthHeight(props: GridResizeEdgeProperties, scale: number): CSSProperties { +export function gridEdgeToWidthHeight( + props: GridResizeEdgeProperties, + scale: number, +): CSSProperties { return { width: props.isColumn ? (GRID_RESIZE_HANDLE_SIZES.short * 4) / scale : '100%', height: props.isRow ? (GRID_RESIZE_HANDLE_SIZES.short * 4) / scale : '100%', @@ -1808,24 +1132,6 @@ function gridEdgeToWidthHeight(props: GridResizeEdgeProperties, scale: number): } } -function gridKeyFromPath(path: ElementPath): string { - return `grid-${EP.toString(path)}` -} - -const placeholderBorderBaseWidth = 2 - -function gridPlaceholderBorder(color: string, scale: number): string { - return `${placeholderBorderBaseWidth / scale}px solid ${color}` -} - -function gridPlaceholderTopOrLeftPosition(scale: number): string { - return `${-placeholderBorderBaseWidth / scale}px` -} - -function gridPlaceholderWidthOrHeight(scale: number): string { - return `calc(100% + ${(placeholderBorderBaseWidth * 2) / scale}px)` -} - export function controlsForGridPlaceholders( gridPath: ElementPath, whenToShow: WhenToShowControl = 'always-visible', 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 adca71944cc3..86d13f5c1d66 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,6 +1,6 @@ import React, { useState } from 'react' import type { CanvasRectangle, CanvasVector, Size } from '../../../../core/shared/math-utils' -import { size, windowPoint } from '../../../../core/shared/math-utils' +import { canvasRectangle, 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' @@ -25,6 +25,7 @@ import { useBoundingBox } from '../bounding-box-hooks' import { isZeroSizedElement } from '../outline-utils' import { createArrayWithLength } from '../../../../core/shared/array-utils' import { useGridData } from '../grid-controls' +import { x } from 'tar' export interface GridGapControlProps { selectedElement: ElementPath @@ -134,21 +135,6 @@ export const GridGapControl = controlForStrategyMemoized((p 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 @@ -160,59 +146,64 @@ export const GridGapControl = controlForStrategyMemoized((p }) 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 ( - - ) - })} -
-
+
+ {controlBounds.gaps.map(({ gap, bounds, axis, gapId }) => { + const boundsFixed = canvasRectangle({ + x: axis === 'row' ? 0 : bounds.x, + y: axis === 'row' ? bounds.y : 0, + width: axis === 'row' ? 20 : bounds.width, + height: axis === 'row' ? bounds.height : 20, + }) + const gapControlProps = { + mouseDownHandler: axisMouseDownHandler, + gapId: gapId, + bounds: boundsFixed, + 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, + } + return ( + + {axis === 'row' ? ( + + + + ) : ( + + + + )} + + ) + })} +
) }) @@ -346,12 +337,10 @@ const GapControlSegment = React.memo((props) => { justifyContent: 'center', placeItems: 'center', gap: internalGrid.gap.value, - gridTemplateColumns: axis === 'row' ? internalGrid.gridTemplateColumns : '1fr', - gridTemplateRows: axis === 'column' ? internalGrid.gridTemplateRows : '1fr', position: 'relative', }} > - {createArrayWithLength(handles, (i) => ( + {createArrayWithLength(1, (i) => ( { gridTemplateColumns: `[col] ${columnWidths[0]}px [col] ${columnWidths[1]}px [canvas] 1fr [col] ${columnWidths[2]}px [col] ${columnWidths[3]}px [end]`, gridTemplateRows: 'repeat(12, 1fr)', gridAutoFlow: 'dense', - paddingTop: GridPanelVerticalGapHalf + GridVerticalExtraPadding, + paddingTop: GridPanelVerticalGapHalf + GridVerticalExtraPadding + 20, paddingBottom: GridPanelVerticalGapHalf + GridVerticalExtraPadding, - paddingLeft: GridPanelHorizontalGapHalf + GridHorizontalExtraPadding, + paddingLeft: GridPanelHorizontalGapHalf + GridHorizontalExtraPadding + 20, paddingRight: GridPanelHorizontalGapHalf + GridHorizontalExtraPadding, }} >