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
- }
-}