From 353cf7bdc0be6ef03e5acb03a55527dc381da1f8 Mon Sep 17 00:00:00 2001 From: Federico Ruggi <1081051+ruggi@users.noreply.github.com> Date: Tue, 9 Jul 2024 16:46:36 +0200 Subject: [PATCH] Feat/animate grid (#6046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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 --- editor/package.json | 2 +- editor/pnpm-lock.yaml | 21 ++-- .../canvas/controls/grid-controls.tsx | 116 +++++++++++++++--- .../components/canvas/design-panel-root.tsx | 3 + .../animation-context.ts | 66 ++++++++++ .../left-pane/roll-your-own-pane.tsx | 4 +- editor/src/templates/editor.tsx | 10 +- 7 files changed, 187 insertions(+), 35 deletions(-) create mode 100644 editor/src/components/canvas/ui-jsx-canvas-renderer/animation-context.ts diff --git a/editor/package.json b/editor/package.json index f45e99062113..ec325fd660d3 100644 --- a/editor/package.json +++ b/editor/package.json @@ -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", diff --git a/editor/pnpm-lock.yaml b/editor/pnpm-lock.yaml index 040204595233..ea4ad40d82a0 100644 --- a/editor/pnpm-lock.yaml +++ b/editor/pnpm-lock.yaml @@ -194,7 +194,7 @@ specifiers: fontfaceobserver: 2.1.0 fork-ts-checker-async-overlay-webpack-plugin: 0.4.0 fork-ts-checker-webpack-plugin: 8.0.0 - framer-motion: 10.16.5 + framer-motion: 11.2.13 friendly-words: 1.1.10 fse: 1.0.1 fsevents: 2.1.2 @@ -406,7 +406,7 @@ dependencies: eslint4b: 6.6.0 fast-deep-equal: 2.0.1 fontfaceobserver: 2.1.0 - framer-motion: 10.16.5_ef5jwxihqo6n7gxfmzogljlgcm + framer-motion: 11.2.13_ef5jwxihqo6n7gxfmzogljlgcm friendly-words: 1.1.10 glob-to-regexp: 0.4.1 graphlib: 2.1.1 @@ -2259,14 +2259,6 @@ packages: resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} dev: false - /@emotion/is-prop-valid/0.8.8: - resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==} - requiresBuild: true - dependencies: - '@emotion/memoize': 0.7.4 - dev: false - optional: true - /@emotion/is-prop-valid/1.1.0: resolution: {integrity: sha512-9RkilvXAufQHsSsjQ3PIzSns+pxuX4EW8EbGeSPjZMHuMx6z/MOzb9LpqNieQX4F3mre3NWS2+X3JNRHTQztUQ==} dependencies: @@ -10257,12 +10249,15 @@ packages: map-cache: 0.2.2 dev: true - /framer-motion/10.16.5_ef5jwxihqo6n7gxfmzogljlgcm: - resolution: {integrity: sha512-GEzVjOYP2MIpV9bT/GbhcsBNoImG3/2X3O/xVNWmktkv9MdJ7P/44zELm/7Fjb+O3v39SmKFnoDQB32giThzpg==} + /framer-motion/11.2.13_ef5jwxihqo6n7gxfmzogljlgcm: + resolution: {integrity: sha512-AyIeegfkXlkX1lWEudRYsJlC+0A59cE8oFK9IsN9bUQzxLwcvN3AEaYaznkELiWlHC7a0eD7pxsYQo7BC05S5A==} peerDependencies: + '@emotion/is-prop-valid': '*' react: ^18.0.0 react-dom: ^18.0.0 peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true react: optional: true react-dom: @@ -10271,8 +10266,6 @@ packages: react: 18.1.0_47cciibm4ysmleigs33s763fqu react-dom: 18.1.0_abari7w75zfr6mrhvamxwmfpxm_react@18.1.0 tslib: 2.6.2 - optionalDependencies: - '@emotion/is-prop-valid': 0.8.8 dev: false /fresh/0.5.2: diff --git a/editor/src/components/canvas/controls/grid-controls.tsx b/editor/src/components/canvas/controls/grid-controls.tsx index 39f2e057f812..83d2164ff922 100644 --- a/editor/src/components/canvas/controls/grid-controls.tsx +++ b/editor/src/components/canvas/controls/grid-controls.tsx @@ -12,7 +12,7 @@ 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, @@ -20,6 +20,7 @@ import { isFiniteRectangle, offsetPoint, pointDifference, + pointsEqual, windowPoint, } from '../../../core/shared/math-utils' import { @@ -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)}` @@ -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 @@ -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, @@ -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( 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) => { @@ -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(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) { diff --git a/editor/src/components/canvas/design-panel-root.tsx b/editor/src/components/canvas/design-panel-root.tsx index a698ebcb451e..cfd85994fbef 100644 --- a/editor/src/components/canvas/design-panel-root.tsx +++ b/editor/src/components/canvas/design-panel-root.tsx @@ -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') { @@ -95,9 +96,11 @@ const DesignPanelRootInner = React.memo(() => { }) export const DesignPanelRoot = React.memo(() => { + const { scope: animationScope } = React.useContext(AnimationContext) return ( <> AnimationPlaybackControls) + | null +} + +export const AnimationContext = React.createContext({ + 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], + ) +} diff --git a/editor/src/components/navigator/left-pane/roll-your-own-pane.tsx b/editor/src/components/navigator/left-pane/roll-your-own-pane.tsx index 246bfba014ee..8183caace18c 100644 --- a/editor/src/components/navigator/left-pane/roll-your-own-pane.tsx +++ b/editor/src/components/navigator/left-pane/roll-your-own-pane.tsx @@ -14,7 +14,7 @@ type GridFeatures = { dragVerbatim: boolean dragMagnetic: boolean dragRatio: boolean - animateSnap: boolean + animateShadowSnap: boolean dotgrid: boolean shadow: boolean adaptiveOpacity: boolean @@ -38,7 +38,7 @@ const defaultRollYourOwnFeatures: RollYourOwnFeatures = { dragVerbatim: false, dragMagnetic: false, dragRatio: true, - animateSnap: true, + animateShadowSnap: false, dotgrid: true, shadow: true, adaptiveOpacity: true, diff --git a/editor/src/templates/editor.tsx b/editor/src/templates/editor.tsx index 6a0b494bf48b..d64b56adfeea 100644 --- a/editor/src/templates/editor.tsx +++ b/editor/src/templates/editor.tsx @@ -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() @@ -691,6 +693,8 @@ export const EditorRoot: React.FunctionComponent<{ spyCollector, domWalkerMutableState, }) => { + const [animationScope, animate] = useAnimate() + return ( @@ -701,7 +705,11 @@ export const EditorRoot: React.FunctionComponent<{ - + + +