import { NumberField } from "@/components/ui/number-field"; import { useEditor } from "@/editor/use-editor"; import { clamp, isNearlyEqual } from "@/utils/math"; import type { VisualElement } from "@/model"; import { Section, SectionContent, SectionField, SectionFields, SectionHeader, SectionTitle, } from "@/components/section"; import { Button } from "@/components/ui/button"; import { HugeiconsIcon } from "@hugeicons/react"; import { ArrowExpandIcon, Link05Icon, RotateClockwiseIcon, } from "@hugeicons/core-free-icons"; import { getGroupKeyframesAtTime, hasGroupKeyframeAtTime, } from "@/animation"; import { resolveTransformAtTime } from "@/rendering/animation-values"; import { DEFAULTS } from "@/timeline/defaults"; import { useElementPlayhead } from "@/components/editor/panels/properties/hooks/use-element-playhead"; import { KeyframeToggle } from "@/components/editor/panels/properties/components/keyframe-toggle"; import { useKeyframedNumberProperty } from "@/components/editor/panels/properties/hooks/use-keyframed-number-property"; import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; 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 TransformTab({ element, trackId, }: { element: VisualElement; trackId: string; }) { const editor = useEditor(); const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); const setTransformScaleLocked = usePropertiesStore( (s) => s.setTransformScaleLocked, ); const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ startTime: element.startTime, duration: element.duration, }); const resolvedTransform = resolveTransformAtTime({ baseTransform: element.transform, animations: element.animations, localTime, }); const positionX = useKeyframedNumberProperty({ trackId, elementId: element.id, animations: element.animations, propertyPath: "transform.positionX", localTime, isPlayheadWithinElementRange, displayValue: Math.round(resolvedTransform.position.x).toString(), parse: (input) => parseNumericInput({ input }), valueAtPlayhead: resolvedTransform.position.x, step: 1, buildBaseUpdates: ({ value }) => ({ transform: { ...element.transform, position: { ...element.transform.position, x: value }, }, }), }); const positionY = useKeyframedNumberProperty({ trackId, elementId: element.id, animations: element.animations, propertyPath: "transform.positionY", localTime, isPlayheadWithinElementRange, displayValue: Math.round(resolvedTransform.position.y).toString(), parse: (input) => parseNumericInput({ input }), valueAtPlayhead: resolvedTransform.position.y, step: 1, buildBaseUpdates: ({ value }) => ({ transform: { ...element.transform, position: { ...element.transform.position, y: value }, }, }), }); const parseScale = (input: string) => { const parsed = parseNumericInput({ input }); if (parsed === null) return null; return parsed / 100; }; const scaleX = useKeyframedNumberProperty({ trackId, elementId: element.id, animations: element.animations, propertyPath: "transform.scaleX", localTime, isPlayheadWithinElementRange, displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), parse: parseScale, valueAtPlayhead: resolvedTransform.scaleX, step: 0.01, buildBaseUpdates: ({ value }) => ({ transform: { ...element.transform, scaleX: value, ...(isScaleLocked ? { scaleY: value } : {}), }, }), buildAdditionalKeyframes: isScaleLocked ? ({ value }) => [{ propertyPath: "transform.scaleY", value }] : undefined, }); const scaleY = useKeyframedNumberProperty({ trackId, elementId: element.id, animations: element.animations, propertyPath: "transform.scaleY", localTime, isPlayheadWithinElementRange, displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), parse: parseScale, valueAtPlayhead: resolvedTransform.scaleY, step: 0.01, buildBaseUpdates: ({ value }) => ({ transform: { ...element.transform, scaleY: value, ...(isScaleLocked ? { scaleX: value } : {}), }, }), buildAdditionalKeyframes: isScaleLocked ? ({ value }) => [{ propertyPath: "transform.scaleX", value }] : undefined, }); const scaleFieldPropsX = { value: scaleX.displayValue, onFocus: scaleX.onFocus, onChange: scaleX.onChange, onBlur: scaleX.onBlur, dragSensitivity: "slow" as const, onScrub: scaleX.scrubTo, onScrubEnd: scaleX.commitScrub, onReset: () => scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), isDefault: isPropertyAtDefault({ hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue: resolvedTransform.scaleX, staticValue: element.transform.scaleX, defaultValue: DEFAULTS.element.transform.scaleX, }), }; const scaleFieldPropsY = { value: scaleY.displayValue, onFocus: scaleY.onFocus, onChange: scaleY.onChange, onBlur: scaleY.onBlur, dragSensitivity: "slow" as const, onScrub: scaleY.scrubTo, onScrubEnd: scaleY.commitScrub, onReset: () => scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), isDefault: isPropertyAtDefault({ hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue: resolvedTransform.scaleY, staticValue: element.transform.scaleY, defaultValue: DEFAULTS.element.transform.scaleY, }), }; 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, step: 1, buildBaseUpdates: ({ value }) => ({ transform: { ...element.transform, rotate: value, }, }), }); const hasScaleKeyframe = hasGroupKeyframeAtTime({ animations: element.animations, group: "transform.scale", time: localTime, }); const toggleScaleKeyframe = () => { if (!isPlayheadWithinElementRange) return; const existing = getGroupKeyframesAtTime({ animations: element.animations, group: "transform.scale", time: localTime, }); if (existing.length > 0) { editor.timeline.removeKeyframes({ keyframes: existing.map((ref) => ({ trackId, elementId: element.id, ...ref, })), }); return; } editor.timeline.upsertKeyframes({ keyframes: [ { trackId, elementId: element.id, propertyPath: "transform.scaleX", time: localTime, value: resolvedTransform.scaleX, }, { trackId, elementId: element.id, propertyPath: "transform.scaleY", time: localTime, value: resolvedTransform.scaleY, }, ], }); }; const scaleLockButton = ( ); return (
Transform
{isScaleLocked ? ( <> } > } {...scaleFieldPropsX} /> {scaleLockButton} ) : ( <> } > {scaleLockButton} } > )}
} > positionX.commitValue({ value: DEFAULTS.element.transform.position.x, }) } isDefault={isPropertyAtDefault({ hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue: resolvedTransform.position.x, staticValue: element.transform.position.x, defaultValue: DEFAULTS.element.transform.position.x, })} /> } > positionY.commitValue({ value: DEFAULTS.element.transform.position.y, }) } isDefault={isPropertyAtDefault({ hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue: resolvedTransform.position.y, staticValue: element.transform.position.y, defaultValue: DEFAULTS.element.transform.position.y, })} />
} >
} 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: DEFAULTS.element.transform.rotate, }) } isDefault={isPropertyAtDefault({ hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, isPlayheadWithinElementRange, resolvedValue: resolvedTransform.rotate, staticValue: element.transform.rotate, defaultValue: DEFAULTS.element.transform.rotate, })} />
); }