mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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:
@@ -286,6 +286,7 @@ export function StudioApp() {
|
||||
const appHotkeys = useAppHotkeys({
|
||||
toggleTimelineVisibility,
|
||||
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
|
||||
handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit,
|
||||
handleDomEditElementDelete: domEditDeleteBridge,
|
||||
domEditSelectionRef: domEditSelectionBridgeRef,
|
||||
clearDomSelectionRef,
|
||||
@@ -489,6 +490,7 @@ export function StudioApp() {
|
||||
<TimelineToolbar
|
||||
toggleTimelineVisibility={toggleTimelineVisibility}
|
||||
domEditSession={domEditSession}
|
||||
onSplitElement={timelineEditing.handleTimelineElementSplit}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
@@ -532,6 +534,7 @@ export function StudioApp() {
|
||||
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
|
||||
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
||||
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
||||
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
||||
setCompIdToSrc={setCompIdToSrc}
|
||||
setCompositionLoading={setCompositionLoading}
|
||||
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -68,9 +68,9 @@ export function DomEditProvider({
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapMaterializeKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
commitAnimatedProperty,
|
||||
invalidateGsapCache,
|
||||
previewIframeRef,
|
||||
},
|
||||
@@ -136,9 +136,9 @@ export function DomEditProvider({
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapMaterializeKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
commitAnimatedProperty,
|
||||
invalidateGsapCache,
|
||||
previewIframeRef,
|
||||
}),
|
||||
@@ -198,9 +198,9 @@ export function DomEditProvider({
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapMaterializeKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
commitAnimatedProperty,
|
||||
invalidateGsapCache,
|
||||
previewIframeRef,
|
||||
],
|
||||
|
||||
@@ -383,7 +383,7 @@ async function commitFromToPosition(
|
||||
|
||||
// ── Runtime property reader ───────────────────────────────────────────────
|
||||
|
||||
function readGsapProperty(
|
||||
export function readGsapProperty(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
selector: string | null,
|
||||
prop: string,
|
||||
@@ -401,7 +401,7 @@ function readGsapProperty(
|
||||
}
|
||||
}
|
||||
|
||||
function readAllAnimatedProperties(
|
||||
export function readAllAnimatedProperties(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
selector: string,
|
||||
anim: GsapAnimation,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Unified helper for committing any GSAP property value from the design panel.
|
||||
*
|
||||
* Handles three cases:
|
||||
* 1. Animation with keyframes → add-keyframe at current percentage
|
||||
* 2. Flat animation (no keyframes) → convert to keyframes, then add-keyframe
|
||||
* 3. No animation → create tl.to(), convert to keyframes, then add-keyframe
|
||||
*/
|
||||
import { useCallback } from "react";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
|
||||
|
||||
interface CommitAnimatedPropertyDeps {
|
||||
selectedGsapAnimations: GsapAnimation[];
|
||||
gsapCommitMutation:
|
||||
| ((
|
||||
selection: DomEditSelection,
|
||||
mutation: Record<string, unknown>,
|
||||
options: {
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
softReload?: boolean;
|
||||
skipReload?: boolean;
|
||||
},
|
||||
) => Promise<void>)
|
||||
| null;
|
||||
addGsapAnimation: (
|
||||
selection: DomEditSelection,
|
||||
method: "to" | "from" | "set" | "fromTo",
|
||||
currentTime?: number,
|
||||
) => void;
|
||||
convertToKeyframes: (selection: DomEditSelection, animId: string) => void;
|
||||
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
bumpGsapCache: () => void;
|
||||
}
|
||||
|
||||
function computePercentage(selection: DomEditSelection): number {
|
||||
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
|
||||
const currentTime = usePlayerStore.getState().currentTime;
|
||||
return elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
}
|
||||
|
||||
function selectorFor(selection: DomEditSelection): string | null {
|
||||
if (selection.id) return `#${selection.id}`;
|
||||
if (selection.selector) return selection.selector;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
||||
const {
|
||||
selectedGsapAnimations,
|
||||
gsapCommitMutation,
|
||||
addGsapAnimation,
|
||||
previewIframeRef,
|
||||
bumpGsapCache,
|
||||
} = deps;
|
||||
|
||||
const commitAnimatedProperty = useCallback(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
property: string,
|
||||
value: number | string,
|
||||
): Promise<void> => {
|
||||
if (!gsapCommitMutation) return;
|
||||
|
||||
const iframe = previewIframeRef.current;
|
||||
const selector = selectorFor(selection);
|
||||
const pct = computePercentage(selection);
|
||||
|
||||
let anim: GsapAnimation | undefined =
|
||||
selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
|
||||
|
||||
// Case 3: No animation — create one first
|
||||
if (!anim) {
|
||||
addGsapAnimation(selection, "to");
|
||||
// The addGsapAnimation triggers a reload. We need to wait for the cache
|
||||
// to update. Use a small delay then bump cache to re-fetch.
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
bumpGsapCache();
|
||||
// After creation, we can't proceed in this call — the animation isn't
|
||||
// in our local state yet. The user's next edit will find it.
|
||||
// For immediate feedback, trigger a convert-to-keyframes on the new animation.
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: Flat animation — convert to keyframes first
|
||||
if (!anim.keyframes) {
|
||||
await gsapCommitMutation(
|
||||
selection,
|
||||
{ type: "convert-to-keyframes", animationId: anim.id },
|
||||
{ label: "Convert to keyframes", skipReload: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Read all currently animated properties from runtime for backfill
|
||||
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
||||
|
||||
// Build the properties object: all runtime props + the new value
|
||||
const properties: Record<string, number | string> = { ...runtimeProps };
|
||||
properties[property] = value;
|
||||
|
||||
// Compute backfill defaults for properties not in existing keyframes
|
||||
const backfillDefaults: Record<string, number | string> = { ...runtimeProps };
|
||||
if (!(property in runtimeProps) && selector) {
|
||||
const cssVal = readGsapProperty(iframe, selector, property);
|
||||
if (cssVal != null) backfillDefaults[property] = cssVal;
|
||||
}
|
||||
backfillDefaults[property] = typeof value === "number" ? value : value;
|
||||
|
||||
await gsapCommitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe",
|
||||
animationId: anim.id,
|
||||
percentage: pct,
|
||||
properties,
|
||||
backfillDefaults,
|
||||
},
|
||||
{ label: `Edit ${property} (keyframe ${pct}%)`, softReload: true },
|
||||
);
|
||||
},
|
||||
[selectedGsapAnimations, gsapCommitMutation, addGsapAnimation, previewIframeRef, bumpGsapCache],
|
||||
);
|
||||
|
||||
return commitAnimatedProperty;
|
||||
}
|
||||
@@ -62,6 +62,7 @@ interface EditHistoryHandle {
|
||||
interface UseAppHotkeysParams {
|
||||
toggleTimelineVisibility: () => void;
|
||||
handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
|
||||
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
|
||||
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
clearDomSelectionRef: React.MutableRefObject<() => void>;
|
||||
@@ -87,6 +88,7 @@ interface UseAppHotkeysParams {
|
||||
export function useAppHotkeys({
|
||||
toggleTimelineVisibility,
|
||||
handleTimelineElementDelete,
|
||||
handleTimelineElementSplit,
|
||||
handleDomEditElementDelete,
|
||||
domEditSelectionRef,
|
||||
editHistory,
|
||||
@@ -195,6 +197,8 @@ export function useAppHotkeys({
|
||||
handleToggleRef.current = handleTimelineToggleHotkey;
|
||||
const handleDeleteRef = useRef(handleTimelineElementDelete);
|
||||
handleDeleteRef.current = handleTimelineElementDelete;
|
||||
const handleSplitRef = useRef(handleTimelineElementSplit);
|
||||
handleSplitRef.current = handleTimelineElementSplit;
|
||||
const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
|
||||
handleDomEditDeleteRef.current = handleDomEditElementDelete;
|
||||
const handleUndoRef = useRef(handleUndo);
|
||||
@@ -306,6 +310,30 @@ export function useAppHotkeys({
|
||||
return;
|
||||
}
|
||||
|
||||
// S — split selected clip at playhead
|
||||
if (
|
||||
event.key === "s" &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!isEditableTarget(event.target)
|
||||
) {
|
||||
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
||||
if (selectedElementId) {
|
||||
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
||||
if (
|
||||
element &&
|
||||
["video", "audio", "img"].includes(element.tag) &&
|
||||
currentTime > element.start &&
|
||||
currentTime < element.start + element.duration
|
||||
) {
|
||||
event.preventDefault();
|
||||
void handleSplitRef.current(element, currentTime);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
|
||||
if (
|
||||
(event.key === "Delete" || event.key === "Backspace") &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { usePlayerStore } from "../player";
|
||||
import {
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||
STUDIO_GSAP_PANEL_ENABLED,
|
||||
@@ -27,8 +28,8 @@ import {
|
||||
tryGsapDragIntercept,
|
||||
tryGsapResizeIntercept,
|
||||
tryGsapRotationIntercept,
|
||||
readRuntimeKeyframes,
|
||||
} from "./gsapRuntimeBridge";
|
||||
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -206,6 +207,18 @@ export function useDomEditSession({
|
||||
onClickToSource,
|
||||
});
|
||||
|
||||
// Sync DOM selection → timeline selectedElementId so that clip selection
|
||||
// highlights and diamond playhead fills work on cold-load URL restore.
|
||||
useEffect(() => {
|
||||
if (!domEditSelection?.id) return;
|
||||
const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState();
|
||||
const matchKey = elements.find(
|
||||
(el) => el.domId === domEditSelection.id || el.id === domEditSelection.id,
|
||||
);
|
||||
const key = matchKey ? (matchKey.key ?? matchKey.id) : null;
|
||||
if (key && key !== selectedElementId) setSelectedElementId(key);
|
||||
}, [domEditSelection?.id]);
|
||||
|
||||
// ── GSAP script editing ──
|
||||
|
||||
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
|
||||
@@ -216,7 +229,6 @@ export function useDomEditSession({
|
||||
STUDIO_GSAP_PANEL_ENABLED ? (projectId ?? null) : null,
|
||||
gsapSourceFile,
|
||||
gsapCacheVersion,
|
||||
previewIframeRef,
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -230,7 +242,6 @@ export function useDomEditSession({
|
||||
? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
|
||||
: null,
|
||||
gsapCacheVersion,
|
||||
previewIframeRef,
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -494,49 +505,6 @@ export function useDomEditSession({
|
||||
[domEditSelection, convertToKeyframes],
|
||||
);
|
||||
|
||||
const handleGsapMaterializeKeyframes = useCallback(
|
||||
async (animId: string) => {
|
||||
if (!domEditSelection || !gsapCommitMutation) return;
|
||||
const anim = selectedGsapAnimations.find((a) => a.id === animId);
|
||||
if (!anim || (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) || !anim.keyframes)
|
||||
return;
|
||||
if (anim.hasUnresolvedSelector) {
|
||||
const { scanAllRuntimeKeyframes } = await import("./gsapRuntimeKeyframes");
|
||||
const allScanned = scanAllRuntimeKeyframes(previewIframeRef.current);
|
||||
if (allScanned.size === 0) return;
|
||||
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
|
||||
selector: `#${id}`,
|
||||
keyframes: data.keyframes,
|
||||
easeEach: data.easeEach,
|
||||
}));
|
||||
await gsapCommitMutation(
|
||||
domEditSelection,
|
||||
{
|
||||
type: "materialize-keyframes",
|
||||
animationId: animId,
|
||||
keyframes: allScanned.get(domEditSelection.id ?? "")?.keyframes ?? [],
|
||||
allElements,
|
||||
},
|
||||
{ label: "Unroll dynamic animations", skipReload: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
const runtime = readRuntimeKeyframes(previewIframeRef.current, anim.targetSelector);
|
||||
if (!runtime || runtime.keyframes.length === 0) return;
|
||||
await gsapCommitMutation(
|
||||
domEditSelection,
|
||||
{
|
||||
type: "materialize-keyframes",
|
||||
animationId: animId,
|
||||
keyframes: runtime.keyframes,
|
||||
easeEach: runtime.easeEach,
|
||||
},
|
||||
{ label: "Materialize dynamic keyframes", skipReload: true },
|
||||
);
|
||||
},
|
||||
[domEditSelection, selectedGsapAnimations, gsapCommitMutation, previewIframeRef],
|
||||
);
|
||||
|
||||
const handleGsapRemoveAllKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
@@ -559,6 +527,15 @@ export function useDomEditSession({
|
||||
return true;
|
||||
}, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
|
||||
|
||||
const commitAnimatedProperty = useAnimatedPropertyCommit({
|
||||
selectedGsapAnimations,
|
||||
gsapCommitMutation,
|
||||
addGsapAnimation: (sel, method, time) => addGsapAnimation(sel, method, time),
|
||||
convertToKeyframes: (sel, animId) => convertToKeyframes(sel, animId),
|
||||
previewIframeRef,
|
||||
bumpGsapCache,
|
||||
});
|
||||
|
||||
// Sync selection from preview document on load / refresh
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
@@ -702,9 +679,9 @@ export function useDomEditSession({
|
||||
handleGsapAddKeyframe,
|
||||
handleGsapRemoveKeyframe,
|
||||
handleGsapConvertToKeyframes,
|
||||
handleGsapMaterializeKeyframes,
|
||||
handleGsapRemoveAllKeyframes,
|
||||
handleResetSelectedElementKeyframes,
|
||||
commitAnimatedProperty,
|
||||
invalidateGsapCache: bumpGsapCache,
|
||||
previewIframeRef,
|
||||
};
|
||||
|
||||
@@ -471,13 +471,15 @@ export function useTimelineEditing({
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
|
||||
const splittableTags = new Set(["video", "audio", "img"]);
|
||||
if (
|
||||
element.timelineLocked ||
|
||||
element.timingSource === "implicit" ||
|
||||
element.compositionSrc ||
|
||||
!splittableTags.has(element.tag) ||
|
||||
!element.duration ||
|
||||
!Number.isFinite(element.duration)
|
||||
) {
|
||||
showToast("This clip cannot be split.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Camera as PhCamera,
|
||||
ArrowClockwise,
|
||||
Gear,
|
||||
Scissors as PhScissors,
|
||||
} from "@phosphor-icons/react";
|
||||
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
||||
|
||||
@@ -55,3 +56,4 @@ export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
||||
export const Camera = makeIcon(PhCamera);
|
||||
export const RotateCw = makeIcon(ArrowClockwise);
|
||||
export const Settings = makeIcon(Gear);
|
||||
export const Scissors = makeIcon(PhScissors);
|
||||
|
||||
@@ -43,11 +43,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
||||
const adjustedX = Math.min(x, window.innerWidth - 200);
|
||||
const adjustedY = Math.min(y, window.innerHeight - 200);
|
||||
|
||||
const canSplit = currentTime > element.start && currentTime < element.start + element.duration;
|
||||
const isSplittable = ["video", "audio", "img"].includes(element.tag);
|
||||
const canSplit =
|
||||
isSplittable && currentTime > element.start && currentTime < element.start + element.duration;
|
||||
|
||||
const splitLabel = canSplit
|
||||
? `Split at ${currentTime.toFixed(2)}s`
|
||||
: "Split (move playhead inside clip)";
|
||||
const splitLabel = !isSplittable
|
||||
? null
|
||||
: canSplit
|
||||
? `Split at ${currentTime.toFixed(2)}s`
|
||||
: "Split (move playhead inside clip)";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -55,26 +59,29 @@ export const ClipContextMenu = memo(function ClipContextMenu({
|
||||
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
|
||||
style={{ left: adjustedX, top: adjustedY }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
|
||||
canSplit
|
||||
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
||||
: "text-neutral-600 cursor-not-allowed"
|
||||
}`}
|
||||
disabled={!canSplit}
|
||||
onClick={() => {
|
||||
if (canSplit) {
|
||||
onSplit(element, currentTime);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{splitLabel}</span>
|
||||
<span className="text-neutral-500 text-[10px] ml-3">S</span>
|
||||
</button>
|
||||
|
||||
<div className="my-1 border-t border-neutral-700/60" />
|
||||
{splitLabel && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
|
||||
canSplit
|
||||
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
|
||||
: "text-neutral-600 cursor-not-allowed"
|
||||
}`}
|
||||
disabled={!canSplit}
|
||||
onClick={() => {
|
||||
if (canSplit) {
|
||||
onSplit(element, currentTime);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{splitLabel}</span>
|
||||
<span className="text-neutral-500 text-[10px] ml-3">S</span>
|
||||
</button>
|
||||
<div className="my-1 border-t border-neutral-700/60" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type KeyframeDiamondContextMenuState,
|
||||
} from "./KeyframeDiamondContextMenu";
|
||||
import { useTimelineClipDrag } from "./useTimelineClipDrag";
|
||||
import { ClipContextMenu } from "./ClipContextMenu";
|
||||
import {
|
||||
GUTTER,
|
||||
TRACK_H,
|
||||
@@ -70,6 +71,7 @@ interface TimelineProps {
|
||||
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||
) => Promise<void> | void;
|
||||
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
||||
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
||||
onSelectElement?: (element: TimelineElement | null) => void;
|
||||
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onDeleteAllKeyframes?: (elementId: string) => void;
|
||||
@@ -91,6 +93,7 @@ export const Timeline = memo(function Timeline({
|
||||
onMoveElement,
|
||||
onResizeElement,
|
||||
onBlockedEditAttempt,
|
||||
onSplitElement,
|
||||
onSelectElement,
|
||||
onDeleteKeyframe,
|
||||
onDeleteAllKeyframes,
|
||||
@@ -135,6 +138,11 @@ export const Timeline = memo(function Timeline({
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
||||
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
|
||||
const [clipContextMenu, setClipContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
element: TimelineElement;
|
||||
} | null>(null);
|
||||
const [viewportWidth, setViewportWidth] = useState(0);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const shortcutHintRafRef = useRef(0);
|
||||
@@ -532,6 +540,12 @@ export const Timeline = memo(function Timeline({
|
||||
currentEase: kf?.ease ?? kfData?.ease,
|
||||
});
|
||||
}}
|
||||
onContextMenuClip={(e, el) => {
|
||||
e.preventDefault();
|
||||
setSelectedElementId(el.key ?? el.id);
|
||||
onSelectElement?.(el);
|
||||
setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -583,6 +597,18 @@ export const Timeline = memo(function Timeline({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{clipContextMenu && (
|
||||
<ClipContextMenu
|
||||
x={clipContextMenu.x}
|
||||
y={clipContextMenu.y}
|
||||
element={clipContextMenu.element}
|
||||
currentTime={currentTime}
|
||||
onClose={() => setClipContextMenu(null)}
|
||||
onSplit={(el, time) => onSplitElement?.(el, time)}
|
||||
onDelete={(el) => _onDeleteElement?.(el)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ interface TimelineCanvasProps {
|
||||
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
||||
onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
||||
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
||||
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
||||
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
||||
}
|
||||
|
||||
@@ -116,6 +117,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
onShiftClickKeyframe,
|
||||
onDragKeyframe,
|
||||
onContextMenuKeyframe,
|
||||
onContextMenuClip,
|
||||
onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead,
|
||||
}: TimelineCanvasProps) {
|
||||
const draggedElement = draggedClip?.element ?? null;
|
||||
@@ -249,6 +251,10 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
||||
return (
|
||||
<TimelineClip
|
||||
key={clipKey}
|
||||
onContextMenu={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onContextMenuClip?.(e, el);
|
||||
}}
|
||||
el={previewElement}
|
||||
pps={pps}
|
||||
clipY={CLIP_Y}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface TimelineClipProps {
|
||||
onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onDoubleClick: (e: React.MouseEvent) => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
onResizeStart,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
children,
|
||||
}: TimelineClipProps) {
|
||||
const leftPx = el.start * pps;
|
||||
@@ -51,14 +53,14 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
||||
|
||||
const borderColor = isSelected
|
||||
? trackStyle.accent + "60"
|
||||
? trackStyle.accent
|
||||
: isHovered
|
||||
? theme.clipBorderHover
|
||||
: theme.clipBorder;
|
||||
const boxShadow = isDragging
|
||||
? theme.clipShadowDragging
|
||||
: isSelected
|
||||
? `0 0 0 1px ${trackStyle.accent}40`
|
||||
? `0 0 0 1px ${trackStyle.accent}80, 0 0 8px ${trackStyle.accent}25`
|
||||
: isHovered
|
||||
? theme.clipShadowHover
|
||||
: theme.clipShadow;
|
||||
@@ -98,6 +100,7 @@ export const TimelineClip = memo(function TimelineClip({
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{/* Left accent stripe */}
|
||||
<div
|
||||
|
||||
@@ -122,7 +122,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
||||
const leftPx = (kf.percentage / 100) * clipWidthPx - half;
|
||||
const kfKey = `${elementId}:${kf.percentage}`;
|
||||
const isKfSelected = selectedKeyframes.has(kfKey);
|
||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.05;
|
||||
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
||||
const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3";
|
||||
return (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user