Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/animate grid #6046

Merged
merged 15 commits into from
Jul 9, 2024
Merged
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}`
ruggi marked this conversation as resolved.
Show resolved Hide resolved
}

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,
ruggi marked this conversation as resolved.
Show resolved Hide resolved
})

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
Loading