mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
feat(studio): runtime-synced design panel + 3D props + split polish (#1188)
* feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines Baselines regenerated inside Dockerfile.test on the devbox to match the current runtime init.ts changes. Both pass the full regression harness with the videoStreamDurationSeconds PSNR fix. * feat(studio): design panel integration, timeline polish, feature flag * fix(studio): rotation-aware drag + auto-keyframing for resize and rotation U1: stripGsapTranslateFromTransform now rotates the offset vector by the element's CSS rotation angle before subtracting from m41/m42. Fixes elements drifting from cursor during drag when rotated. U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the runtime bridge. Resize and rotation handle changes now create keyframes via the same async pipeline as position drag. CSS path guards prevent double-persistence for GSAP-animated elements. * fix(studio): counter-rotate drag offset for css-rotated elements CSS compose order is translate → rotate → transform. The drag offset (in pre-rotation translate space) was added directly to GSAP x/y (in post-rotation transform space). Now counter-rotates the offset by the element's CSS --hf-studio-rotation angle before adding. * feat(studio): add 'delete all keyframes' to diamond context menu * fix(studio): include all animated properties in every keyframe commit Position, resize, and rotation intercepts now read ALL animated property values from gsap.getProperty() at commit time and include them in the keyframe. Prevents other properties from jumping to interpolated values between surrounding keyframes when only one property (e.g., width) was explicitly changed. * feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * feat(core): spring physics solver + runtime fixes + spring ease editor * feat(core): spring physics solver + runtime fixes + spring ease editor Revert totalTime nudge that caused black first frames in from() tweens. Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup. * ci: trigger regression run * ci: trigger regression run * feat(studio): design panel integration, timeline polish, feature flag * fix(studio): rotation-aware drag + auto-keyframing for resize and rotation U1: stripGsapTranslateFromTransform now rotates the offset vector by the element's CSS rotation angle before subtracting from m41/m42. Fixes elements drifting from cursor during drag when rotated. U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the runtime bridge. Resize and rotation handle changes now create keyframes via the same async pipeline as position drag. CSS path guards prevent double-persistence for GSAP-animated elements. * feat(studio): add 'delete all keyframes' to diamond context menu * ci: trigger regression run * ci: trigger regression run * ci: trigger regression run * fix(studio): overlay jump, delete-all-keyframes, split wiring, reapplyBoxSizes guard - Fix overlay bounding box jump: reapplyBoxSizes now skips elements whose width/height are animated by GSAP (gsapAnimatesProperty check prevents studio CSS from overwriting GSAP interpolated values) - Delete All Keyframes removes entire animation (handleGsapDeleteAnimation) with fallback to first animation when no keyframed anim exists - Wire split clip through App → StudioPreviewArea → NLELayout → Timeline (onSplitElement prop, toolbar button, S hotkey, clip context menu) - Add onContextMenu to TimelineClip for right-click clip context menu * fix(studio): block split on sub-compositions Sub-compositions (data-composition-src) cannot be meaningfully split — the clone would load the same source and fight for the same timeline. Block with a specific toast message and disable in the context menu. * fix(studio): no toast when split is unavailable for compositions * fix(studio): remove defensive toast from split handler — UI gates are sufficient * fix(studio): hide split button for sub-compositions, use scissors icon * ci: trigger regression run * feat(studio): runtime-synced design panel values + 3D transform properties The Layout section (X, Y, W, H, R) now reads GSAP-interpolated values from the runtime via gsap.getProperty() at the current seek time. When an element has GSAP animations, the fields reflect the actual interpolated position/size/rotation instead of the CSS defaults. Also adds 3D transform properties to SUPPORTED_PROPS: z, rotationX, rotationY, rotationZ, perspective, transformOrigin. * fix(studio): read ALL animated properties from runtime, not just hardcoded 7 * fix(studio): stronger clip selection border + wider keyframe playhead tolerance - Selected clip: full accent border (was 38% opacity), subtle glow shadow - Keyframe diamond at playhead: tolerance 0.5% (was 0.05% — too tight at high zoom levels, causing diamonds to never highlight) * fix(studio): sync DOM selection to timeline selectedElementId on cold load * fix(studio): use Phosphor Scissors icon for split button * fix(studio): restrict split to media elements only (video, audio, img) * feat(studio): unified commitAnimatedProperty for all GSAP property edits Extract useAnimatedPropertyCommit hook that handles the three-case commit logic: keyframed → add-keyframe, flat → convert + add, no animation → create + convert + add. Wire Z, Scale, RotX, RotY design panel fields and 2D Layout fields through this unified pipeline. Export readAllAnimatedProperties and readGsapProperty from the runtime bridge so the commit helper can read all animated props for backfill. * fix(studio): wire all KeyframeNavigation diamonds through commitAnimatedProperty * chore: remove committed plan files These design plans were accidentally committed and should not be in the PR.
This commit is contained in:
@@ -49,6 +49,7 @@ export interface StudioPreviewAreaProps {
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
setCompIdToSrc: (map: Map<string, string>) => void;
|
||||
setCompositionLoading: (loading: boolean) => void;
|
||||
shouldShowSelectedDomBounds: boolean;
|
||||
@@ -67,6 +68,7 @@ export function StudioPreviewArea({
|
||||
handleTimelineElementMove,
|
||||
handleTimelineElementResize,
|
||||
handleBlockedTimelineEdit,
|
||||
handleTimelineElementSplit,
|
||||
setCompIdToSrc,
|
||||
setCompositionLoading,
|
||||
shouldShowSelectedDomBounds,
|
||||
@@ -107,7 +109,7 @@ export function StudioPreviewArea({
|
||||
handleGsapUpdateMeta,
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleGsapDeleteAnimation,
|
||||
} = useDomEditContext();
|
||||
|
||||
return (
|
||||
@@ -127,10 +129,12 @@ export function StudioPreviewArea({
|
||||
onMoveElement={handleTimelineElementMove}
|
||||
onResizeElement={handleTimelineElementResize}
|
||||
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
||||
onSplitElement={handleTimelineElementSplit}
|
||||
onSelectTimelineElement={handleTimelineElementSelect}
|
||||
onDeleteAllKeyframes={(_elId) => {
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
if (anim) handleGsapRemoveAllKeyframes(anim.id);
|
||||
const anim =
|
||||
selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
|
||||
if (anim) handleGsapDeleteAnimation(anim.id);
|
||||
}}
|
||||
onDeleteKeyframe={(_elId, pct) => {
|
||||
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
||||
|
||||
@@ -91,6 +91,7 @@ export function StudioRightPanel({
|
||||
handleGsapUpdateFromProperty,
|
||||
handleGsapAddFromProperty,
|
||||
handleGsapRemoveFromProperty,
|
||||
commitAnimatedProperty,
|
||||
} = useDomEditContext();
|
||||
|
||||
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
|
||||
@@ -211,6 +212,7 @@ export function StudioRightPanel({
|
||||
onImportAssets={handleImportFiles}
|
||||
fontAssets={fontAssets}
|
||||
onImportFonts={handleImportFonts}
|
||||
previewIframeRef={previewIframeRef}
|
||||
gsapAnimations={selectedGsapAnimations}
|
||||
gsapMultipleTimelines={gsapMultipleTimelines}
|
||||
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
|
||||
@@ -223,6 +225,7 @@ export function StudioRightPanel({
|
||||
onAddGsapFromProperty={handleGsapAddFromProperty}
|
||||
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
|
||||
onAddGsapAnimation={handleGsapAddAnimation}
|
||||
onCommitAnimatedProperty={commitAnimatedProperty}
|
||||
/>
|
||||
) : motionPanelActive ? (
|
||||
<MotionPanel
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
|
||||
import { usePlayerStore, type TimelineElement } from "../player";
|
||||
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
|
||||
import { Tooltip } from "./ui";
|
||||
import { Scissors } from "../icons/SystemIcons";
|
||||
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "./editor/domEditingTypes";
|
||||
|
||||
@@ -218,38 +219,35 @@ export function TimelineToolbar({
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onSplitElement && (
|
||||
<Tooltip label="Split clip at playhead (S)">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
||||
if (!selectedElementId) return;
|
||||
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
|
||||
if (el && currentTime > el.start && currentTime < el.start + el.duration) {
|
||||
onSplitElement(el, currentTime);
|
||||
}
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-neutral-500 transition-colors hover:text-neutral-200"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="8" y1="2" x2="8" y2="14" />
|
||||
<polyline points="5,5 3,2" />
|
||||
<polyline points="11,5 13,2" />
|
||||
<polyline points="5,11 3,14" />
|
||||
<polyline points="11,11 13,14" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onSplitElement &&
|
||||
(() => {
|
||||
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
||||
const el = selectedElementId
|
||||
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
|
||||
: null;
|
||||
const splittable =
|
||||
el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
|
||||
if (!splittable) return null;
|
||||
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
|
||||
return (
|
||||
<Tooltip label="Split clip at playhead (S)">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSplit}
|
||||
onClick={() => {
|
||||
if (canSplit) onSplitElement(el, currentTime);
|
||||
}}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
canSplit
|
||||
? "text-neutral-500 hover:text-neutral-200"
|
||||
: "text-neutral-700 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Scissors size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip label="Fit timeline to width">
|
||||
|
||||
@@ -52,6 +52,7 @@ interface PropertyPanelProps {
|
||||
onImportAssets?: (files: FileList) => Promise<string[]>;
|
||||
fontAssets?: ImportedFontAsset[];
|
||||
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
||||
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
||||
gsapAnimations?: import("@hyperframes/core/gsap-parser").GsapAnimation[];
|
||||
gsapMultipleTimelines?: boolean;
|
||||
gsapUnsupportedTimelinePattern?: boolean;
|
||||
@@ -75,6 +76,11 @@ interface PropertyPanelProps {
|
||||
) => void;
|
||||
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
||||
onConvertToKeyframes?: (animationId: string) => void;
|
||||
onCommitAnimatedProperty?: (
|
||||
selection: DomEditSelection,
|
||||
property: string,
|
||||
value: number | string,
|
||||
) => Promise<void>;
|
||||
onSeekToTime?: (time: number) => void;
|
||||
}
|
||||
|
||||
@@ -169,6 +175,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onImportAssets,
|
||||
fontAssets = [],
|
||||
onImportFonts,
|
||||
previewIframeRef,
|
||||
gsapAnimations = [],
|
||||
gsapMultipleTimelines,
|
||||
gsapUnsupportedTimelinePattern,
|
||||
@@ -184,6 +191,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
onAddKeyframe,
|
||||
onRemoveKeyframe,
|
||||
onConvertToKeyframes,
|
||||
onCommitAnimatedProperty,
|
||||
onSeekToTime,
|
||||
}: PropertyPanelProps) {
|
||||
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
||||
@@ -238,6 +246,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
|
||||
const parsed = parsePxMetricValue(nextValue);
|
||||
if (parsed == null) return;
|
||||
if (onCommitAnimatedProperty && (gsapAnimId || gsapAnimations.length > 0)) {
|
||||
void onCommitAnimatedProperty(element, axis, parsed);
|
||||
return;
|
||||
}
|
||||
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(currentPct * 10) / 10));
|
||||
onAddKeyframe(gsapAnimId, pct, axis, parsed);
|
||||
@@ -286,6 +298,49 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
const gsapAnimId =
|
||||
gsapAnimations?.find((a) => a.keyframes)?.id ?? gsapAnimations?.[0]?.id ?? null;
|
||||
|
||||
// Read ALL GSAP-interpolated values at the current seek time.
|
||||
// Discovers animated properties from the animation's keyframes/tween vars.
|
||||
const gsapRuntimeValues: Record<string, number> | null = (() => {
|
||||
if (!gsapAnimId || gsapAnimations.length === 0) return null;
|
||||
const iframe = previewIframeRef?.current;
|
||||
if (!iframe?.contentWindow) return null;
|
||||
const selector = element.id ? `#${element.id}` : element.selector;
|
||||
if (!selector) return null;
|
||||
try {
|
||||
const gsap = (
|
||||
iframe.contentWindow as unknown as {
|
||||
gsap?: { getProperty: (el: Element, prop: string) => number | string };
|
||||
}
|
||||
).gsap;
|
||||
if (!gsap?.getProperty) return null;
|
||||
const el = iframe.contentDocument?.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const propKeys = new Set<string>();
|
||||
for (const anim of gsapAnimations) {
|
||||
if (anim.keyframes) {
|
||||
for (const kf of anim.keyframes.keyframes) {
|
||||
for (const p of Object.keys(kf.properties)) propKeys.add(p);
|
||||
}
|
||||
}
|
||||
for (const p of Object.keys(anim.properties)) propKeys.add(p);
|
||||
}
|
||||
const result: Record<string, number> = {};
|
||||
for (const prop of propKeys) {
|
||||
const v = Number(gsap.getProperty(el, prop));
|
||||
if (Number.isFinite(v)) result[prop] = Math.round(v * 100) / 100;
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
|
||||
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
|
||||
const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
|
||||
const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
|
||||
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
|
||||
<div className="border-b border-neutral-800 px-4 py-5">
|
||||
@@ -351,7 +406,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="X"
|
||||
value={formatPxMetricValue(manualOffset.x)}
|
||||
value={formatPxMetricValue(displayX)}
|
||||
disabled={manualOffsetEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualOffset("x", next)}
|
||||
@@ -363,7 +418,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "x", manualOffset.x)}
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "x", displayX)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
|
||||
/>
|
||||
@@ -373,7 +431,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="Y"
|
||||
value={formatPxMetricValue(manualOffset.y)}
|
||||
value={formatPxMetricValue(displayY)}
|
||||
disabled={manualOffsetEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualOffset("y", next)}
|
||||
@@ -385,7 +443,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "y", manualOffset.y)}
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "y", displayY)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
|
||||
/>
|
||||
@@ -395,7 +456,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="W"
|
||||
value={formatPxMetricValue(resolvedWidth)}
|
||||
value={formatPxMetricValue(displayW)}
|
||||
disabled={manualSizeEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualSize("width", next)}
|
||||
@@ -407,7 +468,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={(pct) => onAddKeyframe?.(gsapAnimId, pct, "width", resolvedWidth)}
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "width", displayW)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
|
||||
/>
|
||||
@@ -417,7 +481,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="H"
|
||||
value={formatPxMetricValue(resolvedHeight)}
|
||||
value={formatPxMetricValue(displayH)}
|
||||
disabled={manualSizeEditingDisabled}
|
||||
scrub
|
||||
onCommit={(next) => commitManualSize("height", next)}
|
||||
@@ -429,8 +493,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={(pct) =>
|
||||
onAddKeyframe?.(gsapAnimId, pct, "height", resolvedHeight)
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "height", displayH)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
|
||||
@@ -441,7 +506,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="R"
|
||||
value={`${manualRotation.angle}°`}
|
||||
value={`${displayR}°`}
|
||||
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
|
||||
/>
|
||||
</div>
|
||||
@@ -451,8 +516,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={(pct) =>
|
||||
onAddKeyframe?.(gsapAnimId, pct, "rotation", manualRotation.angle)
|
||||
onAddKeyframe={() =>
|
||||
onCommitAnimatedProperty &&
|
||||
void onCommitAnimatedProperty(element, "rotation", displayR)
|
||||
}
|
||||
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
|
||||
@@ -460,7 +526,80 @@ export const PropertyPanel = memo(function PropertyPanel({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{gsapRuntimeValues && (
|
||||
<div className="mt-3 border-t border-neutral-800/40 pt-3">
|
||||
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
|
||||
3D Transform
|
||||
</div>
|
||||
<div className={RESPONSIVE_GRID}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<MetricField
|
||||
label="Z"
|
||||
value={formatPxMetricValue(gsapRuntimeValues.z ?? 0)}
|
||||
scrub
|
||||
onCommit={(next) => {
|
||||
const v = parsePxMetricValue(next);
|
||||
if (v != null && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "z", v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
|
||||
<KeyframeNavigation
|
||||
property="z"
|
||||
keyframes={gsapKeyframes}
|
||||
currentPercentage={currentPct}
|
||||
onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
|
||||
onAddKeyframe={() => {
|
||||
if (onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
|
||||
}
|
||||
}}
|
||||
onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
|
||||
onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MetricField
|
||||
label="Scale"
|
||||
value={String(gsapRuntimeValues.scale ?? 1)}
|
||||
scrub
|
||||
onCommit={(next) => {
|
||||
const v = Number.parseFloat(next);
|
||||
if (Number.isFinite(v) && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "scale", v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MetricField
|
||||
label="RotX"
|
||||
value={`${gsapRuntimeValues.rotationX ?? 0}°`}
|
||||
onCommit={(next) => {
|
||||
const v = Number.parseFloat(next.replace("°", ""));
|
||||
if (Number.isFinite(v) && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "rotationX", v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<MetricField
|
||||
label="RotY"
|
||||
value={`${gsapRuntimeValues.rotationY ?? 0}°`}
|
||||
onCommit={(next) => {
|
||||
const v = Number.parseFloat(next.replace("°", ""));
|
||||
if (Number.isFinite(v) && onCommitAnimatedProperty) {
|
||||
void onCommitAnimatedProperty(element, "rotationY", v);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
|
||||
Stacking
|
||||
</div>
|
||||
<MetricField
|
||||
label="Z-index"
|
||||
value={String(parseInt(styles["z-index"] || "auto", 10) || 0)}
|
||||
|
||||
@@ -516,8 +516,58 @@ function reapplyPathOffsets(doc: Document): void {
|
||||
}
|
||||
}
|
||||
|
||||
function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
|
||||
const win = el.ownerDocument.defaultView as
|
||||
| (Window & {
|
||||
__timelines?: Record<
|
||||
string,
|
||||
{
|
||||
getChildren?: (
|
||||
deep: boolean,
|
||||
) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
|
||||
}
|
||||
>;
|
||||
})
|
||||
| null;
|
||||
if (!win?.__timelines) return false;
|
||||
const propSet = new Set(props);
|
||||
for (const tl of Object.values(win.__timelines)) {
|
||||
if (!tl?.getChildren) continue;
|
||||
try {
|
||||
for (const child of tl.getChildren(true)) {
|
||||
if (!child.targets || !child.vars) continue;
|
||||
let targetsEl = false;
|
||||
for (const t of child.targets()) {
|
||||
if (t === el || (el.id && t.id === el.id)) {
|
||||
targetsEl = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!targetsEl) continue;
|
||||
const vars = child.vars;
|
||||
for (const p of propSet) {
|
||||
if (p in vars) return true;
|
||||
}
|
||||
if (vars.keyframes && typeof vars.keyframes === "object") {
|
||||
for (const kfVal of Object.values(vars.keyframes as Record<string, unknown>)) {
|
||||
if (kfVal && typeof kfVal === "object") {
|
||||
for (const p of propSet) {
|
||||
if (p in (kfVal as Record<string, unknown>)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function reapplyBoxSizes(doc: Document): void {
|
||||
for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
|
||||
if (gsapAnimatesProperty(el, "width", "height")) continue;
|
||||
const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
|
||||
const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
|
||||
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
||||
|
||||
@@ -69,6 +69,7 @@ interface NLELayoutProps {
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onSelectTimelineElement?: (element: TimelineElement | null) => void;
|
||||
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onDeleteAllKeyframes?: (elementId: string) => void;
|
||||
@@ -122,6 +123,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
onSplitElement,
|
||||
onSelectTimelineElement,
|
||||
onDeleteKeyframe,
|
||||
onDeleteAllKeyframes,
|
||||
@@ -457,6 +459,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
onMoveElement={onMoveElement}
|
||||
onResizeElement={onResizeElement}
|
||||
onBlockedEditAttempt={onBlockedEditAttempt}
|
||||
onSplitElement={onSplitElement}
|
||||
onSelectElement={onSelectTimelineElement}
|
||||
onDeleteKeyframe={onDeleteKeyframe}
|
||||
onDeleteAllKeyframes={onDeleteAllKeyframes}
|
||||
|
||||
Reference in New Issue
Block a user