383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
import { NumberField } from "@/components/ui/number-field";
|
|
import { useEditor } from "@/hooks/use-editor";
|
|
import { clamp, isNearlyEqual } from "@/utils/math";
|
|
import type { AnimationPropertyPath } from "@/types/animation";
|
|
import type { VisualElement } from "@/types/timeline";
|
|
import {
|
|
Section,
|
|
SectionContent,
|
|
SectionField,
|
|
SectionFields,
|
|
SectionHeader,
|
|
SectionTitle,
|
|
} from "../section";
|
|
import { Button } from "@/components/ui/button";
|
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
import {
|
|
ArrowExpandIcon,
|
|
Link05Icon,
|
|
RotateClockwiseIcon,
|
|
} from "@hugeicons/core-free-icons";
|
|
import { useState } from "react";
|
|
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
|
|
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
|
|
import { getElementLocalTime, resolveTransformAtTime } from "@/lib/animation";
|
|
import { KeyframeToggle } from "../keyframe-toggle";
|
|
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
|
|
|
export function parseNumericInput({ input }: { input: string }): number | null {
|
|
const parsed = parseFloat(input);
|
|
return Number.isNaN(parsed) ? null : parsed;
|
|
}
|
|
|
|
export function isPropertyAtDefault({
|
|
hasAnimatedKeyframes,
|
|
isPlayheadWithinElementRange,
|
|
resolvedValue,
|
|
staticValue,
|
|
defaultValue,
|
|
}: {
|
|
hasAnimatedKeyframes: boolean;
|
|
isPlayheadWithinElementRange: boolean;
|
|
resolvedValue: number;
|
|
staticValue: number;
|
|
defaultValue: number;
|
|
}): boolean {
|
|
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
|
|
return isNearlyEqual({
|
|
leftValue: resolvedValue,
|
|
rightValue: defaultValue,
|
|
});
|
|
}
|
|
|
|
return staticValue === defaultValue;
|
|
}
|
|
|
|
export function TransformSection({
|
|
element,
|
|
trackId,
|
|
showTopBorder = true,
|
|
}: {
|
|
element: VisualElement;
|
|
trackId: string;
|
|
showTopBorder?: boolean;
|
|
}) {
|
|
const editor = useEditor();
|
|
const [isScaleLocked, setIsScaleLocked] = useState(false);
|
|
const playheadTime = editor.playback.getCurrentTime();
|
|
const localTime = getElementLocalTime({
|
|
timelineTime: playheadTime,
|
|
elementStartTime: element.startTime,
|
|
elementDuration: element.duration,
|
|
});
|
|
const resolvedTransform = resolveTransformAtTime({
|
|
baseTransform: element.transform,
|
|
animations: element.animations,
|
|
localTime,
|
|
});
|
|
const isPlayheadWithinElementRange =
|
|
playheadTime >= element.startTime - TIME_EPSILON_SECONDS &&
|
|
playheadTime <= element.startTime + element.duration + TIME_EPSILON_SECONDS;
|
|
|
|
const positionX = useKeyframedNumberProperty({
|
|
trackId,
|
|
elementId: element.id,
|
|
animations: element.animations,
|
|
propertyPath: "transform.position.x",
|
|
localTime,
|
|
isPlayheadWithinElementRange,
|
|
displayValue: Math.round(resolvedTransform.position.x).toString(),
|
|
parse: (input) => parseNumericInput({ input }),
|
|
valueAtPlayhead: resolvedTransform.position.x,
|
|
buildBaseUpdates: ({ value }) => ({
|
|
transform: {
|
|
...element.transform,
|
|
position: {
|
|
...element.transform.position,
|
|
x: value,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const positionY = useKeyframedNumberProperty({
|
|
trackId,
|
|
elementId: element.id,
|
|
animations: element.animations,
|
|
propertyPath: "transform.position.y",
|
|
localTime,
|
|
isPlayheadWithinElementRange,
|
|
displayValue: Math.round(resolvedTransform.position.y).toString(),
|
|
parse: (input) => parseNumericInput({ input }),
|
|
valueAtPlayhead: resolvedTransform.position.y,
|
|
buildBaseUpdates: ({ value }) => ({
|
|
transform: {
|
|
...element.transform,
|
|
position: {
|
|
...element.transform.position,
|
|
y: value,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const scale = useKeyframedNumberProperty({
|
|
trackId,
|
|
elementId: element.id,
|
|
animations: element.animations,
|
|
propertyPath: "transform.scale",
|
|
localTime,
|
|
isPlayheadWithinElementRange,
|
|
displayValue: Math.round(resolvedTransform.scale * 100).toString(),
|
|
parse: (input) => {
|
|
const parsed = parseNumericInput({ input });
|
|
if (parsed === null) return null;
|
|
return Math.max(parsed, 1) / 100;
|
|
},
|
|
valueAtPlayhead: resolvedTransform.scale,
|
|
buildBaseUpdates: ({ value }) => ({
|
|
transform: {
|
|
...element.transform,
|
|
scale: value,
|
|
},
|
|
}),
|
|
});
|
|
const scaleFieldProps = {
|
|
className: "flex-1",
|
|
value: scale.displayValue,
|
|
onFocus: scale.onFocus,
|
|
onChange: scale.onChange,
|
|
onBlur: scale.onBlur,
|
|
dragSensitivity: "slow" as const,
|
|
onScrub: scale.scrubTo,
|
|
onScrubEnd: scale.commitScrub,
|
|
onReset: () => scale.commitValue({ value: DEFAULT_TRANSFORM.scale }),
|
|
isDefault: isPropertyAtDefault({
|
|
hasAnimatedKeyframes: scale.hasAnimatedKeyframes,
|
|
isPlayheadWithinElementRange,
|
|
resolvedValue: resolvedTransform.scale,
|
|
staticValue: element.transform.scale,
|
|
defaultValue: DEFAULT_TRANSFORM.scale,
|
|
}),
|
|
};
|
|
|
|
const rotation = useKeyframedNumberProperty({
|
|
trackId,
|
|
elementId: element.id,
|
|
animations: element.animations,
|
|
propertyPath: "transform.rotate",
|
|
localTime,
|
|
isPlayheadWithinElementRange,
|
|
displayValue: Math.round(resolvedTransform.rotate).toString(),
|
|
parse: (input) => {
|
|
const parsed = parseNumericInput({ input });
|
|
if (parsed === null) return null;
|
|
return clamp({ value: parsed, min: -360, max: 360 });
|
|
},
|
|
valueAtPlayhead: resolvedTransform.rotate,
|
|
buildBaseUpdates: ({ value }) => ({
|
|
transform: {
|
|
...element.transform,
|
|
rotate: value,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const hasPositionKeyframe =
|
|
positionX.isKeyframedAtTime || positionY.isKeyframedAtTime;
|
|
|
|
const togglePositionKeyframe = () => {
|
|
if (!isPlayheadWithinElementRange) {
|
|
return;
|
|
}
|
|
|
|
if (positionX.keyframeIdAtTime || positionY.keyframeIdAtTime) {
|
|
const keyframesToRemove: Array<{
|
|
trackId: string;
|
|
elementId: string;
|
|
propertyPath: AnimationPropertyPath;
|
|
keyframeId: string;
|
|
}> = [];
|
|
if (positionX.keyframeIdAtTime) {
|
|
keyframesToRemove.push({
|
|
trackId,
|
|
elementId: element.id,
|
|
propertyPath: "transform.position.x" as const,
|
|
keyframeId: positionX.keyframeIdAtTime,
|
|
});
|
|
}
|
|
if (positionY.keyframeIdAtTime) {
|
|
keyframesToRemove.push({
|
|
trackId,
|
|
elementId: element.id,
|
|
propertyPath: "transform.position.y" as const,
|
|
keyframeId: positionY.keyframeIdAtTime,
|
|
});
|
|
}
|
|
|
|
editor.timeline.removeKeyframes({
|
|
keyframes: keyframesToRemove,
|
|
});
|
|
return;
|
|
}
|
|
|
|
editor.timeline.upsertKeyframes({
|
|
keyframes: [
|
|
{
|
|
trackId,
|
|
elementId: element.id,
|
|
propertyPath: "transform.position.x",
|
|
time: localTime,
|
|
value: resolvedTransform.position.x,
|
|
},
|
|
{
|
|
trackId,
|
|
elementId: element.id,
|
|
propertyPath: "transform.position.y",
|
|
time: localTime,
|
|
value: resolvedTransform.position.y,
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Section
|
|
collapsible
|
|
sectionKey={`${element.type}:transform`}
|
|
showTopBorder={showTopBorder}
|
|
>
|
|
<SectionHeader><SectionTitle>Transform</SectionTitle></SectionHeader>
|
|
<SectionContent>
|
|
<SectionFields>
|
|
<SectionField
|
|
label="Scale"
|
|
beforeLabel={
|
|
<KeyframeToggle
|
|
isActive={scale.isKeyframedAtTime}
|
|
isDisabled={!isPlayheadWithinElementRange}
|
|
title="Toggle scale keyframe"
|
|
onToggle={scale.toggleKeyframe}
|
|
/>
|
|
}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{isScaleLocked ? (
|
|
<>
|
|
<NumberField icon="W" {...scaleFieldProps} />
|
|
<NumberField icon="H" {...scaleFieldProps} />
|
|
</>
|
|
) : (
|
|
<NumberField
|
|
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
|
{...scaleFieldProps}
|
|
className="flex-1"
|
|
/>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant={isScaleLocked ? "secondary" : "ghost"}
|
|
size="icon"
|
|
aria-pressed={isScaleLocked}
|
|
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
|
|
>
|
|
<HugeiconsIcon icon={Link05Icon} />
|
|
</Button>
|
|
</div>
|
|
</SectionField>
|
|
<SectionField
|
|
label="Position"
|
|
beforeLabel={
|
|
<KeyframeToggle
|
|
isActive={hasPositionKeyframe}
|
|
isDisabled={!isPlayheadWithinElementRange}
|
|
title="Toggle position keyframe"
|
|
onToggle={togglePositionKeyframe}
|
|
/>
|
|
}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<NumberField
|
|
icon="X"
|
|
className="flex-1"
|
|
value={positionX.displayValue}
|
|
onFocus={positionX.onFocus}
|
|
onChange={positionX.onChange}
|
|
onBlur={positionX.onBlur}
|
|
onScrub={positionX.scrubTo}
|
|
onScrubEnd={positionX.commitScrub}
|
|
onReset={() =>
|
|
positionX.commitValue({ value: DEFAULT_TRANSFORM.position.x })
|
|
}
|
|
isDefault={isPropertyAtDefault({
|
|
hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
|
|
isPlayheadWithinElementRange,
|
|
resolvedValue: resolvedTransform.position.x,
|
|
staticValue: element.transform.position.x,
|
|
defaultValue: DEFAULT_TRANSFORM.position.x,
|
|
})}
|
|
/>
|
|
<NumberField
|
|
icon="Y"
|
|
className="flex-1"
|
|
value={positionY.displayValue}
|
|
onFocus={positionY.onFocus}
|
|
onChange={positionY.onChange}
|
|
onBlur={positionY.onBlur}
|
|
onScrub={positionY.scrubTo}
|
|
onScrubEnd={positionY.commitScrub}
|
|
onReset={() =>
|
|
positionY.commitValue({ value: DEFAULT_TRANSFORM.position.y })
|
|
}
|
|
isDefault={isPropertyAtDefault({
|
|
hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
|
|
isPlayheadWithinElementRange,
|
|
resolvedValue: resolvedTransform.position.y,
|
|
staticValue: element.transform.position.y,
|
|
defaultValue: DEFAULT_TRANSFORM.position.y,
|
|
})}
|
|
/>
|
|
</div>
|
|
</SectionField>
|
|
|
|
<SectionField
|
|
label="Rotation"
|
|
beforeLabel={
|
|
<KeyframeToggle
|
|
isActive={rotation.isKeyframedAtTime}
|
|
isDisabled={!isPlayheadWithinElementRange}
|
|
title="Toggle rotation keyframe"
|
|
onToggle={rotation.toggleKeyframe}
|
|
/>
|
|
}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<NumberField
|
|
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
|
className="flex-none"
|
|
value={rotation.displayValue}
|
|
onFocus={rotation.onFocus}
|
|
onChange={rotation.onChange}
|
|
onBlur={rotation.onBlur}
|
|
dragSensitivity="slow"
|
|
onScrub={rotation.scrubTo}
|
|
onScrubEnd={rotation.commitScrub}
|
|
onReset={() =>
|
|
rotation.commitValue({ value: DEFAULT_TRANSFORM.rotate })
|
|
}
|
|
isDefault={isPropertyAtDefault({
|
|
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
|
|
isPlayheadWithinElementRange,
|
|
resolvedValue: resolvedTransform.rotate,
|
|
staticValue: element.transform.rotate,
|
|
defaultValue: DEFAULT_TRANSFORM.rotate,
|
|
})}
|
|
/>
|
|
</div>
|
|
</SectionField>
|
|
</SectionFields>
|
|
</SectionContent>
|
|
</Section>
|
|
);
|
|
}
|