From e3098c4400b19647263b243b42355ce97fe5745c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 31 May 2023 15:16:27 +0200 Subject: [PATCH] ui improvements add timeline animations --- app/package.json | 2 + app/src/App.tsx | 4 +- app/src/components/Canvas/index.tsx | 23 ++-- .../components/Timeline/KeyframeIndicator.tsx | 130 +++++++++++++++--- app/src/components/Timeline/Track.tsx | 20 +-- .../Timeline/TrackPropertiesEditor.tsx | 77 ++++++++--- app/src/components/Timeline/index.tsx | 6 +- app/src/components/ToggleGroup.tsx | 12 +- app/src/example.ts | 3 +- app/src/hooks/useKeyControls.ts | 20 ++- app/src/primitives/AnimatedEntities.ts | 4 +- app/src/utils/index.ts | 33 +++++ app/yarn.lock | 17 +++ 13 files changed, 278 insertions(+), 73 deletions(-) diff --git a/app/package.json b/app/package.json index ff55051..45e4bf3 100644 --- a/app/package.json +++ b/app/package.json @@ -19,10 +19,12 @@ "@radix-ui/react-toolbar": "^1.0.3", "@tauri-apps/api": "^1.3.0", "@tempblade/common": "^2.0.1", + "@types/lodash.set": "^4.3.7", "@unom/style": "^0.2.14", "canvaskit-wasm": "^0.38.1", "framer-motion": "^10.12.12", "immer": "^10.0.2", + "lodash.set": "^4.3.2", "react": "^18.2.0", "react-dom": "^18.2.0", "uuid": "^9.0.0", diff --git a/app/src/App.tsx b/app/src/App.tsx index 7a4ddb8..c26161c 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -27,8 +27,8 @@ export default function App() {
-
-
+
+
diff --git a/app/src/components/Canvas/index.tsx b/app/src/components/Canvas/index.tsx index 15fc0ab..6996d5d 100644 --- a/app/src/components/Canvas/index.tsx +++ b/app/src/components/Canvas/index.tsx @@ -26,18 +26,17 @@ const CanvasComponent: FC = () => { }, []); return ( -
-
- -
+
+
); }; diff --git a/app/src/components/Timeline/KeyframeIndicator.tsx b/app/src/components/Timeline/KeyframeIndicator.tsx index b0f5a8e..a558385 100644 --- a/app/src/components/Timeline/KeyframeIndicator.tsx +++ b/app/src/components/Timeline/KeyframeIndicator.tsx @@ -1,51 +1,111 @@ import { ease } from "@unom/style"; -import { motion } from "framer-motion"; +import { PanInfo, motion } from "framer-motion"; import { AnimationData } from "primitives/AnimatedEntities"; import { Keyframe } from "primitives/Keyframe"; -import { FC } from "react"; +import { FC, useCallback, useState } from "react"; import { z } from "zod"; -import { TIMELINE_SCALE } from "./common"; +import { TIMELINE_SCALE, calculateOffset } from "./common"; import { AnimatedNumber, AnimatedVec2, AnimatedVec3 } from "primitives/Values"; import { useKeyframeStore } from "stores/keyframe.store"; +import { produce } from "immer"; const KeyframeIndicator: FC<{ keyframe: z.input; animationData: z.input; -}> = ({ keyframe, animationData }) => { + onUpdate?: (e: z.input) => void; +}> = ({ keyframe, animationData, onUpdate }) => { const { selectedKeyframe, selectKeyframe, deselectKeyframe } = useKeyframeStore(); const selected = selectedKeyframe === keyframe.id; + const handleUpdate = useCallback( + (info: PanInfo) => { + if (onUpdate) { + let offset = info.offset.x; + + offset = calculateOffset(offset); + + offset += keyframe.offset; + + onUpdate({ ...keyframe, offset: offset < 0 ? 0 : offset }); + } + }, + [onUpdate, animationData, keyframe] + ); + + const [isDragged, setIsDragged] = useState(false); + return ( e.preventDefault()} + variants={{ + enter: {}, + from: {}, + exit: {}, + tap: {}, + drag: {}, + }} data-selected={selected} + onDragStart={() => setIsDragged(true)} + onDragEnd={(e, info) => { + e.preventDefault(); + setIsDragged(false); + if (onUpdate) { + handleUpdate(info); + } + }} + onMouseDown={(e) => e.preventDefault()} dragConstraints={{ left: 0 }} + initial={{ + x: (animationData.offset + keyframe.offset) * TIMELINE_SCALE + 4, + scale: 0, + }} + whileTap={{ scale: 1.1 }} animate={{ x: (animationData.offset + keyframe.offset) * TIMELINE_SCALE + 4, + scale: 1, }} transition={ease.quint(0.4).out} - style={{ - clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)", + onClick={() => { + if (!isDragged) { + selected ? deselectKeyframe() : selectKeyframe(keyframe.id); + } }} - onClick={() => - selected ? deselectKeyframe() : selectKeyframe(keyframe.id) - } - className="bg-indigo-500 data-[selected=true]:bg-indigo-300 absolute w-2 h-2 z-30 select-none" - /> + className="h-full absolute z-30 select-none w-3 flex items-center justify-center filter + data-[selected=true]:drop-shadow-[0px_2px_6px_rgba(255,255,255,1)] transition-colors" + > + + ); }; const AnimatedNumberKeyframeIndicator: FC<{ animatedNumber: z.input; animationData: z.input; -}> = ({ animatedNumber, animationData }) => { + onUpdate: (e: z.input) => void; +}> = ({ animatedNumber, animationData, onUpdate }) => { return ( <> - {animatedNumber.keyframes.values.map((keyframe) => ( + {animatedNumber.keyframes.values.map((keyframe, index) => ( + onUpdate( + produce(animatedNumber, (draft) => { + draft.keyframes.values[index] = keyframe; + }) + ) + } key={keyframe.id} keyframe={keyframe} animationData={animationData} @@ -65,11 +125,29 @@ const AnimatedVec2KeyframeIndicator: FC<{ animatedVec2: z.input; dimension?: DimensionsVec2; animationData: z.input; -}> = ({ animatedVec2, animationData, dimension }) => { + onUpdate: (e: z.input) => void; +}> = ({ animatedVec2, animationData, dimension, onUpdate }) => { + const handleUpdate = useCallback( + ( + animatedNumber: z.input, + dimensionIndex: number + ) => { + onUpdate( + produce(animatedVec2, (draft) => { + draft.keyframes[dimensionIndex] = animatedNumber; + }) + ); + }, + [animatedVec2] + ); + if (dimension) { return ( + handleUpdate(animatedNumber, VEC2_DIMENSION_INDEX_MAPPING[dimension]) + } animatedNumber={ animatedVec2.keyframes[VEC2_DIMENSION_INDEX_MAPPING[dimension]] } @@ -81,6 +159,7 @@ const AnimatedVec2KeyframeIndicator: FC<{ <> {animatedVec2.keyframes.map((animatedNumber, index) => ( handleUpdate(animatedNumber, index)} key={index} animatedNumber={animatedNumber} animationData={animationData} @@ -101,11 +180,29 @@ const AnimatedVec3KeyframeIndicator: FC<{ animatedVec3: z.input; animationData: z.input; dimension?: DimensionsVec3; -}> = ({ animatedVec3, animationData, dimension }) => { + onUpdate: (e: z.input) => void; +}> = ({ animatedVec3, animationData, dimension, onUpdate }) => { + const handleUpdate = useCallback( + ( + animatedNumber: z.input, + dimensionIndex: number + ) => { + onUpdate( + produce(animatedVec3, (draft) => { + draft.keyframes[dimensionIndex] = animatedNumber; + }) + ); + }, + [animatedVec3] + ); + if (dimension) { return ( + handleUpdate(animatedNumber, VEC3_DIMENSION_INDEX_MAPPING[dimension]) + } animatedNumber={ animatedVec3.keyframes[VEC3_DIMENSION_INDEX_MAPPING[dimension]] } @@ -118,6 +215,7 @@ const AnimatedVec3KeyframeIndicator: FC<{ {animatedVec3.keyframes.map((animatedNumber, index) => ( handleUpdate(animatedNumber, index)} animatedNumber={animatedNumber} animationData={animationData} /> diff --git a/app/src/components/Timeline/Track.tsx b/app/src/components/Timeline/Track.tsx index ff88080..1c5eb19 100644 --- a/app/src/components/Timeline/Track.tsx +++ b/app/src/components/Timeline/Track.tsx @@ -6,10 +6,7 @@ import { FC, useState } from "react"; import { useEntitiesStore } from "stores/entities.store"; import { z } from "zod"; import { shallow } from "zustand/shallow"; -import KeyframeIndicator, { - AnimatedVec2KeyframeIndicator, - AnimatedVec3KeyframeIndicator, -} from "./KeyframeIndicator"; +import KeyframeIndicator from "./KeyframeIndicator"; import { TIMELINE_SCALE, calculateOffset } from "./common"; import { TriangleDownIcon } from "@radix-ui/react-icons"; import TrackPropertiesEditor from "./TrackPropertiesEditor"; @@ -49,9 +46,14 @@ const Track: FC = ({ value={entity} dragListener={false} dragControls={controls} + onMouseDown={(e) => e.preventDefault()} className="min-h-8 relative flex flex-1 flex-col gap-1 select-none" > -
+ e.preventDefault()} + className="flex flex-row gap-1 select-none" + >
e.preventDefault()} onPointerDown={(e) => controls.start(e)} @@ -132,10 +134,10 @@ const Track: FC = ({ }, }); }} - className="z-10 w-4 bg-slate-500 h-8 absolute rounded-md select-none cursor-w-resize" + className="z-10 w-4 bg-slate-500 h-8 top-1 absolute rounded-md select-none cursor-w-resize" /> e.preventDefault()} drag="x" animate={{ @@ -194,10 +196,10 @@ const Track: FC = ({ }, }); }} - className="z-5 h-8 absolute rounded-md transition-colors bg-gray-700 hover:bg-gray-600 select-none cursor-grab" + className="z-5 h-8 top-1 absolute rounded-md transition-colors bg-gray-700 hover:bg-gray-600 select-none cursor-grab" >
-
+ {isExpanded && } ); diff --git a/app/src/components/Timeline/TrackPropertiesEditor.tsx b/app/src/components/Timeline/TrackPropertiesEditor.tsx index 7811bad..fb3eb20 100644 --- a/app/src/components/Timeline/TrackPropertiesEditor.tsx +++ b/app/src/components/Timeline/TrackPropertiesEditor.tsx @@ -5,7 +5,7 @@ import { } from "primitives/AnimatedEntities"; import { AnimatedProperty } from "primitives/AnimatedProperty"; import { AnimatedVec2, ValueType } from "primitives/Values"; -import { FC, useMemo, useState } from "react"; +import { FC, useCallback, useMemo, useState } from "react"; import { z } from "zod"; import { AnimatedNumberKeyframeIndicator, @@ -13,18 +13,34 @@ import { AnimatedVec3KeyframeIndicator, } from "./KeyframeIndicator"; import { ToggleGroup, ToggleGroupItem } from "components/ToggleGroup"; +import { produce } from "immer"; +import set from "lodash.set"; +import { useEntitiesStore } from "stores/entities.store"; +import { shallow } from "zustand/shallow"; +import { AnimatedValue } from "primitives/Values"; +import { motion } from "framer-motion"; +import { ease } from "@unom/style"; const TrackAnimatedPropertyKeyframes: FC<{ animatedProperty: z.input; animationData: z.input; + onUpdate: (animatedProperty: z.input) => void; selectedDimension?: "x" | "y" | "z"; -}> = ({ animatedProperty, animationData, selectedDimension }) => { +}> = ({ animatedProperty, animationData, selectedDimension, onUpdate }) => { + const handleUpdate = useCallback( + (animatedValue: z.input) => { + onUpdate({ ...animatedProperty, animatedValue }); + }, + [onUpdate, animatedProperty] + ); + switch (animatedProperty.animatedValue.type) { case "Number": return ( ); case "Vec2": @@ -33,6 +49,7 @@ const TrackAnimatedPropertyKeyframes: FC<{ dimension={selectedDimension !== "z" ? selectedDimension : undefined} animatedVec2={animatedProperty.animatedValue} animationData={animationData} + onUpdate={handleUpdate} /> ); @@ -42,6 +59,7 @@ const TrackAnimatedPropertyKeyframes: FC<{ dimension={selectedDimension} animatedVec3={animatedProperty.animatedValue} animationData={animationData} + onUpdate={handleUpdate} /> ); default: @@ -52,14 +70,18 @@ const TrackAnimatedPropertyKeyframes: FC<{ const TrackAnimatedProperty: FC<{ animatedProperty: z.input; animationData: z.input; - trackIndex: number; -}> = ({ animatedProperty, animationData }) => { + onUpdate: (e: z.input) => void; +}> = ({ animatedProperty, animationData, onUpdate }) => { const [selectedDimension, setSelectedDimension] = useState<"x" | "y" | "z">(); return ( -
-
-

{animatedProperty.label}

+ +
+

{animatedProperty.label}

setSelectedDimension("x")} @@ -90,36 +112,59 @@ const TrackAnimatedProperty: FC<{ ? selectedDimension : undefined } + onUpdate={onUpdate} animatedProperty={animatedProperty} animationData={animationData} />
-
+ ); }; -const TrackPropertiesEditor: FC<{ entity: z.input }> = ({ - entity, -}) => { +const TrackPropertiesEditor: FC<{ + entity: z.input; +}> = ({ entity }) => { const animatedProperties = useMemo( () => getAnimatedPropertiesByAnimatedEntity(entity), [entity] ); + const handleUpdate = useCallback( + (animatedProperty: z.input) => { + const entitiesStore = useEntitiesStore.getState(); + + const nextValue = produce(entity, (draft) => { + const animatedValue = animatedProperty.animatedValue; + + set(draft, animatedProperty.propertyPath, animatedValue); + }); + + const parsedEntity = AnimatedEntity.parse(nextValue); + + entitiesStore.updateEntityById(parsedEntity.id, parsedEntity); + }, + [entity] + ); + return ( -
+ {animatedProperties.map((animatedProperty, index) => ( ))} -
+ ); }; export default TrackPropertiesEditor; - -AnimatedVec2._def.typeName; diff --git a/app/src/components/Timeline/index.tsx b/app/src/components/Timeline/index.tsx index 61e1110..a971299 100644 --- a/app/src/components/Timeline/index.tsx +++ b/app/src/components/Timeline/index.tsx @@ -26,7 +26,7 @@ const Timeline: FC = () => { })); return ( -
+
-
+
diff --git a/app/src/components/ToggleGroup.tsx b/app/src/components/ToggleGroup.tsx index 92ee6aa..e649da8 100644 --- a/app/src/components/ToggleGroup.tsx +++ b/app/src/components/ToggleGroup.tsx @@ -1,5 +1,6 @@ import { FC, ReactNode } from "react"; import * as ToggleGroupComponents from "@radix-ui/react-toggle-group"; +import { motion } from "framer-motion"; const ToggleGroupItem: FC<{ children: ReactNode; @@ -9,23 +10,26 @@ const ToggleGroupItem: FC<{ return ( - {children} + + {children} + ); }; const ToggleGroup: FC<{ children: ReactNode }> = ({ children }) => ( > = [ - buildStaggeredText("Ehrenmann?", 2.0, { + buildStaggeredText("Work in Progress...", 2.0, { value: [255, 255, 255, 1.0], }), // buildText("Wie gehts?", 2.5, 40, 40, { value: [200, 200, 200, 1.0] }), diff --git a/app/src/hooks/useKeyControls.ts b/app/src/hooks/useKeyControls.ts index 33700b5..88f6b07 100644 --- a/app/src/hooks/useKeyControls.ts +++ b/app/src/hooks/useKeyControls.ts @@ -4,13 +4,19 @@ import { useRenderStateStore } from "stores/render-state.store"; export default function useKeyControls() { const handleKeyPress = useCallback((e: KeyboardEvent) => { - if (e.code === "Space") { - useRenderStateStore.getState().togglePlaying(); - } - if (e.code === "Backspace") { - const selectedEntity = useEntitiesStore.getState().selectedEntity; - if (selectedEntity !== undefined) { - useEntitiesStore.getState().deleteEntity(selectedEntity); + // Only run shortcuts if no input is focused + + if (document.activeElement?.nodeName !== "INPUT") { + if (e.code === "Space") { + e.preventDefault(); + useRenderStateStore.getState().togglePlaying(); + } + if (e.code === "Backspace") { + const selectedEntity = useEntitiesStore.getState().selectedEntity; + + if (selectedEntity !== undefined) { + useEntitiesStore.getState().deleteEntity(selectedEntity); + } } } }, []); diff --git a/app/src/primitives/AnimatedEntities.ts b/app/src/primitives/AnimatedEntities.ts index 7b9dd06..5c6be5e 100644 --- a/app/src/primitives/AnimatedEntities.ts +++ b/app/src/primitives/AnimatedEntities.ts @@ -130,9 +130,9 @@ export function getAnimatedPropertiesByAnimatedEntity( label: "Origin", }); animatedProperties.push({ - propertyPath: "radius", + propertyPath: "size", animatedValue: animatedEntity.size, - label: "Radius", + label: "Size", }); if (animatedEntity.transform) { diff --git a/app/src/utils/index.ts b/app/src/utils/index.ts index 9ec0cca..771b8cb 100644 --- a/app/src/utils/index.ts +++ b/app/src/utils/index.ts @@ -79,3 +79,36 @@ export function flattenedKeyframesByEntity( return keyframes; } + +/** + * Set a value inside an object with its path: example: set({}, 'a.b.c', '...') => { a: { b: { c: '...' } } } + * If one of the keys in path doesn't exists in object, it'll be created. + * + * @param object Object to manipulate + * @param path Path to the object field that need to be created/updated + * @param value Value to set + */ +export function set(object: any, path: string, value: any) { + const decomposedPath = path.split("."); + const base = decomposedPath[0]; + + if (base === undefined) { + return object; + } + + // assign an empty object in order to spread object + if (!object.hasOwnProperty(base)) { + object[base] = {}; + } + + // Determine if there is still layers to traverse + value = + decomposedPath.length <= 1 + ? value + : set(object[base], decomposedPath.slice(1).join("."), value); + + return { + ...object, + [base]: value, + }; +} diff --git a/app/yarn.lock b/app/yarn.lock index 2562e6a..3906dba 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -897,6 +897,18 @@ resolved "https://packages.unom.io/@tempblade%2fcommon/-/common-2.0.1.tgz#2849607543549b993a2870b6a9f590d29961d776" integrity sha512-8uCqsfu2tcQq4O4XODS7Hn7Mj9hZh+Rh+Y0Fsej9Bbemn/WwlIT0WrUSzWGMZLcTspvgl6kz/ljBzCqLAa3Yyw== +"@types/lodash.set@^4.3.7": + version "4.3.7" + resolved "https://registry.yarnpkg.com/@types/lodash.set/-/lodash.set-4.3.7.tgz#784fccea3fbef4d0949d1897a780f592da700942" + integrity sha512-bS5Wkg/nrT82YUfkNYPSccFrNZRL+irl7Yt4iM6OTSQ0VZJED2oUIVm15NkNtUAQ8SRhCe+axqERUV6MJgkeEg== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.195" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632" + integrity sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg== + "@types/node@^18.7.10": version "18.16.16" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.16.16.tgz#3b64862856c7874ccf7439e6bab872d245c86d8e" @@ -1393,6 +1405,11 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +lodash.set@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" + integrity sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg== + loose-envify@^1.0.0, loose-envify@^1.1.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"