improve ui

add track properties editor
This commit is contained in:
2023-05-30 23:58:36 +02:00
parent 28613c9214
commit 8d1f949280
33 changed files with 2777 additions and 3751 deletions

View File

@@ -127,13 +127,6 @@ export const TextProperties: FC<TextPropertiesProps> = ({
}
></input>
</label>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, origin: updatedEntity })
}
label="Origin"
entity={entity.origin}
/>
</motion.div>
);
};
@@ -217,13 +210,6 @@ export const StaggeredTextProperties: FC<StaggeredTextPropertiesProps> = ({
})
}
/>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, origin: updatedEntity })
}
label="Origin"
entity={entity.origin}
/>
</motion.div>
);
};
@@ -240,20 +226,6 @@ export const RectProperties: FC<RectPropertiesProps> = ({
onUpdate({ ...entity, paint: { ...entity.paint, ...paint } })
}
/>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, position: updatedEntity })
}
label="Position"
entity={entity.position}
/>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, size: updatedEntity })
}
label="Size"
entity={entity.size}
/>
</div>
);
};
@@ -270,20 +242,6 @@ export const EllipseProperties: FC<EllipsePropertiesProps> = ({
onUpdate({ ...entity, paint: { ...entity.paint, ...paint } })
}
/>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, position: updatedEntity })
}
label="Position"
entity={entity.position}
/>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, radius: updatedEntity })
}
label="Size"
entity={entity.radius}
/>
</div>
);
};

View File

@@ -5,6 +5,8 @@ import { z } from "zod";
import { produce } from "immer";
import { Interpolation } from "primitives/Interpolation";
import { Color } from "primitives/Paint";
import { colorToString, parseColor, parseCssColor } from "@tempblade/common";
import { rgbToHex } from "utils";
const InterpolationProperties: FC<
PropertiesProps<z.input<typeof Interpolation>>
@@ -76,8 +78,43 @@ const AnimatedNumberProperties: FC<
export const ColorProperties: FC<
PropertiesProps<z.input<typeof Color>> & {
label: string;
mode?: "RGB" | "Picker";
}
> = ({ entity, onUpdate }) => {
> = ({ entity, onUpdate, mode = "Picker" }) => {
if (mode === "Picker") {
return (
<label className="flex flex-col items-start">
<span className="label">Color</span>
<div className="flex flex-row gap-3">
<input
value={rgbToHex(entity.value[0], entity.value[1], entity.value[2])}
type="color"
style={{
width: 32,
height: 32,
backgroundColor: rgbToHex(
entity.value[0],
entity.value[1],
entity.value[2]
),
}}
onChange={(e) =>
onUpdate(
produce(entity, (draft) => {
const color = parseCssColor(e.target.value);
if (color) {
draft.value = [...color, 1.0];
}
})
)
}
/>
</div>
</label>
);
}
return (
<label className="flex flex-col items-start">
<span className="label">Color</span>

View File

@@ -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;

View File

@@ -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>
);
};

View 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;

View File

@@ -0,0 +1,37 @@
import { FC, ReactNode } from "react";
import * as ToggleGroupComponents from "@radix-ui/react-toggle-group";
const ToggleGroupItem: FC<{
children: ReactNode;
selected: boolean;
onClick?: () => void;
}> = ({ children, selected, onClick }) => {
return (
<ToggleGroupComponents.Item
data-selected={selected}
onClick={onClick}
className="hover:bg-indigo-400 text-white data-[selected=true]:bg-indigo-600
data-[selected=true]:text-indigo-200 flex h-6 w-6
items-center justify-center bg-slate-900 text-sm leading-4
first:rounded-l last:rounded-r focus:z-10 focus:shadow-[0_0_0_2px] focus:shadow-black
focus:outline-none"
value="left"
aria-label="Left aligned"
>
{children}
</ToggleGroupComponents.Item>
);
};
const ToggleGroup: FC<{ children: ReactNode }> = ({ children }) => (
<ToggleGroupComponents.Root
className="inline-flex bg-slate-800 rounded shadow-[0_2px_10px] shadow-black space-x-px"
type="single"
defaultValue="center"
aria-label="Text alignment"
>
{children}
</ToggleGroupComponents.Root>
);
export { ToggleGroup, ToggleGroupItem };