improve ui
add track properties editor
This commit is contained in:
@@ -5,13 +5,24 @@ import { Keyframe } from "primitives/Keyframe";
|
||||
import { FC } from "react";
|
||||
import { z } from "zod";
|
||||
import { TIMELINE_SCALE } from "./common";
|
||||
import { AnimatedNumber, AnimatedVec2, AnimatedVec3 } from "primitives/Values";
|
||||
import { useKeyframeStore } from "stores/keyframe.store";
|
||||
|
||||
const KeyframeIndicator: FC<{
|
||||
keyframe: z.input<typeof Keyframe>;
|
||||
animationData: z.input<typeof AnimationData>;
|
||||
}> = ({ keyframe, animationData }) => {
|
||||
const { selectedKeyframe, selectKeyframe, deselectKeyframe } =
|
||||
useKeyframeStore();
|
||||
|
||||
const selected = selectedKeyframe === keyframe.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
drag="x"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
data-selected={selected}
|
||||
dragConstraints={{ left: 0 }}
|
||||
animate={{
|
||||
x: (animationData.offset + keyframe.offset) * TIMELINE_SCALE + 4,
|
||||
}}
|
||||
@@ -19,9 +30,105 @@ const KeyframeIndicator: FC<{
|
||||
style={{
|
||||
clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
|
||||
}}
|
||||
className="bg-indigo-300 absolute w-2 h-2 z-30 top-[39%] select-none pointer-events-none"
|
||||
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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AnimatedNumberKeyframeIndicator: FC<{
|
||||
animatedNumber: z.input<typeof AnimatedNumber>;
|
||||
animationData: z.input<typeof AnimationData>;
|
||||
}> = ({ animatedNumber, animationData }) => {
|
||||
return (
|
||||
<>
|
||||
{animatedNumber.keyframes.values.map((keyframe) => (
|
||||
<KeyframeIndicator
|
||||
key={keyframe.id}
|
||||
keyframe={keyframe}
|
||||
animationData={animationData}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type DimensionsVec2 = "x" | "y";
|
||||
const VEC2_DIMENSION_INDEX_MAPPING: Record<DimensionsVec2, number> = {
|
||||
x: 0,
|
||||
y: 1,
|
||||
};
|
||||
|
||||
const AnimatedVec2KeyframeIndicator: FC<{
|
||||
animatedVec2: z.input<typeof AnimatedVec2>;
|
||||
dimension?: DimensionsVec2;
|
||||
animationData: z.input<typeof AnimationData>;
|
||||
}> = ({ animatedVec2, animationData, dimension }) => {
|
||||
if (dimension) {
|
||||
return (
|
||||
<AnimatedNumberKeyframeIndicator
|
||||
animationData={animationData}
|
||||
animatedNumber={
|
||||
animatedVec2.keyframes[VEC2_DIMENSION_INDEX_MAPPING[dimension]]
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{animatedVec2.keyframes.map((animatedNumber, index) => (
|
||||
<AnimatedNumberKeyframeIndicator
|
||||
key={index}
|
||||
animatedNumber={animatedNumber}
|
||||
animationData={animationData}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type DimensionsVec3 = "x" | "y" | "z";
|
||||
const VEC3_DIMENSION_INDEX_MAPPING: Record<DimensionsVec3, number> = {
|
||||
x: 0,
|
||||
y: 1,
|
||||
z: 2,
|
||||
};
|
||||
|
||||
const AnimatedVec3KeyframeIndicator: FC<{
|
||||
animatedVec3: z.input<typeof AnimatedVec3>;
|
||||
animationData: z.input<typeof AnimationData>;
|
||||
dimension?: DimensionsVec3;
|
||||
}> = ({ animatedVec3, animationData, dimension }) => {
|
||||
if (dimension) {
|
||||
return (
|
||||
<AnimatedNumberKeyframeIndicator
|
||||
animationData={animationData}
|
||||
animatedNumber={
|
||||
animatedVec3.keyframes[VEC3_DIMENSION_INDEX_MAPPING[dimension]]
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{animatedVec3.keyframes.map((animatedNumber, index) => (
|
||||
<AnimatedNumberKeyframeIndicator
|
||||
key={index}
|
||||
animatedNumber={animatedNumber}
|
||||
animationData={animationData}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
AnimatedNumberKeyframeIndicator,
|
||||
AnimatedVec3KeyframeIndicator,
|
||||
AnimatedVec2KeyframeIndicator,
|
||||
};
|
||||
export default KeyframeIndicator;
|
||||
|
||||
@@ -2,12 +2,17 @@ import { ease } from "@unom/style";
|
||||
import { useDragControls, Reorder, motion } from "framer-motion";
|
||||
import { AnimationData, AnimatedEntity } from "primitives/AnimatedEntities";
|
||||
import { Keyframe } from "primitives/Keyframe";
|
||||
import { FC } from "react";
|
||||
import { FC, useState } from "react";
|
||||
import { useEntitiesStore } from "stores/entities.store";
|
||||
import { z } from "zod";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import KeyframeIndicator from "./KeyframeIndicator";
|
||||
import KeyframeIndicator, {
|
||||
AnimatedVec2KeyframeIndicator,
|
||||
AnimatedVec3KeyframeIndicator,
|
||||
} from "./KeyframeIndicator";
|
||||
import { TIMELINE_SCALE, calculateOffset } from "./common";
|
||||
import { TriangleDownIcon } from "@radix-ui/react-icons";
|
||||
import TrackPropertiesEditor from "./TrackPropertiesEditor";
|
||||
|
||||
type TrackProps = {
|
||||
animationData: z.input<typeof AnimationData>;
|
||||
@@ -26,6 +31,8 @@ const Track: FC<TrackProps> = ({
|
||||
}) => {
|
||||
const controls = useDragControls();
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const { updateEntity, selectEntity, selectedEntity, deselectEntity } =
|
||||
useEntitiesStore(
|
||||
(store) => ({
|
||||
@@ -42,137 +49,156 @@ const Track: FC<TrackProps> = ({
|
||||
value={entity}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
className="h-8 relative flex flex-1 flex-row gap-1 select-none"
|
||||
className="min-h-8 relative flex flex-1 flex-col gap-1 select-none"
|
||||
>
|
||||
<div
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
className={`h-full transition-all rounded-sm min-w-[200px] p-1 px-2 flex flex-row ${
|
||||
selectedEntity === index ? "bg-gray-800" : "bg-gray-900"
|
||||
}`}
|
||||
>
|
||||
<h3
|
||||
onClick={() =>
|
||||
selectedEntity !== undefined && selectedEntity === index
|
||||
? deselectEntity()
|
||||
: selectEntity(index)
|
||||
}
|
||||
className="text-white-800 select-none cursor-pointer"
|
||||
<div className="flex flex-row gap-1 select-none">
|
||||
<div
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
className={`h-full transition-all rounded-sm min-w-[200px] p-1 px-2 flex flex-col ${
|
||||
selectedEntity === index ? "bg-gray-800" : "bg-gray-900"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<motion.div
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="will-change-transform"
|
||||
animate={{ rotate: isExpanded ? 0 : -90 }}
|
||||
>
|
||||
<TriangleDownIcon
|
||||
width="32px"
|
||||
height="32px"
|
||||
className="text-white"
|
||||
/>
|
||||
</motion.div>
|
||||
<h3
|
||||
onClick={() =>
|
||||
selectedEntity !== undefined && selectedEntity === index
|
||||
? deselectEntity()
|
||||
: selectEntity(index)
|
||||
}
|
||||
className="text-white-800 select-none cursor-pointer"
|
||||
>
|
||||
{name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ width: TIMELINE_SCALE * 10 }}
|
||||
className="flex h-full flex-row relative bg-gray-900 select-none shrink-0"
|
||||
>
|
||||
{keyframes.map((keyframe, index) => (
|
||||
<KeyframeIndicator
|
||||
animationData={animationData}
|
||||
keyframe={keyframe}
|
||||
key={index}
|
||||
<div
|
||||
style={{ width: TIMELINE_SCALE * 10 }}
|
||||
className="flex h-full flex-row relative bg-gray-900 select-none shrink-0"
|
||||
>
|
||||
{!isExpanded &&
|
||||
keyframes.map((keyframe, index) => (
|
||||
<KeyframeIndicator
|
||||
animationData={animationData}
|
||||
keyframe={keyframe}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
drag="x"
|
||||
animate={{
|
||||
x: animationData.offset * TIMELINE_SCALE,
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
}}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
transition={ease.circ(0.6).out}
|
||||
dragElastic={false}
|
||||
dragConstraints={{ left: 0 }}
|
||||
onDragEnd={(e, info) => {
|
||||
let offset = info.offset.x;
|
||||
|
||||
offset = calculateOffset(offset);
|
||||
|
||||
const animationOffset =
|
||||
animationData.offset + offset < 0
|
||||
? 0
|
||||
: animationData.offset + offset;
|
||||
|
||||
const duration = animationData.duration - offset;
|
||||
|
||||
updateEntity(index, {
|
||||
animation_data: {
|
||||
...animationData,
|
||||
offset: animationOffset < 0 ? 0 : animationOffset,
|
||||
duration: duration < 0 ? 0 : duration,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="z-10 w-4 bg-slate-500 h-8 absolute rounded-md select-none cursor-w-resize"
|
||||
/>
|
||||
))}
|
||||
<motion.div
|
||||
drag="x"
|
||||
animate={{
|
||||
x: animationData.offset * TIMELINE_SCALE,
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
}}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
transition={ease.circ(0.6).out}
|
||||
dragElastic={false}
|
||||
dragConstraints={{ left: 0 }}
|
||||
onDragEnd={(e, info) => {
|
||||
let offset = info.offset.x;
|
||||
<motion.div
|
||||
className="z-10 w-4 bg-slate-500 h-8 absolute rounded-md select-none cursor-e-resize"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
drag="x"
|
||||
animate={{
|
||||
x:
|
||||
(animationData.duration + animationData.offset) *
|
||||
TIMELINE_SCALE -
|
||||
16,
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
}}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
}}
|
||||
transition={ease.circ(0.6).out}
|
||||
dragConstraints={{ left: 0 }}
|
||||
onDragEnd={(e, info) => {
|
||||
let offset = info.offset.x;
|
||||
|
||||
offset = calculateOffset(offset);
|
||||
offset = calculateOffset(offset);
|
||||
|
||||
const animationOffset =
|
||||
animationData.offset + offset < 0
|
||||
? 0
|
||||
: animationData.offset + offset;
|
||||
const duration = animationData.duration + offset;
|
||||
|
||||
const duration = animationData.duration - offset;
|
||||
updateEntity(index, {
|
||||
animation_data: {
|
||||
...animationData,
|
||||
duration: duration < 0 ? 0 : duration,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
drag="x"
|
||||
animate={{
|
||||
width: animationData.duration * TIMELINE_SCALE,
|
||||
x: animationData.offset * TIMELINE_SCALE,
|
||||
}}
|
||||
whileHover={{ scaleY: 1.1 }}
|
||||
whileTap={{ scaleY: 0.9 }}
|
||||
dragConstraints={{
|
||||
left: 0,
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
transition={ease.circ(0.8).out}
|
||||
onDragEnd={(_e, info) => {
|
||||
let offset = info.offset.x;
|
||||
|
||||
updateEntity(index, {
|
||||
animation_data: {
|
||||
...animationData,
|
||||
offset: animationOffset < 0 ? 0 : animationOffset,
|
||||
duration: duration < 0 ? 0 : duration,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="z-10 w-4 bg-slate-500 h-full absolute rounded-md select-none cursor-w-resize"
|
||||
/>
|
||||
<motion.div
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
drag="x"
|
||||
animate={{
|
||||
x:
|
||||
(animationData.duration + animationData.offset) * TIMELINE_SCALE -
|
||||
16,
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
}}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
}}
|
||||
transition={ease.circ(0.6).out}
|
||||
dragConstraints={{ left: 0 }}
|
||||
onDragEnd={(e, info) => {
|
||||
let offset = info.offset.x;
|
||||
offset = calculateOffset(offset);
|
||||
|
||||
offset = calculateOffset(offset);
|
||||
offset += animationData.offset;
|
||||
|
||||
const duration = animationData.duration + offset;
|
||||
|
||||
updateEntity(index, {
|
||||
animation_data: {
|
||||
...animationData,
|
||||
duration: duration < 0 ? 0 : duration,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="z-10 w-4 bg-slate-500 h-full absolute rounded-md select-none cursor-e-resize"
|
||||
/>
|
||||
<motion.div
|
||||
drag="x"
|
||||
animate={{
|
||||
width: animationData.duration * TIMELINE_SCALE,
|
||||
x: animationData.offset * TIMELINE_SCALE,
|
||||
}}
|
||||
whileHover={{ scaleY: 1.1 }}
|
||||
whileTap={{ scaleY: 0.9 }}
|
||||
dragConstraints={{
|
||||
left: 0,
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
transition={ease.circ(0.8).out}
|
||||
onDragEnd={(_e, info) => {
|
||||
let offset = info.offset.x;
|
||||
|
||||
offset = calculateOffset(offset);
|
||||
|
||||
offset += animationData.offset;
|
||||
|
||||
updateEntity(index, {
|
||||
animation_data: {
|
||||
...animationData,
|
||||
offset: offset < 0 ? 0 : offset,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="z-5 h-full absolute rounded-md transition-colors bg-gray-700 hover:bg-gray-600 select-none cursor-grab"
|
||||
></motion.div>
|
||||
updateEntity(index, {
|
||||
animation_data: {
|
||||
...animationData,
|
||||
offset: offset < 0 ? 0 : offset,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="z-5 h-8 absolute rounded-md transition-colors bg-gray-700 hover:bg-gray-600 select-none cursor-grab"
|
||||
></motion.div>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && <TrackPropertiesEditor entity={entity} />}
|
||||
</Reorder.Item>
|
||||
);
|
||||
};
|
||||
|
||||
125
app/src/components/Timeline/TrackPropertiesEditor.tsx
Normal file
125
app/src/components/Timeline/TrackPropertiesEditor.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
AnimatedEntity,
|
||||
AnimationData,
|
||||
getAnimatedPropertiesByAnimatedEntity,
|
||||
} from "primitives/AnimatedEntities";
|
||||
import { AnimatedProperty } from "primitives/AnimatedProperty";
|
||||
import { AnimatedVec2, ValueType } from "primitives/Values";
|
||||
import { FC, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AnimatedNumberKeyframeIndicator,
|
||||
AnimatedVec2KeyframeIndicator,
|
||||
AnimatedVec3KeyframeIndicator,
|
||||
} from "./KeyframeIndicator";
|
||||
import { ToggleGroup, ToggleGroupItem } from "components/ToggleGroup";
|
||||
|
||||
const TrackAnimatedPropertyKeyframes: FC<{
|
||||
animatedProperty: z.input<typeof AnimatedProperty>;
|
||||
animationData: z.input<typeof AnimationData>;
|
||||
selectedDimension?: "x" | "y" | "z";
|
||||
}> = ({ animatedProperty, animationData, selectedDimension }) => {
|
||||
switch (animatedProperty.animatedValue.type) {
|
||||
case "Number":
|
||||
return (
|
||||
<AnimatedNumberKeyframeIndicator
|
||||
animatedNumber={animatedProperty.animatedValue}
|
||||
animationData={animationData}
|
||||
/>
|
||||
);
|
||||
case "Vec2":
|
||||
return (
|
||||
<AnimatedVec2KeyframeIndicator
|
||||
dimension={selectedDimension !== "z" ? selectedDimension : undefined}
|
||||
animatedVec2={animatedProperty.animatedValue}
|
||||
animationData={animationData}
|
||||
/>
|
||||
);
|
||||
|
||||
case "Vec3":
|
||||
return (
|
||||
<AnimatedVec3KeyframeIndicator
|
||||
dimension={selectedDimension}
|
||||
animatedVec3={animatedProperty.animatedValue}
|
||||
animationData={animationData}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const TrackAnimatedProperty: FC<{
|
||||
animatedProperty: z.input<typeof AnimatedProperty>;
|
||||
animationData: z.input<typeof AnimationData>;
|
||||
trackIndex: number;
|
||||
}> = ({ animatedProperty, animationData }) => {
|
||||
const [selectedDimension, setSelectedDimension] = useState<"x" | "y" | "z">();
|
||||
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<div className="min-w-[200px] flex flex-row justify-between">
|
||||
<h3>{animatedProperty.label}</h3>
|
||||
<ToggleGroup>
|
||||
<ToggleGroupItem
|
||||
onClick={() => setSelectedDimension("x")}
|
||||
selected={selectedDimension === "x"}
|
||||
>
|
||||
X
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
onClick={() => setSelectedDimension("y")}
|
||||
selected={selectedDimension === "y"}
|
||||
>
|
||||
Y
|
||||
</ToggleGroupItem>
|
||||
{animatedProperty.animatedValue.type === ValueType.Enum.Vec3 && (
|
||||
<ToggleGroupItem
|
||||
onClick={() => setSelectedDimension("z")}
|
||||
selected={selectedDimension === "z"}
|
||||
>
|
||||
Z
|
||||
</ToggleGroupItem>
|
||||
)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<TrackAnimatedPropertyKeyframes
|
||||
selectedDimension={
|
||||
animatedProperty.animatedValue.type !== "Number"
|
||||
? selectedDimension
|
||||
: undefined
|
||||
}
|
||||
animatedProperty={animatedProperty}
|
||||
animationData={animationData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrackPropertiesEditor: FC<{ entity: z.input<typeof AnimatedEntity> }> = ({
|
||||
entity,
|
||||
}) => {
|
||||
const animatedProperties = useMemo(
|
||||
() => getAnimatedPropertiesByAnimatedEntity(entity),
|
||||
[entity]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{animatedProperties.map((animatedProperty, index) => (
|
||||
<TrackAnimatedProperty
|
||||
trackIndex={index}
|
||||
animationData={entity.animation_data}
|
||||
key={index}
|
||||
animatedProperty={animatedProperty}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrackPropertiesEditor;
|
||||
|
||||
AnimatedVec2._def.typeName;
|
||||
Reference in New Issue
Block a user