Skip to content

Commit

Permalink
Feat/animate grid (#6046)
Browse files Browse the repository at this point in the history
**Problem:**

When rearranging grid elements, they should animate along the X/Y axii
to indicate the completed movement.
Moreover, we don't have a way to animate rendered canvas elements.

**Fix:**

This PR introduces a way to animate canvas elements programmatically,
without having to inject anything inside the rendered components
themselves. After doing that, the PR uses the new logic to animate the
grid rearrange as a first test subject.

The goal is to be able to arbitrarily animate canvas elements using the
same framer motion API we use elsewhere, explicitly. In order to avoid
manipulating the rendered elements, the targeting capabilities of
`useAnimate` are used here.

1. Create the animation scope and animation function with `useAnimate` 
2. Connect the scope with the `DesignPanelRoot` component
3. Store the animation function in a new `AnimationContext` context
4. `useCanvasAnimation` can now be used to target specific canvas
elements and animate them, selecting them from the DOM by their
`data-uid` prop
5. 🎈 



https://github.com/concrete-utopia/utopia/assets/1081051/ab70b016-a91b-47f8-95ec-bec00bdca838
  • Loading branch information
ruggi authored and liady committed Dec 13, 2024
1 parent aa4bb88 commit 353cf7b
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 35 deletions.
2 changes: 1 addition & 1 deletion editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@
"eslint4b": "6.6.0",
"fast-deep-equal": "2.0.1",
"fontfaceobserver": "2.1.0",
"framer-motion": "10.16.5",
"framer-motion": "11.2.13",
"friendly-words": "1.1.10",
"glob-to-regexp": "0.4.1",
"graphlib": "2.1.1",
Expand Down
21 changes: 7 additions & 14 deletions editor/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

116 changes: 99 additions & 17 deletions editor/src/components/canvas/controls/grid-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ import {
isGridAutoOrTemplateDimensions,
type GridAutoOrTemplateBase,
} from '../../../core/shared/element-template'
import type { CanvasPoint, CanvasRectangle, CanvasVector } from '../../../core/shared/math-utils'
import type { CanvasPoint, CanvasRectangle } from '../../../core/shared/math-utils'
import {
canvasPoint,
distance,
getRectCenter,
isFiniteRectangle,
offsetPoint,
pointDifference,
pointsEqual,
windowPoint,
} from '../../../core/shared/math-utils'
import {
Expand Down Expand Up @@ -47,8 +48,11 @@ import { windowToCanvasCoordinates } from '../dom-lookup'
import { CanvasOffsetWrapper } from './canvas-offset-wrapper'
import { useColorTheme } from '../../../uuiui'
import { gridCellTargetId } from '../canvas-strategies/strategies/grid-helpers'
import type { EditorDispatch } from '../../../components/editor/action-types'
import { useCanvasAnimation } from '../ui-jsx-canvas-renderer/animation-context'
import { CanvasLabel } from './select-mode/controls-common'
import { optionalMap } from '../../../core/shared/optional-utils'

const CELL_ANIMATION_DURATION = 0.15 // seconds

export const GridCellTestId = (elementPath: ElementPath) => `grid-cell-${EP.toString(elementPath)}`

Expand All @@ -58,10 +62,6 @@ export function gridCellCoordinates(row: number, column: number): GridCellCoordi
return { row: row, column: column }
}

export function gridCellCoordinatesToString(coords: GridCellCoordinates): string {
return `${coords.row}:${coords.column}`
}

function getCellsCount(template: GridAutoOrTemplateBase | null): number {
if (template == null) {
return 0
Expand Down Expand Up @@ -273,11 +273,6 @@ export const GridControls = controlForStrategyMemoized(() => {
(store) => store.strategyState.customStrategyState.grid.currentRootCell,
'GridControls targetRootCell',
)
const targetRootCellId = React.useMemo(
() => (targetRootCell == null ? null : gridCellCoordinatesToString(targetRootCell)),
[targetRootCell],
)
useSnapAnimation(targetRootCellId, controls)

const dragging = useEditorState(
Substores.canvas,
Expand Down Expand Up @@ -412,13 +407,22 @@ export const GridControls = controlForStrategyMemoized(() => {
}, [grids, jsxMetadata])

const shadow = React.useMemo(() => {
return cells.find((cell) => EP.toUid(cell.elementPath) === dragging)
return cells.find((cell) => EP.toUid(cell.elementPath) === dragging) ?? null
}, [cells, dragging])

const [initialShadowFrame, setInitialShadowFrame] = React.useState<CanvasRectangle | null>(
shadow?.globalFrame ?? null,
)

const gridPath = optionalMap(EP.parentPath, shadow?.elementPath)

useSnapAnimation({
targetRootCell: targetRootCell,
controls: controls,
shadowFrame: initialShadowFrame,
gridPath: gridPath,
})

const startInteractionWithUid = React.useCallback(
(params: { uid: string; row: number; column: number; frame: CanvasRectangle }) =>
(event: React.MouseEvent) => {
Expand Down Expand Up @@ -741,14 +745,92 @@ export const GridControls = controlForStrategyMemoized(() => {
)
})

function useSnapAnimation(targetRootCellId: string | null, controls: AnimationControls) {
function useSnapAnimation(params: {
gridPath: ElementPath | null
shadowFrame: CanvasRectangle | null
targetRootCell: GridCellCoordinates | null
controls: AnimationControls
}) {
const { gridPath, targetRootCell, controls, shadowFrame } = params
const features = useRollYourOwnFeatures()

const [lastTargetRootCellId, setLastTargetRootCellId] = React.useState(targetRootCell)
const [lastSnapPoint, setLastSnapPoint] = React.useState<CanvasPoint | null>(shadowFrame)

const selectedViews = useEditorState(
Substores.selectedViews,
(store) => store.editor.selectedViews,
'useSnapAnimation selectedViews',
)

const animate = useCanvasAnimation(selectedViews)

const canvasScale = useEditorState(
Substores.canvasOffset,
(store) => store.editor.canvas.scale,
'useSnapAnimation canvasScale',
)

const canvasOffset = useEditorState(
Substores.canvasOffset,
(store) => store.editor.canvas.roundedCanvasOffset,
'useSnapAnimation canvasOffset',
)

const moveFromPoint = React.useMemo(() => {
return lastSnapPoint ?? shadowFrame
}, [lastSnapPoint, shadowFrame])

const snapPoint = React.useMemo(() => {
if (gridPath == null || targetRootCell == null) {
return null
}

const element = document.getElementById(
gridCellTargetId(gridPath, targetRootCell.row, targetRootCell.column),
)
if (element == null) {
return null
}

const rect = element.getBoundingClientRect()
const point = windowPoint({ x: rect.x, y: rect.y })

return windowToCanvasCoordinates(canvasScale, canvasOffset, point).canvasPositionRounded
}, [canvasScale, canvasOffset, gridPath, targetRootCell])

React.useEffect(() => {
if (!features.Grid.animateSnap || targetRootCellId == null) {
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 },
)

if (features.Grid.animateShadowSnap) {
void controls.start(SHADOW_SNAP_ANIMATION)
}
}
}
void controls.start(SHADOW_SNAP_ANIMATION)
}, [targetRootCellId, controls, features.Grid.animateSnap])
setLastSnapPoint(snapPoint)
setLastTargetRootCellId(targetRootCell)
}, [
targetRootCell,
controls,
features.Grid.animateShadowSnap,
lastSnapPoint,
snapPoint,
animate,
moveFromPoint,
lastTargetRootCellId,
])
}

function useMouseMove(activelyDraggingOrResizingCell: string | null) {
Expand Down
3 changes: 3 additions & 0 deletions editor/src/components/canvas/design-panel-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { useCanComment } from '../../core/commenting/comment-hooks'
import { ElementsOutsideVisibleAreaIndicator } from '../editor/elements-outside-visible-area-indicator'
import { isFeatureEnabled } from '../../utils/feature-switches'
import { RollYourOwnFeaturesPane } from '../navigator/left-pane/roll-your-own-pane'
import { AnimationContext } from './ui-jsx-canvas-renderer/animation-context'

function isCodeEditorEnabled(): boolean {
if (typeof window !== 'undefined') {
Expand Down Expand Up @@ -95,9 +96,11 @@ const DesignPanelRootInner = React.memo(() => {
})

export const DesignPanelRoot = React.memo(() => {
const { scope: animationScope } = React.useContext(AnimationContext)
return (
<>
<SimpleFlexRow
ref={animationScope}
className='OpenFileEditorShell'
style={{
position: 'relative',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type {
ElementOrSelector,
DOMKeyframesDefinition,
DynamicAnimationOptions,
AnimationPlaybackControls,
AnimationScope,
} from 'framer-motion'
import React, { useContext } from 'react'
import type { ElementPath } from 'utopia-shared/src/types'
import { MetadataUtils } from '../../../core/model/element-metadata-utils'
import { getUtopiaID } from '../../../core/shared/uid-utils'
import { Substores, useEditorState } from '../../editor/store/store-hook'
import { mapDropNulls } from '../../../core/shared/array-utils'

export type AnimationCtx = {
scope: AnimationScope | null
animate:
| ((
value: ElementOrSelector,
keyframes: DOMKeyframesDefinition,
options?: DynamicAnimationOptions | undefined,
) => AnimationPlaybackControls)
| null
}

export const AnimationContext = React.createContext<AnimationCtx>({
scope: null,
animate: null,
})

export function useCanvasAnimation(paths: ElementPath[]) {
const ctx = useContext(AnimationContext)

const uids = useEditorState(
Substores.metadata,
(store) => {
return mapDropNulls((path) => {
const element = MetadataUtils.findElementByElementPath(store.editor.jsxMetadata, path)
if (element == null) {
return null
}
return getUtopiaID(element)
}, paths)
},
'useCanvasAnimation uids',
)

const selector = React.useMemo(() => {
return uids.map((uid) => `[data-uid='${uid}']`).join(',')
}, [uids])

const elements = React.useMemo(
() => (selector === '' ? [] : document.querySelectorAll(selector)),
[selector],
)

return React.useCallback(
(keyframes: DOMKeyframesDefinition, options?: DynamicAnimationOptions) => {
if (ctx.animate == null || elements.length === 0) {
return
}
void ctx.animate(elements, keyframes, options)
},
[ctx, elements],
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type GridFeatures = {
dragVerbatim: boolean
dragMagnetic: boolean
dragRatio: boolean
animateSnap: boolean
animateShadowSnap: boolean
dotgrid: boolean
shadow: boolean
adaptiveOpacity: boolean
Expand All @@ -38,7 +38,7 @@ const defaultRollYourOwnFeatures: RollYourOwnFeatures = {
dragVerbatim: false,
dragMagnetic: false,
dragRatio: true,
animateSnap: true,
animateShadowSnap: false,
dotgrid: true,
shadow: true,
adaptiveOpacity: true,
Expand Down
10 changes: 9 additions & 1 deletion editor/src/templates/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ import {
} from '../components/github/github-repository-clone-flow'
import { hasReactRouterErrorBeenLogged } from '../core/shared/runtime-report-logs'
import { InitialOnlineState, startOnlineStatusPolling } from '../components/editor/online-status'
import { useAnimate } from 'framer-motion'
import { AnimationContext } from '../components/canvas/ui-jsx-canvas-renderer/animation-context'

if (PROBABLY_ELECTRON) {
let { webFrame } = requireElectron()
Expand Down Expand Up @@ -691,6 +693,8 @@ export const EditorRoot: React.FunctionComponent<{
spyCollector,
domWalkerMutableState,
}) => {
const [animationScope, animate] = useAnimate()

return (
<AtomsDevtools>
<JotaiProvider>
Expand All @@ -701,7 +705,11 @@ export const EditorRoot: React.FunctionComponent<{
<CanvasStateContext.Provider value={canvasStore}>
<LowPriorityStateContext.Provider value={lowPriorityStore}>
<UiJsxCanvasCtxAtom.Provider value={spyCollector}>
<EditorComponent />
<AnimationContext.Provider
value={{ animate: animate, scope: animationScope }}
>
<EditorComponent />
</AnimationContext.Provider>
</UiJsxCanvasCtxAtom.Provider>
</LowPriorityStateContext.Provider>
</CanvasStateContext.Provider>
Expand Down

0 comments on commit 353cf7b

Please sign in to comment.