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

119 changes: 102 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,9 +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'

const CELL_ANIMATION_DURATION = 0.15 // seconds

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

export type GridCellCoordinates = { row: number; column: number }
Expand All @@ -58,10 +61,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 +272,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 +406,27 @@ 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 = React.useMemo(() => {
if (shadow == null) {
return null
}
return EP.parentPath(shadow.elementPath)
}, [shadow])

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 +749,91 @@ 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: targetRootCellId, controls, shadowFrame } = params
const features = useRollYourOwnFeatures()

const [lastTargetRootCellId, setLastTargetRootCellId] = React.useState(targetRootCellId)
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 || targetRootCellId == null) {
return null
}

const element = document.getElementById(
gridCellTargetId(gridPath, targetRootCellId.row, targetRootCellId.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, targetRootCellId])

React.useEffect(() => {
if (!features.Grid.animateSnap || targetRootCellId == null) {
return
if (targetRootCellId != 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(targetRootCellId)
}, [
targetRootCellId,
controls,
features.Grid.animateShadowSnap,
lastSnapPoint,
snapPoint,
animate,
moveFromPoint,
lastTargetRootCellId,
])
}

function useMouseMove(activelyDraggingOrResizingCell: string | null) {
Expand Down
4 changes: 3 additions & 1 deletion 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 type { AnimationScope } from 'framer-motion'

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

export const DesignPanelRoot = React.memo(() => {
export const DesignPanelRoot = React.memo((props: { animationScope: AnimationScope<any> }) => {
return (
<>
<SimpleFlexRow
ref={props.animationScope}
className='OpenFileEditorShell'
style={{
position: 'relative',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type {
ElementOrSelector,
DOMKeyframesDefinition,
DynamicAnimationOptions,
AnimationPlaybackControls,
} 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 = {
animate:
| ((
value: ElementOrSelector,
keyframes: DOMKeyframesDefinition,
options?: DynamicAnimationOptions | undefined,
) => AnimationPlaybackControls)
| null
}

export const AnimationContext = React.createContext<AnimationCtx>({
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
@@ -1,12 +1,11 @@
import type React from 'react'
import { emptySet } from '../../../core/shared/set-utils'
import type { MapLike } from 'typescript'
import { atomWithPubSub } from '../../../core/shared/atom-with-pub-sub'
import type { Either } from '../../../core/shared/either'
import { left } from '../../../core/shared/either'
import type { ElementPath } from '../../../core/shared/project-file-types'
import type { ProjectContentTreeRoot } from '../../assets'
import type { TransientFilesState, UIFileBase64Blobs } from '../../editor/store/editor-state'
import type { UIFileBase64Blobs } from '../../editor/store/editor-state'
import type { VariableData } from '../ui-jsx-canvas'
import type { FilePathMappings } from '../../../core/model/project-file-utils'

Expand Down
7 changes: 5 additions & 2 deletions editor/src/components/editor/editor-component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
import { useGithubPolling } from '../../core/shared/github/helpers'
import { useAtom } from 'jotai'
import { clearOpenMenuIds } from '../../core/shared/menu-state'
import type { AnimationScope } from 'framer-motion'

const liveModeToastId = 'play-mode-toast'

Expand Down Expand Up @@ -143,7 +144,9 @@ function githubOperationPrettyNameForOverlay(op: GithubOperation): string {
}
}

export interface EditorProps {}
export interface EditorProps {
animationScope: AnimationScope<any>
}

export const EditorComponentInner = React.memo((props: EditorProps) => {
const room = useRoom()
Expand Down Expand Up @@ -516,7 +519,7 @@ export const EditorComponentInner = React.memo((props: EditorProps) => {
overflowX: 'hidden',
}}
>
<DesignPanelRoot />
<DesignPanelRoot animationScope={props.animationScope} />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the prop drilling here? couldn't / shouldn't this be in a context? is props.animationScope referentially stable?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about this b60e41e ?

</SimpleFlexRow>
{/* insert more columns here */}

Expand Down
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
Loading
Loading