diff --git a/editor/src/components/canvas/canvas-strategies/strategies/absolute-resize-bounding-box-strategy.spec.browser2.tsx b/editor/src/components/canvas/canvas-strategies/strategies/absolute-resize-bounding-box-strategy.spec.browser2.tsx index a71db5b5a05f..db8ad88f5dec 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/absolute-resize-bounding-box-strategy.spec.browser2.tsx +++ b/editor/src/components/canvas/canvas-strategies/strategies/absolute-resize-bounding-box-strategy.spec.browser2.tsx @@ -1470,6 +1470,164 @@ export var storyboard = ( altModifier, ) }) + describe('snapping to pinned children', () => { + it('container does not snap to flow child', async () => { + const renderResult = await renderTestEditorWithCode( + makeTestProjectCodeWithSnippet(` +
+
+
+
+
+ `), + 'await-first-dom-report', + ) + + const target = EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb']) + + await renderResult.dispatch([selectComponents([target], false)], true) + await doSnapDrag(renderResult, { x: 0, y: 20 }, EdgePositionTop, async () => { + // no guidelines are shown + expect( + renderResult.getEditorState().editor.canvas.controls.snappingGuidelines.length, + ).toEqual(0) + }) + + await renderResult.getDispatchFollowUpActionsFinished() + expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual( + makeTestProjectCodeWithSnippet(` +
+
+
+
+
+ `), + ) + }) + it('container does not snap to flex child', async () => { + const renderResult = await renderTestEditorWithCode( + makeTestProjectCodeWithSnippet(` +
+
+
+
+
+ `), + 'await-first-dom-report', + ) + + const target = EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb']) + + await renderResult.dispatch([selectComponents([target], false)], true) + await doSnapDrag(renderResult, { x: 0, y: 20 }, EdgePositionTop, async () => { + // no guidelines are shown + expect( + renderResult.getEditorState().editor.canvas.controls.snappingGuidelines.length, + ).toEqual(0) + }) + + await renderResult.getDispatchFollowUpActionsFinished() + expect(getPrintedUiJsCode(renderResult.getEditorState())).toEqual( + makeTestProjectCodeWithSnippet(` +
+
+
+
+
+ `), + ) + }) + ;( + [ + [windowPoint({ y: 0, x: 10 }), EdgePositionLeft, 'left', 1], + [windowPoint({ y: 10, x: 0 }), EdgePositionTop, 'top', 1], + [windowPoint({ y: 10, x: 10 }), EdgePositionTopLeft, 'top left', 2], + [windowPoint({ y: 0, x: -20 }), EdgePositionRight, 'right', 0], + [windowPoint({ y: -20, x: 0 }), EdgePositionBottom, 'bottom', 0], + [windowPoint({ y: -20, x: -20 }), EdgePositionBottomRight, 'bottom right', 0], + ] as const + ).forEach(([delta, edge, label, numberOfGuidelines]) => { + it(`${numberOfGuidelines} snap lines shown when resizing container with absolte child pinned to bottom and right - dragging from ${label}`, async () => { + const renderResult = await renderTestEditorWithCode( + makeTestProjectCodeWithSnippet(` +
+
+
+
+
+ `), + 'await-first-dom-report', + ) + + const target = EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb']) + + await renderResult.dispatch([selectComponents([target], false)], true) + await doSnapDrag(renderResult, delta, edge, async () => { + // guidelines are shown + expect( + renderResult.getEditorState().editor.canvas.controls.snappingGuidelines.length, + ).toEqual(numberOfGuidelines) + }) + }) + }) + ;( + [ + [windowPoint({ y: 0, x: 20 }), EdgePositionLeft, 'left', 0], + [windowPoint({ y: 20, x: 0 }), EdgePositionTop, 'top', 0], + [windowPoint({ y: 20, x: 10 }), EdgePositionTopLeft, 'top left', 0], + [windowPoint({ y: 0, x: -10 }), EdgePositionRight, 'right', 1], + [windowPoint({ y: -10, x: 0 }), EdgePositionBottom, 'bottom', 1], + [windowPoint({ y: -10, x: -10 }), EdgePositionBottomRight, 'bottom right', 2], + ] as const + ).forEach(([delta, edge, label, numberOfGuidelines]) => { + it(`${numberOfGuidelines} snap lines shown when resizing container with absolte child pinned to top and left - dragging from ${label}`, async () => { + const renderResult = await renderTestEditorWithCode( + makeTestProjectCodeWithSnippet(` +
+
+
+
+
+ `), + 'await-first-dom-report', + ) + + const target = EP.appendNewElementPath(TestScenePath, ['aaa', 'bbb']) + + await renderResult.dispatch([selectComponents([target], false)], true) + await doSnapDrag(renderResult, delta, edge, async () => { + // guidelines are shown + expect( + renderResult.getEditorState().editor.canvas.controls.snappingGuidelines.length, + ).toEqual(numberOfGuidelines) + }) + }) + }) + }) describe('groups', () => { AllFragmentLikeTypes.forEach((type) => { diff --git a/editor/src/components/canvas/canvas-strategies/strategies/absolute-resize-bounding-box-strategy.tsx b/editor/src/components/canvas/canvas-strategies/strategies/absolute-resize-bounding-box-strategy.tsx index cb0a48afbbd2..c2310ace6960 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/absolute-resize-bounding-box-strategy.tsx +++ b/editor/src/components/canvas/canvas-strategies/strategies/absolute-resize-bounding-box-strategy.tsx @@ -28,6 +28,7 @@ import { setCursorCommand } from '../../commands/set-cursor-command' import { setElementsToRerenderCommand } from '../../commands/set-elements-to-rerender-command' import { setSnappingGuidelines } from '../../commands/set-snapping-guidelines-command' import { updateHighlightedViews } from '../../commands/update-highlighted-views-command' +import { gatherParentAndSiblingTargets } from '../../controls/guideline-helpers' import { ImmediateParentBounds } from '../../controls/parent-bounds' import { ImmediateParentOutlines } from '../../controls/parent-outlines' import { AbsoluteResizeControl } from '../../controls/select-mode/absolute-resize-control' @@ -56,7 +57,7 @@ import { } from './resize-helpers' import type { EnsureFramePointsExist } from './resize-strategy-helpers' import { createResizeCommandsFromFrame } from './resize-strategy-helpers' -import { runLegacyAbsoluteResizeSnapping } from './shared-absolute-resize-strategy-helpers' +import { childrenBoundsToSnapTo, snapBoundingBox } from './shared-absolute-resize-strategy-helpers' import { flattenSelection, getMultiselectBounds } from './shared-move-strategies-helpers' export function absoluteResizeBoundingBoxStrategy( @@ -153,7 +154,22 @@ export function absoluteResizeBoundingBoxStrategy( lockedAspectRatio, centerBased, ) + const parentAndSiblings: ElementPath[] = gatherParentAndSiblingTargets( + canvasState.startingMetadata, + canvasState.startingAllElementProps, + canvasState.startingElementPathTree, + originalTargets, + ) + const childrenToSnapTo = childrenBoundsToSnapTo( + edgePosition, + originalTargets, + canvasState.startingMetadata, + canvasState.startingAllElementProps, + canvasState.startingElementPathTree, + ) + const snapTargets = [...childrenToSnapTo, ...parentAndSiblings] const { snappedBoundingBox, guidelinesWithSnappingVector } = snapBoundingBox( + snapTargets, originalTargets, canvasState.startingMetadata, edgePosition, @@ -492,32 +508,3 @@ function getBoundDimension( return null } } - -function snapBoundingBox( - selectedElements: Array, - jsxMetadata: ElementInstanceMetadataMap, - edgePosition: EdgePosition, - resizedBounds: CanvasRectangle, - canvasScale: number, - lockedAspectRatio: number | null, - centerBased: 'center-based' | 'non-center-based', - allElementProps: AllElementProps, - pathTrees: ElementPathTrees, -) { - const { snappedBoundingBox, guidelinesWithSnappingVector } = runLegacyAbsoluteResizeSnapping( - selectedElements, - jsxMetadata, - edgePosition, - resizedBounds, - canvasScale, - lockedAspectRatio, - centerBased, - allElementProps, - pathTrees, - ) - - return { - snappedBoundingBox, - guidelinesWithSnappingVector, - } -} diff --git a/editor/src/components/canvas/canvas-strategies/strategies/keyboard-absolute-move-strategy.ts b/editor/src/components/canvas/canvas-strategies/strategies/keyboard-absolute-move-strategy.ts index 4bd7050ad1df..97aa8a6c7ee2 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/keyboard-absolute-move-strategy.ts +++ b/editor/src/components/canvas/canvas-strategies/strategies/keyboard-absolute-move-strategy.ts @@ -1,5 +1,5 @@ import { MetadataUtils } from '../../../../core/model/element-metadata-utils' -import { Keyboard, KeyCharacter } from '../../../../utils/keyboard' +import { Keyboard } from '../../../../utils/keyboard' import type { CanvasStrategy, InteractionCanvasState } from '../canvas-strategy-types' import { emptyStrategyApplicationResult, @@ -8,7 +8,6 @@ import { } from '../canvas-strategy-types' import type { CanvasVector } from '../../../../core/shared/math-utils' import { - CanvasRectangle, canvasRectangle, offsetPoint, offsetRect, @@ -36,6 +35,7 @@ import { honoursPropsPosition } from './absolute-utils' import type { InteractionSession } from '../interaction-state' import type { ElementPath } from '../../../../core/shared/project-file-types' import { retargetStrategyToChildrenOfFragmentLikeElements } from './fragment-like-helpers' +import { gatherParentAndSiblingTargets } from '../../controls/guideline-helpers' export function keyboardAbsoluteMoveStrategy( canvasState: InteractionCanvasState, @@ -93,7 +93,13 @@ export function keyboardAbsoluteMoveStrategy( keyboardMovement, ) - const guidelines = getKeyboardStrategyGuidelines(canvasState, interactionSession, newFrame) + const snapTargets: ElementPath[] = gatherParentAndSiblingTargets( + canvasState.startingMetadata, + canvasState.startingAllElementProps, + canvasState.startingElementPathTree, + getTargetPathsFromInteractionTarget(canvasState.interactionTarget), + ) + const guidelines = getKeyboardStrategyGuidelines(snapTargets, interactionSession, newFrame) commands.push(setSnappingGuidelines('mid-interaction', guidelines)) commands.push(pushIntendedBoundsAndUpdateGroups(intendedBounds, 'starting-metadata')) diff --git a/editor/src/components/canvas/canvas-strategies/strategies/keyboard-absolute-resize-strategy.tsx b/editor/src/components/canvas/canvas-strategies/strategies/keyboard-absolute-resize-strategy.tsx index 571ce3c8db78..fcc7442d05c9 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/keyboard-absolute-resize-strategy.tsx +++ b/editor/src/components/canvas/canvas-strategies/strategies/keyboard-absolute-resize-strategy.tsx @@ -25,11 +25,15 @@ import type { CanvasStrategy, InteractionCanvasState } from '../canvas-strategy- import { controlWithProps, emptyStrategyApplicationResult, + getTargetPathsFromInteractionTarget, strategyApplicationResult, } from '../canvas-strategy-types' import type { InteractionSession } from '../interaction-state' import { resizeBoundingBox, supportsAbsoluteResize } from './resize-helpers' -import { createResizeCommands } from './shared-absolute-resize-strategy-helpers' +import { + childrenBoundsToSnapTo, + createResizeCommands, +} from './shared-absolute-resize-strategy-helpers' import type { AccumulatedPresses } from './shared-keyboard-strategy-helpers' import { accumulatePresses, @@ -44,6 +48,9 @@ import type { ElementPath } from '../../../../core/shared/project-file-types' import type { ProjectContentTreeRoot } from '../../../../components/assets' import type { InspectorStrategy } from '../../../../components/inspector/inspector-strategies/inspector-strategy' import type { ElementPathTrees } from '../../../../core/shared/element-path-tree' +import { gatherParentAndSiblingTargets } from '../../controls/guideline-helpers' +import { uniqBy } from '../../../../core/shared/array-utils' +import * as EP from '../../../../core/shared/element-path' interface VectorAndEdge { movement: CanvasVector @@ -278,7 +285,21 @@ export function keyboardAbsoluteResizeStrategy( intendedBounds = changeBoundsResult.intendedBounds commands.push(...changeBoundsResult.commands) }) - const guidelines = getKeyboardStrategyGuidelines(canvasState, interactionSession, newFrame) + const parentsAndSiblings: ElementPath[] = gatherParentAndSiblingTargets( + canvasState.startingMetadata, + canvasState.startingAllElementProps, + canvasState.startingElementPathTree, + getTargetPathsFromInteractionTarget(canvasState.interactionTarget), + ) + const children = getChildrenToSnapTo( + deduplicateEdges(movementsWithEdges.map((m) => m.edge)), + selectedElements, + canvasState.startingMetadata, + canvasState.startingAllElementProps, + canvasState.startingElementPathTree, + ) + const snapTargets = [...children, ...parentsAndSiblings] + const guidelines = getKeyboardStrategyGuidelines(snapTargets, interactionSession, newFrame) commands.push(setSnappingGuidelines('mid-interaction', guidelines)) commands.push(pushIntendedBoundsAndUpdateGroups(intendedBounds, 'starting-metadata')) commands.push(setElementsToRerenderCommand(selectedElements)) @@ -302,3 +323,22 @@ function getEdgePositionFromKey(key: KeyCharacter): EdgePosition | null { return null } } + +function deduplicateEdges(edges: EdgePosition[]): EdgePosition[] { + return uniqBy(edges, (l, r) => l.x === r.x && l.y === r.y) +} + +function getChildrenToSnapTo( + edgePositions: EdgePosition[], + targets: Array, + componentMetadata: ElementInstanceMetadataMap, + allElementProps: AllElementProps, + pathTrees: ElementPathTrees, +) { + return uniqBy( + edgePositions.flatMap((edge) => + childrenBoundsToSnapTo(edge, targets, componentMetadata, allElementProps, pathTrees), + ), + (l, r) => EP.toString(l) === EP.toString(r), + ) +} diff --git a/editor/src/components/canvas/canvas-strategies/strategies/shared-absolute-resize-strategy-helpers.ts b/editor/src/components/canvas/canvas-strategies/strategies/shared-absolute-resize-strategy-helpers.ts index d4712d45077a..d765485eac5b 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/shared-absolute-resize-strategy-helpers.ts +++ b/editor/src/components/canvas/canvas-strategies/strategies/shared-absolute-resize-strategy-helpers.ts @@ -1,9 +1,10 @@ import { styleStringInArray } from '../../../../utils/common-constants' import { isHorizontalPoint } from 'utopia-api/core' import { getLayoutProperty } from '../../../../core/layout/getLayoutProperty' +import type { LayoutEdgeProp, LayoutPinnedProp } from '../../../../core/layout/layout-helpers-new' import { framePointForPinnedProp } from '../../../../core/layout/layout-helpers-new' -import { mapDropNulls } from '../../../../core/shared/array-utils' -import { isRight, right } from '../../../../core/shared/either' +import { mapDropNulls, stripNulls } from '../../../../core/shared/array-utils' +import { isLeft, isRight, right } from '../../../../core/shared/either' import type { ElementInstanceMetadataMap, JSXElement, @@ -17,7 +18,7 @@ import { import type { ElementPath } from '../../../../core/shared/project-file-types' import type { AllElementProps } from '../../../editor/store/editor-state' import { stylePropPathMappingFn } from '../../../inspector/common/property-path-hooks' -import type { CanvasFrameAndTarget, EdgePosition } from '../../canvas-types' +import { type CanvasFrameAndTarget, type EdgePosition } from '../../canvas-types' import { pickPointOnRect, snapPoint } from '../../canvas-utils' import type { AdjustCssLengthProperties } from '../../commands/adjust-css-length-command' import { @@ -25,11 +26,12 @@ import { lengthPropertyToAdjust, } from '../../commands/adjust-css-length-command' import { pointGuidelineToBoundsEdge } from '../../controls/guideline-helpers' -import type { GuidelineWithSnappingVectorAndPointsOfRelevance } from '../../guideline' -import type { AbsolutePin, IsCenterBased } from './resize-helpers' +import type { AbsolutePin } from './resize-helpers' import { resizeBoundingBox } from './resize-helpers' import type { FlexDirection } from '../../../inspector/common/css-utils' import type { ElementPathTrees } from '../../../../core/shared/element-path-tree' +import { replaceNonDOMElementPathsWithTheirChildrenRecursive } from './fragment-like-helpers' +import { MetadataUtils } from '../../../../core/model/element-metadata-utils' export function createResizeCommands( element: JSXElement, @@ -103,21 +105,18 @@ function pinsForEdgePosition(edgePosition: EdgePosition): AbsolutePin[] { return [...horizontalPins, ...verticalPins] } -export function runLegacyAbsoluteResizeSnapping( +export function snapBoundingBox( + elementsToSnapTo: ElementPath[], selectedElements: Array, jsxMetadata: ElementInstanceMetadataMap, draggedCorner: EdgePosition, resizedBounds: CanvasRectangle, canvasScale: number, lockedAspectRatio: number | null, - centerBased: IsCenterBased, + centerBased: 'center-based' | 'non-center-based', allElementProps: AllElementProps, pathTrees: ElementPathTrees, -): { - snapDelta: CanvasVector - snappedBoundingBox: CanvasRectangle - guidelinesWithSnappingVector: Array -} { +) { const oppositeCorner: EdgePosition = { x: 1 - draggedCorner.x, y: 1 - draggedCorner.y, @@ -130,6 +129,7 @@ export function runLegacyAbsoluteResizeSnapping( : getPointOnDiagonal(draggedCorner, draggedPointMovedWithoutSnap, lockedAspectRatio) const { snappedPointOnCanvas, guidelinesWithSnappingVector } = snapPoint( + elementsToSnapTo, selectedElements, jsxMetadata, canvasScale, @@ -219,3 +219,58 @@ function getPointOnDiagonal( } throw new Error(`Edge position ${edgePosition} is not a corner position`) } + +const hasPin = (pin: LayoutPinnedProp, element: JSXElement) => { + const rawPin = getLayoutProperty(pin, right(element.props), styleStringInArray) + return isRight(rawPin) && rawPin.value != null +} + +export function childrenBoundsToSnapTo( + resizingFromEdge: EdgePosition, + targets: ElementPath[], + metadata: ElementInstanceMetadataMap, + allElementProps: AllElementProps, + pathTrees: ElementPathTrees, +): ElementPath[] { + const actualChildren = targets.flatMap((target) => { + const childPaths = MetadataUtils.getChildrenUnordered(metadata, target).map( + (child) => child.elementPath, + ) + return replaceNonDOMElementPathsWithTheirChildrenRecursive( + metadata, + allElementProps, + pathTrees, + childPaths, + ) + }) + + // exclude the children that pinned to the edge/corner that's being resized + const pinsToExclude = pinsFromEdgePosition(resizingFromEdge) + return actualChildren.filter((child) => { + const element = MetadataUtils.findElementByElementPath(metadata, child) + if ( + element == null || + !MetadataUtils.isPositionAbsolute(element) || + isLeft(element.element) || + element.element.value.type !== 'JSX_ELEMENT' + ) { + return false + } + const jsxElement = element.element.value + const hasForbiddenPin = pinsToExclude.some((p) => hasPin(p, jsxElement)) + return !hasForbiddenPin + }) +} + +function pinsFromEdgePosition(edgePosition: EdgePosition): Array { + const leftEdge = edgePosition.x === 0 + const rightEdge = edgePosition.x === 1 + const topEdge = edgePosition.y === 0 + const bottomEdge = edgePosition.y === 1 + return stripNulls([ + leftEdge ? 'left' : null, + rightEdge ? 'right' : null, + topEdge ? 'top' : null, + bottomEdge ? 'bottom' : null, + ]) +} diff --git a/editor/src/components/canvas/canvas-strategies/strategies/shared-keyboard-strategy-helpers.ts b/editor/src/components/canvas/canvas-strategies/strategies/shared-keyboard-strategy-helpers.ts index b5d12c5ebb52..b7d9a4800b9e 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/shared-keyboard-strategy-helpers.ts +++ b/editor/src/components/canvas/canvas-strategies/strategies/shared-keyboard-strategy-helpers.ts @@ -2,6 +2,7 @@ import { last, mapDropNulls } from '../../../../core/shared/array-utils' import * as EP from '../../../../core/shared/element-path' import type { CanvasRectangle, CanvasVector } from '../../../../core/shared/math-utils' import { canvasRectangle, getRoundedRectPointsAlongAxes } from '../../../../core/shared/math-utils' +import type { ElementPath } from '../../../../core/shared/project-file-types' import { setsEqual } from '../../../../core/shared/set-utils' import type { KeyCharacter } from '../../../../utils/keyboard' import type { Modifiers } from '../../../../utils/modifiers' @@ -14,8 +15,6 @@ import { } from '../../controls/guideline-helpers' import type { GuidelineWithSnappingVectorAndPointsOfRelevance } from '../../guideline' import { Guidelines } from '../../guideline' -import type { InteractionCanvasState } from '../canvas-strategy-types' -import { getTargetPathsFromInteractionTarget } from '../canvas-strategy-types' import type { InteractionSession, KeyState } from '../interaction-state' export interface AccumulatedPresses extends KeyState { @@ -95,16 +94,13 @@ export function getMovementDeltaFromKey(key: KeyCharacter, modifiers: Modifiers) } export function getKeyboardStrategyGuidelines( - canvasState: InteractionCanvasState, + snapTargets: ElementPath[], interactionSession: InteractionSession, draggedFrame: CanvasRectangle, ): Array { - const selectedElements = getTargetPathsFromInteractionTarget(canvasState.interactionTarget) const moveGuidelines = collectParentAndSiblingGuidelines( + snapTargets, interactionSession.latestMetadata, - canvasState.startingAllElementProps, - canvasState.startingElementPathTree, - selectedElements, ).map((g) => g.guideline) const { horizontalPoints, verticalPoints } = getRoundedRectPointsAlongAxes(draggedFrame) diff --git a/editor/src/components/canvas/canvas-strategies/strategies/shared-move-strategies-helpers.ts b/editor/src/components/canvas/canvas-strategies/strategies/shared-move-strategies-helpers.ts index 79a6e56e5bad..93ce895d5e1b 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/shared-move-strategies-helpers.ts +++ b/editor/src/components/canvas/canvas-strategies/strategies/shared-move-strategies-helpers.ts @@ -44,6 +44,7 @@ import { setSnappingGuidelines } from '../../commands/set-snapping-guidelines-co import { updateHighlightedViews } from '../../commands/update-highlighted-views-command' import { collectParentAndSiblingGuidelines, + gatherParentAndSiblingTargets, runLegacyAbsoluteMoveSnapping, } from '../../controls/guideline-helpers' import type { @@ -132,12 +133,16 @@ export function applyMoveCommon( const targetsForSnapping = targets.map( (path) => interactionSession.updatedTargetPaths[EP.toString(path)] ?? path, ) - const moveGuidelines = collectParentAndSiblingGuidelines( + const snapTargets: ElementPath[] = gatherParentAndSiblingTargets( canvasState.startingMetadata, canvasState.startingAllElementProps, canvasState.startingElementPathTree, originalTargets, ) + const moveGuidelines = collectParentAndSiblingGuidelines( + snapTargets, + canvasState.startingMetadata, + ) const { snappedDragVector, guidelinesWithSnappingVector } = snapDrag( drag, diff --git a/editor/src/components/canvas/canvas-utils.ts b/editor/src/components/canvas/canvas-utils.ts index 13bc58f6c8df..2c9c45ba00d1 100644 --- a/editor/src/components/canvas/canvas-utils.ts +++ b/editor/src/components/canvas/canvas-utils.ts @@ -122,7 +122,6 @@ import { filterGuidelinesStaticAxis, oneGuidelinePerDimension, } from './controls/guideline-helpers' -import { determineElementsToOperateOnForDragging } from './controls/select-mode/move-utils' import type { GuidelineWithRelevantPoints, GuidelineWithSnappingVectorAndPointsOfRelevance, @@ -885,23 +884,21 @@ export function isEdgePositionEqualTo(a: EdgePosition, b: EdgePosition): boolean export const SnappingThreshold = 5 export function collectGuidelines( + snapTargets: ElementPath[], metadata: ElementInstanceMetadataMap, selectedViews: Array, scale: number, draggedPoint: CanvasPoint | null, resizingFromPosition: EdgePosition | null, allElementProps: AllElementProps, - pathTrees: ElementPathTrees, ): Array { if (draggedPoint == null) { return [] } let guidelines: Array = collectParentAndSiblingGuidelines( + snapTargets, metadata, - allElementProps, - pathTrees, - selectedViews, ) // For any images create guidelines at the current multiplier setting. @@ -1164,13 +1161,13 @@ export function collectGuidelines( } function innerSnapPoint( + snapTargets: ElementPath[], selectedViews: Array, jsxMetadata: ElementInstanceMetadataMap, canvasScale: number, point: CanvasPoint, resizingFromPosition: EdgePosition | null, allElementProps: AllElementProps, - pathTrees: ElementPathTrees, ): { point: CanvasPoint snappedGuideline: GuidelineWithSnappingVectorAndPointsOfRelevance | null @@ -1178,13 +1175,13 @@ function innerSnapPoint( } { const guidelines = oneGuidelinePerDimension( collectGuidelines( + snapTargets, jsxMetadata, selectedViews, canvasScale, point, resizingFromPosition, allElementProps, - pathTrees, ), ) let snappedPoint = point @@ -1202,6 +1199,7 @@ function innerSnapPoint( } export function snapPoint( + elementsToTarget: ElementPath[], selectedViews: Array, jsxMetadata: ElementInstanceMetadataMap, canvasScale: number, @@ -1219,14 +1217,7 @@ export function snapPoint( snappedPointOnCanvas: CanvasPoint guidelinesWithSnappingVector: Array } { - const elementsToTarget = determineElementsToOperateOnForDragging( - selectedViews, - jsxMetadata, - true, - false, - ) - - const anythingPinnedAndNotAbsolutePositioned = elementsToTarget.some((elementToTarget) => { + const anythingPinnedAndNotAbsolutePositioned = selectedViews.some((elementToTarget) => { return MetadataUtils.isPinnedAndNotAbsolutePositioned(jsxMetadata, elementToTarget) }) @@ -1243,62 +1234,68 @@ export function snapPoint( !resizedBoundsBelowThreshold && (anyElementFragmentLike || !anythingPinnedAndNotAbsolutePositioned) - if (keepAspectRatio) { - const closestPointOnLine = Utils.closestPointOnLine(diagonalA, diagonalB, pointToSnap) - if (shouldSnap) { - const { snappedGuideline: guideline, guidelinesWithSnappingVector } = innerSnapPoint( - selectedViews, - jsxMetadata, - canvasScale, - closestPointOnLine, - resizingFromPosition, - allElementProps, - pathTrees, - ) - if (guideline != null) { - const guidelinePoints = Guidelines.convertGuidelineToPoints(guideline.guideline) - // for now, because scale is not a first-class citizen, we know that CanvasVector and LocalVector have the same dimensions - let snappedPoint: CanvasPoint | null = null - switch (guidelinePoints.type) { - case 'cornerguidelinepoint': - snappedPoint = guidelinePoints.point - break - default: - snappedPoint = Utils.lineIntersection( - diagonalA, - diagonalB, - guidelinePoints.start, - guidelinePoints.end, - ) - } - if (snappedPoint != null) { - return { - snappedPointOnCanvas: snappedPoint, - guidelinesWithSnappingVector: guidelinesWithSnappingVector, - } - } - } - // fallback to regular diagonal snapping - return { snappedPointOnCanvas: closestPointOnLine, guidelinesWithSnappingVector: [] } - } else { - return { snappedPointOnCanvas: pointToSnap, guidelinesWithSnappingVector: [] } - } - } else { + if (!shouldSnap) { + return { snappedPointOnCanvas: pointToSnap, guidelinesWithSnappingVector: [] } + } + + if (!keepAspectRatio) { const { point, guidelinesWithSnappingVector } = innerSnapPoint( + elementsToTarget, selectedViews, jsxMetadata, canvasScale, pointToSnap, resizingFromPosition, allElementProps, - pathTrees, ) - return shouldSnap - ? { - snappedPointOnCanvas: point, - guidelinesWithSnappingVector: guidelinesWithSnappingVector, - } - : { snappedPointOnCanvas: pointToSnap, guidelinesWithSnappingVector: [] } + return { + snappedPointOnCanvas: point, + guidelinesWithSnappingVector: guidelinesWithSnappingVector, + } + } + + const closestPointOnLine = Utils.closestPointOnLine(diagonalA, diagonalB, pointToSnap) + const { snappedGuideline: guideline, guidelinesWithSnappingVector } = innerSnapPoint( + elementsToTarget, + selectedViews, + jsxMetadata, + canvasScale, + closestPointOnLine, + resizingFromPosition, + allElementProps, + ) + + const snappedPoint = optionalMap( + (g) => snappedPointFromGuideline(g, diagonalA, diagonalB), + guideline, + ) + if (snappedPoint == null) { + return { snappedPointOnCanvas: closestPointOnLine, guidelinesWithSnappingVector: [] } + } + + return { + snappedPointOnCanvas: snappedPoint, + guidelinesWithSnappingVector: guidelinesWithSnappingVector, + } +} + +function snappedPointFromGuideline( + guideline: GuidelineWithSnappingVectorAndPointsOfRelevance, + diagonalA: CanvasPoint, + diagonalB: CanvasPoint, +): CanvasPoint | null { + const guidelinePoints = Guidelines.convertGuidelineToPoints(guideline.guideline) + // for now, because scale is not a first-class citizen, we know that CanvasVector and LocalVector have the same dimensions + switch (guidelinePoints.type) { + case 'cornerguidelinepoint': + return guidelinePoints.point + default: + return Utils.lineIntersection( + diagonalA, + diagonalB, + guidelinePoints.start, + guidelinePoints.end, + ) } } diff --git a/editor/src/components/canvas/controls/guideline-helpers.ts b/editor/src/components/canvas/controls/guideline-helpers.ts index 0b8b47acafe1..8067bbe3fab0 100644 --- a/editor/src/components/canvas/controls/guideline-helpers.ts +++ b/editor/src/components/canvas/controls/guideline-helpers.ts @@ -61,14 +61,13 @@ function getSnapTargetsForElementPath( return [parent, ...siblings] } -export function collectParentAndSiblingGuidelines( +export function gatherParentAndSiblingTargets( componentMetadata: ElementInstanceMetadataMap, allElementProps: AllElementProps, pathTrees: ElementPathTrees, targets: Array, -): Array { - const result: Array = [] - Utils.fastForEach(targets, (target) => { +) { + return targets.flatMap((target) => { const pinnedAndNotAbsolutePositioned = MetadataUtils.isPinnedAndNotAbsolutePositioned( componentMetadata, target, @@ -82,18 +81,26 @@ export function collectParentAndSiblingGuidelines( ) if (isElementFragmentLike || !pinnedAndNotAbsolutePositioned) { - const snapTargets = getSnapTargetsForElementPath( + return getSnapTargetsForElementPath( componentMetadata, allElementProps, pathTrees, target, ).filter((snapTarget) => targets.every((t) => !EP.pathsEqual(snapTarget, t))) - fastForEach(snapTargets, (snapTarget) => { - const frame = MetadataUtils.getFrameInCanvasCoords(snapTarget, componentMetadata) - if (frame != null && isFiniteRectangle(frame)) { - result.push(...Guidelines.guidelinesWithRelevantPointsForFrame(frame, 'include')) - } - }) + } + return [] + }) +} + +export function collectParentAndSiblingGuidelines( + snapTargets: Array, + componentMetadata: ElementInstanceMetadataMap, +): Array { + const result: Array = [] + fastForEach(snapTargets, (snapTarget) => { + const frame = MetadataUtils.getFrameInCanvasCoords(snapTarget, componentMetadata) + if (frame != null && isFiniteRectangle(frame)) { + result.push(...Guidelines.guidelinesWithRelevantPointsForFrame(frame, 'include')) } }) return result diff --git a/editor/src/components/canvas/controls/select-mode/move-utils.ts b/editor/src/components/canvas/controls/select-mode/move-utils.ts deleted file mode 100644 index bc9f4587dd51..000000000000 --- a/editor/src/components/canvas/controls/select-mode/move-utils.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { MetadataUtils } from '../../../../core/model/element-metadata-utils' -import type { ElementInstanceMetadataMap } from '../../../../core/shared/element-template' -import type { ElementPath } from '../../../../core/shared/project-file-types' -import Utils from '../../../../utils/utils' -import type { CanvasPoint, CanvasRectangle, CanvasVector } from '../../../../core/shared/math-utils' -import { isInfinityRectangle, roundPointToNearestWhole } from '../../../../core/shared/math-utils' -import type { EditorAction, EditorDispatch } from '../../../editor/action-types' -import * as EditorActions from '../../../editor/actions/action-creators' -import { setCanvasFrames } from '../../../editor/actions/action-creators' -import type { EditorState } from '../../../editor/store/editor-state' -import * as EP from '../../../../core/shared/element-path' -import type { CanvasFrameAndTarget, FlexMoveChange, PinMoveChange } from '../../canvas-types' -import { flexMoveChange, flexResizeChange, pinFrameChange, pinMoveChange } from '../../canvas-types' -import type { ConstrainedDragAxis, Guideline, GuidelineWithRelevantPoints } from '../../guideline' -import { Guidelines } from '../../guideline' -import { getSnapDelta } from '../guideline-helpers' -import { getNewIndex } from './yoga-utils' -import { mapDropNulls } from '../../../../core/shared/array-utils' -import type { ElementPathTrees } from '../../../../core/shared/element-path-tree' - -export function determineConstrainedDragAxis(dragDelta: CanvasVector): 'x' | 'y' { - if (Math.abs(dragDelta.x) > Math.abs(dragDelta.y)) { - return 'x' - } else { - return 'y' - } -} - -export function extendSelectedViewsForInteraction( - selectedViews: Array, - componentMetadata: ElementInstanceMetadataMap, -): Array { - return Utils.flatMapArray((view) => { - const frame = MetadataUtils.getFrameInCanvasCoords(view, componentMetadata) - if (frame == null) { - // What's the deal here? Why are we checking if the thing has a global frame but then not using it? - return [] - } else { - return [view] - } - }, selectedViews) -} - -export function determineElementsToOperateOnForDragging( - selectedViews: Array, - componentMetadata: ElementInstanceMetadataMap, - isMoving: boolean, - isAnchor: boolean, -): Array { - if (isMoving) { - // Moving. - return extendSelectedViewsForInteraction( - selectedViews.filter((view) => - selectedViews.every((otherView) => { - return EP.pathsEqual(view, otherView) && EP.isDescendantOfOrEqualTo(view, otherView) - }), - ), - componentMetadata, - ) - } else { - // Resizing. - return extendSelectedViewsForInteraction(selectedViews, componentMetadata) - } -} - -export function dragComponent( - componentsMetadata: ElementInstanceMetadataMap, - elementPathTree: ElementPathTrees, - selectedViews: Array, - originalFrames: Array, - moveGuidelines: Array, - dragSelectionBoundingBox: CanvasRectangle | null, - furthestDragDelta: CanvasVector | null, - dragDelta: CanvasVector, - enableSnapping: boolean, - constrainDragAxis: boolean, - scale: number, -): Array { - const roundedDragDelta = roundPointToNearestWhole(dragDelta) - // TODO: Probably makes more sense to pull this out. - const viewsToOperateOn = determineElementsToOperateOnForDragging( - selectedViews, - componentsMetadata, - true, - false, - ) - let dragChanges: Array = [] - Utils.fastForEach(viewsToOperateOn, (view) => { - const parentPath = EP.parentPath(view) - const isFlexContainer = - MetadataUtils.isParentYogaLayoutedContainerAndElementParticipatesInLayout( - view, - componentsMetadata, - ) - const originalFrame = originalFrames.find((frame) => EP.pathsEqual(frame.target, view)) - if (originalFrame == null) { - // found a target with no original frame - return - } - if (isFlexContainer) { - if (originalFrame.frame != null) { - const flexDirection = MetadataUtils.getFlexDirection( - MetadataUtils.getParent(componentsMetadata, view), - ) - const draggedFrame = Utils.offsetRect(originalFrame.frame, dragDelta) - const newIndex = getNewIndex( - componentsMetadata, - elementPathTree, - view, - parentPath, - flexDirection, - draggedFrame, - ) - if (newIndex != null) { - dragChanges.push(flexMoveChange(view, newIndex)) - } - } - } else { - // TODO determine if node graph affects the drag - - const moveGuidelinesWithNoPoints: Array = moveGuidelines.map( - (guideline) => ({ guideline, pointsOfRelevance: [] }), - ) - - const constrainedDragAxis: ConstrainedDragAxis | null = - constrainDragAxis && furthestDragDelta != null - ? determineConstrainedDragAxis(furthestDragDelta) - : null - const snapDelta = enableSnapping - ? getSnapDelta( - moveGuidelinesWithNoPoints, - constrainedDragAxis, - Utils.offsetRect( - Utils.defaultIfNull(Utils.zeroRectangle as CanvasRectangle, dragSelectionBoundingBox), - roundedDragDelta, - ), - scale, - ).delta - : (Utils.zeroPoint as CanvasPoint) - - const dragDeltaToApply = Guidelines.applyDirectionConstraint( - constrainedDragAxis, - Utils.offsetPoint(roundedDragDelta, snapDelta), - ) - if (originalFrame.frame != null) { - dragChanges.push(pinMoveChange(view, dragDeltaToApply)) - } - } - }) - return dragChanges -} - -export function dragComponentForActions( - componentsMetadata: ElementInstanceMetadataMap, - elementPathTree: ElementPathTrees, - selectedViews: Array, - originalFrames: Array, - moveGuidelines: Array, - dragSelectionBoundingBox: CanvasRectangle, - furthestDragDelta: CanvasVector | null, - dragDelta: CanvasVector, - enableSnapping: boolean, - constrainDragAxis: boolean, - scale: number, -): Array { - const frameAndTargets = dragComponent( - componentsMetadata, - elementPathTree, - selectedViews, - originalFrames, - moveGuidelines, - dragSelectionBoundingBox, - furthestDragDelta, - dragDelta, - enableSnapping, - constrainDragAxis, - scale, - ) - return [setCanvasFrames(frameAndTargets, false)] -} - -export function adjustAllSelectedFrames( - editor: EditorState, - dispatch: EditorDispatch, - keepChildrenAtPlace: boolean, - isResizing: boolean, - directionModifier: -1 | 1, - direction: 'vertical' | 'horizontal', - adjustment: 1 | 10, -): Array { - if ( - editor.selectedViews.length === 0 || - (editor.selectedViews.some((view) => { - return MetadataUtils.isParentYogaLayoutedContainerAndElementParticipatesInLayout( - view, - editor.jsxMetadata, - ) - }) && - !isResizing) - ) { - // if any of the selected views have a Yoga parent, we bail out - return [] - } - const selectedFrames = mapDropNulls((view) => { - const frame = MetadataUtils.getFrameInCanvasCoords(view, editor.jsxMetadata) - return frame == null || isInfinityRectangle(frame) ? null : frame - }, editor.selectedViews) - - const boundingBox = Utils.boundingRectangleArray(selectedFrames) - - if (boundingBox == null) { - // none of the selected elements are layoutable, return early - return [] - } - - let actions: Array = [] - if (isResizing) { - let roundedAdjustment: number = 0 - let newBoundingBox: CanvasRectangle - if (direction === 'vertical') { - roundedAdjustment = getRoundedAdjustment( - boundingBox.y + boundingBox.height, - adjustment, - directionModifier, - ) - newBoundingBox = Utils.combineRectangles(boundingBox, { - x: 0, - y: 0, - width: 0, - height: roundedAdjustment, - } as CanvasRectangle) - } else { - roundedAdjustment = getRoundedAdjustment( - boundingBox.x + boundingBox.width, - adjustment, - directionModifier, - ) - newBoundingBox = Utils.combineRectangles(boundingBox, { - x: 0, - y: 0, - width: roundedAdjustment, - height: 0, - } as CanvasRectangle) - } - const newFrameAndTargets = Utils.stripNulls( - editor.selectedViews.map((view) => { - const frame = MetadataUtils.getFrameInCanvasCoords(view, editor.jsxMetadata) - if (frame == null || isInfinityRectangle(frame)) { - return null - } else { - const newFrame = Utils.transformFrameUsingBoundingBox(newBoundingBox, boundingBox, frame) - const hasFlexParent = - MetadataUtils.isParentYogaLayoutedContainerAndElementParticipatesInLayout( - view, - editor.jsxMetadata, - ) - if (hasFlexParent) { - return flexResizeChange(view, 'flexBasis', adjustment * directionModifier) - } else { - return pinFrameChange(view, newFrame) - } - } - }), - ) - actions = [EditorActions.setCanvasFrames(newFrameAndTargets, keepChildrenAtPlace)] - } else { - const originalFrames: CanvasFrameAndTarget[] = Utils.stripNulls( - editor.selectedViews.map((view) => { - const frame = MetadataUtils.getFrameInCanvasCoords(view, editor.jsxMetadata) - if (frame == null || isInfinityRectangle(frame)) { - return null - } - return { - target: view, - frame: frame, - } - }), - ) - - const delta = getRoundedDeltaForKeyboardShortcut( - boundingBox, - direction, - directionModifier, - adjustment, - ) - EditorActions.hideAndShowSelectionControls(dispatch) - actions = dragComponentForActions( - editor.jsxMetadata, - editor.elementPathTree, - editor.selectedViews, - originalFrames, - [], - boundingBox, - delta, - delta, - false, - false, - editor.canvas.scale, - ) - } - - actions.push(EditorActions.startCheckpointTimer()) - return actions -} - -function getRoundedDeltaForKeyboardShortcut( - boundingBox: CanvasRectangle, - side: 'vertical' | 'horizontal', - directionModifier: -1 | 1, - adjustment: 1 | 10, -): CanvasPoint { - if (side === 'vertical') { - return { - x: 0, - y: getRoundedAdjustment(boundingBox.y, adjustment, directionModifier), - } as CanvasPoint - } else { - return { - x: getRoundedAdjustment(boundingBox.x, adjustment, directionModifier), - y: 0, - } as CanvasPoint - } -} - -function getRoundedAdjustment(n: number, adjustment: 10 | 1, directionModifier: -1 | 1): number { - let roundDelta = Utils.roundTo(n, 0) - n - - if (roundDelta === 0) { - // default case, no need to round - return adjustment * directionModifier - } else if (roundDelta * directionModifier < 0) { - // when the rounding and frameshift happens in opposite direction - return (adjustment - Math.abs(roundDelta)) * directionModifier - } else { - return (adjustment + Math.abs(roundDelta)) * directionModifier - } -}