@hyperframes/studio 0.6.90 → 0.6.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-DSLrl2tB.js → index-CDy8BuGq.js} +24 -24
- package/dist/assets/index-CmRIkCwI.js +251 -0
- package/dist/assets/index-rm9tn9nH.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2 -0
- package/src/components/StudioPreviewArea.tsx +54 -13
- package/src/components/TimelineToolbar.tsx +52 -35
- package/src/components/editor/DomEditOverlay.tsx +79 -0
- package/src/components/editor/PropertyPanel.tsx +19 -10
- package/src/components/editor/gsapAnimatesProperty.ts +30 -0
- package/src/components/editor/manualEditingAvailability.test.ts +12 -0
- package/src/components/editor/manualEditingAvailability.ts +16 -0
- package/src/components/editor/manualEditsDom.ts +25 -5
- package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
- package/src/components/editor/manualEditsDomPatches.ts +17 -1
- package/src/components/editor/manualEditsSnapshot.ts +16 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
- package/src/components/editor/useOffScreenIndicators.ts +197 -0
- package/src/components/nle/NLELayout.tsx +22 -32
- package/src/components/nle/TimelineEditorNotice.tsx +2 -25
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +119 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
- package/src/hooks/gsapRuntimeBridge.ts +266 -41
- package/src/hooks/gsapRuntimeReaders.ts +16 -2
- package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
- package/src/hooks/useAppHotkeys.ts +48 -1
- package/src/hooks/useContextMenuDismiss.ts +29 -0
- package/src/hooks/useDomEditCommits.ts +7 -1
- package/src/hooks/useDomEditSession.ts +20 -4
- package/src/hooks/useEnableKeyframes.ts +3 -1
- package/src/hooks/useGestureCommit.ts +99 -13
- package/src/hooks/useGestureRecording.ts +18 -2
- package/src/hooks/useGsapScriptCommits.ts +24 -3
- package/src/hooks/useGsapSelectionHandlers.ts +19 -3
- package/src/hooks/useGsapTweenCache.ts +30 -10
- package/src/hooks/useRazorSplit.ts +298 -0
- package/src/hooks/useTimelineEditing.ts +15 -98
- package/src/player/components/ClipContextMenu.tsx +14 -25
- package/src/player/components/KeyframeDiamondContextMenu.tsx +16 -112
- package/src/player/components/PlayheadIndicator.tsx +43 -0
- package/src/player/components/Timeline.tsx +45 -38
- package/src/player/components/TimelineCanvas.tsx +29 -22
- package/src/player/components/TimelineClipDiamonds.tsx +3 -1
- package/src/player/components/timelineCallbacks.ts +44 -0
- package/src/player/components/timelineDragDrop.ts +2 -14
- package/src/player/components/useTimelineZoom.ts +18 -0
- package/src/player/store/playerStore.ts +20 -0
- package/src/utils/globalTimeCompiler.test.ts +2 -2
- package/src/utils/globalTimeCompiler.ts +2 -1
- package/src/utils/gsapSoftReload.test.ts +16 -0
- package/src/utils/gsapSoftReload.ts +43 -8
- package/src/utils/rdpSimplify.ts +3 -2
- package/src/utils/timelineElementSplit.test.ts +50 -0
- package/src/utils/timelineElementSplit.ts +32 -0
- package/dist/assets/index-BKuDHMYl.js +0 -146
- package/dist/assets/index-D2NkPomd.css +0 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// fallow-ignore-file dead-code
|
|
2
|
+
/**
|
|
3
|
+
* Shared playhead visual used by TimelineCanvas (real playhead) and
|
|
4
|
+
* TimelineEditorNotice (animated illustration).
|
|
5
|
+
*/
|
|
6
|
+
interface PlayheadIndicatorProps {
|
|
7
|
+
/** CSS color, defaults to the HF accent variable */
|
|
8
|
+
color?: string;
|
|
9
|
+
/** Glow shadow color, defaults to translucent accent */
|
|
10
|
+
glowColor?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function PlayheadIndicator({
|
|
14
|
+
color = "var(--hf-accent, #3CE6AC)",
|
|
15
|
+
glowColor = "rgba(60,230,172,0.5)",
|
|
16
|
+
}: PlayheadIndicatorProps) {
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<div
|
|
20
|
+
className="absolute top-0 bottom-0"
|
|
21
|
+
style={{
|
|
22
|
+
left: "50%",
|
|
23
|
+
width: 2,
|
|
24
|
+
marginLeft: -1,
|
|
25
|
+
background: color,
|
|
26
|
+
boxShadow: `0 0 8px ${glowColor}`,
|
|
27
|
+
}}
|
|
28
|
+
/>
|
|
29
|
+
<div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
|
|
30
|
+
<div
|
|
31
|
+
style={{
|
|
32
|
+
width: 0,
|
|
33
|
+
height: 0,
|
|
34
|
+
borderLeft: "6px solid transparent",
|
|
35
|
+
borderRight: "6px solid transparent",
|
|
36
|
+
borderTop: `8px solid ${color}`,
|
|
37
|
+
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
|
38
|
+
}}
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -2,12 +2,12 @@ import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode
|
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
import { EditPopover } from "./EditModal";
|
|
5
|
-
import { type BlockedTimelineEditIntent } from "./timelineEditing";
|
|
6
5
|
import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme";
|
|
7
6
|
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
8
7
|
import { useTimelinePlayhead } from "./useTimelinePlayhead";
|
|
9
8
|
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
|
|
10
9
|
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
10
|
+
import { useTimelineZoom } from "./useTimelineZoom";
|
|
11
11
|
import { useTimelineAssetDrop } from "./timelineDragDrop";
|
|
12
12
|
import { TimelineEmptyState } from "./TimelineEmptyState";
|
|
13
13
|
import { TimelineCanvas } from "./TimelineCanvas";
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
getTimelineCanvasHeight,
|
|
24
24
|
shouldShowTimelineShortcutHint,
|
|
25
25
|
} from "./timelineLayout";
|
|
26
|
+
import type { TimelineEditCallbacks, TimelineDropCallbacks } from "./timelineCallbacks";
|
|
26
27
|
|
|
27
28
|
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
|
|
28
29
|
export {
|
|
@@ -39,7 +40,7 @@ export {
|
|
|
39
40
|
getDefaultDroppedTrack,
|
|
40
41
|
} from "./timelineLayout";
|
|
41
42
|
|
|
42
|
-
interface TimelineProps {
|
|
43
|
+
interface TimelineProps extends TimelineEditCallbacks, TimelineDropCallbacks {
|
|
43
44
|
onSeek?: (time: number) => void;
|
|
44
45
|
onDrillDown?: (element: TimelineElement) => void;
|
|
45
46
|
renderClipContent?: (
|
|
@@ -47,35 +48,8 @@ interface TimelineProps {
|
|
|
47
48
|
style: { clip: string; label: string },
|
|
48
49
|
) => ReactNode;
|
|
49
50
|
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
50
|
-
onFileDrop?: (
|
|
51
|
-
files: File[],
|
|
52
|
-
placement?: { start: number; track: number },
|
|
53
|
-
) => Promise<void> | void;
|
|
54
|
-
onAssetDrop?: (
|
|
55
|
-
assetPath: string,
|
|
56
|
-
placement: { start: number; track: number },
|
|
57
|
-
) => Promise<void> | void;
|
|
58
|
-
onBlockDrop?: (
|
|
59
|
-
blockName: string,
|
|
60
|
-
placement: { start: number; track: number },
|
|
61
|
-
) => Promise<void> | void;
|
|
62
51
|
onDeleteElement?: (element: TimelineElement) => Promise<void> | void;
|
|
63
|
-
onMoveElement?: (
|
|
64
|
-
element: TimelineElement,
|
|
65
|
-
updates: Pick<TimelineElement, "start" | "track">,
|
|
66
|
-
) => Promise<void> | void;
|
|
67
|
-
onResizeElement?: (
|
|
68
|
-
element: TimelineElement,
|
|
69
|
-
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
70
|
-
) => Promise<void> | void;
|
|
71
|
-
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
72
|
-
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
73
52
|
onSelectElement?: (element: TimelineElement | null) => void;
|
|
74
|
-
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
|
75
|
-
onDeleteAllKeyframes?: (elementId: string) => void;
|
|
76
|
-
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
77
|
-
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
78
|
-
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
79
53
|
theme?: Partial<TimelineTheme>;
|
|
80
54
|
}
|
|
81
55
|
|
|
@@ -92,6 +66,8 @@ export const Timeline = memo(function Timeline({
|
|
|
92
66
|
onResizeElement,
|
|
93
67
|
onBlockedEditAttempt,
|
|
94
68
|
onSplitElement,
|
|
69
|
+
onRazorSplit,
|
|
70
|
+
onRazorSplitAll,
|
|
95
71
|
onSelectElement,
|
|
96
72
|
onDeleteKeyframe,
|
|
97
73
|
onDeleteAllKeyframes,
|
|
@@ -107,17 +83,16 @@ export const Timeline = memo(function Timeline({
|
|
|
107
83
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
108
84
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
109
85
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
110
|
-
const zoomMode =
|
|
111
|
-
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
112
|
-
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
113
|
-
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
86
|
+
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
|
|
114
87
|
|
|
115
88
|
const playheadRef = useRef<HTMLDivElement>(null);
|
|
116
89
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
117
90
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
91
|
+
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
118
92
|
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
|
119
93
|
const isDragging = useRef(false);
|
|
120
94
|
const [shiftHeld, setShiftHeld] = useState(false);
|
|
95
|
+
const [razorGuideX, setRazorGuideX] = useState<number | null>(null);
|
|
121
96
|
|
|
122
97
|
useMountEffect(() => {
|
|
123
98
|
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
|
|
@@ -388,7 +363,14 @@ export const Timeline = memo(function Timeline({
|
|
|
388
363
|
<div
|
|
389
364
|
ref={setContainerRef}
|
|
390
365
|
aria-label="Timeline"
|
|
391
|
-
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
366
|
+
className={`relative border-t select-none h-full overflow-hidden ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
367
|
+
onMouseMove={(e) => {
|
|
368
|
+
if (activeTool === "razor" && scrollRef.current) {
|
|
369
|
+
const rect = scrollRef.current.getBoundingClientRect();
|
|
370
|
+
setRazorGuideX(e.clientX - rect.left + scrollRef.current.scrollLeft);
|
|
371
|
+
}
|
|
372
|
+
}}
|
|
373
|
+
onMouseLeave={() => setRazorGuideX(null)}
|
|
392
374
|
style={{
|
|
393
375
|
touchAction: "pan-x pan-y",
|
|
394
376
|
background: theme.shellBackground,
|
|
@@ -402,7 +384,16 @@ export const Timeline = memo(function Timeline({
|
|
|
402
384
|
onDragOver={handleAssetDragOver}
|
|
403
385
|
onDragLeave={() => setIsDragOver(false)}
|
|
404
386
|
onDrop={handleAssetDrop}
|
|
405
|
-
onPointerDown={
|
|
387
|
+
onPointerDown={(e) => {
|
|
388
|
+
if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) {
|
|
389
|
+
const rect = scrollRef.current.getBoundingClientRect();
|
|
390
|
+
const x = e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER;
|
|
391
|
+
const splitTime = Math.max(0, x / pps);
|
|
392
|
+
onRazorSplitAll?.(splitTime);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
handlePointerDown(e);
|
|
396
|
+
}}
|
|
406
397
|
onPointerMove={handlePointerMove}
|
|
407
398
|
onPointerUp={handlePointerUp}
|
|
408
399
|
onLostPointerCapture={handlePointerUp}
|
|
@@ -457,6 +448,9 @@ export const Timeline = memo(function Timeline({
|
|
|
457
448
|
onSelectElement?.(el);
|
|
458
449
|
const absTime = el.start + (pct / 100) * el.duration;
|
|
459
450
|
onSeek?.(absTime);
|
|
451
|
+
const kfData = keyframeCache?.get(elKey);
|
|
452
|
+
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5);
|
|
453
|
+
usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null);
|
|
460
454
|
}}
|
|
461
455
|
onShiftClickKeyframe={(elId, pct) => {
|
|
462
456
|
toggleSelectedKeyframe(`${elId}:${pct}`);
|
|
@@ -473,12 +467,13 @@ export const Timeline = memo(function Timeline({
|
|
|
473
467
|
onSeek?.(absTime);
|
|
474
468
|
}
|
|
475
469
|
const kfData = keyframeCache.get(elId);
|
|
476
|
-
const kf = kfData?.keyframes.find((k) => k.percentage
|
|
470
|
+
const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
|
477
471
|
setKfContextMenu({
|
|
478
|
-
x: e.clientX,
|
|
479
|
-
y: e.clientY,
|
|
472
|
+
x: e.clientX + 4,
|
|
473
|
+
y: e.clientY + 2,
|
|
480
474
|
elementId: elId,
|
|
481
475
|
percentage: pct,
|
|
476
|
+
tweenPercentage: kf?.tweenPercentage,
|
|
482
477
|
currentEase: kf?.ease ?? kfData?.ease,
|
|
483
478
|
});
|
|
484
479
|
}}
|
|
@@ -488,7 +483,19 @@ export const Timeline = memo(function Timeline({
|
|
|
488
483
|
onSelectElement?.(el);
|
|
489
484
|
setClipContextMenu({ x: e.clientX, y: e.clientY, element: el });
|
|
490
485
|
}}
|
|
486
|
+
onRazorSplit={onRazorSplit}
|
|
487
|
+
onRazorSplitAll={onRazorSplitAll}
|
|
491
488
|
/>
|
|
489
|
+
{activeTool === "razor" && razorGuideX !== null && (
|
|
490
|
+
<div
|
|
491
|
+
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
|
492
|
+
style={{
|
|
493
|
+
left: razorGuideX,
|
|
494
|
+
width: 1,
|
|
495
|
+
background: "rgba(239,68,68,0.7)",
|
|
496
|
+
}}
|
|
497
|
+
/>
|
|
498
|
+
)}
|
|
492
499
|
</div>
|
|
493
500
|
|
|
494
501
|
{showShortcutHint && !showPopover && !rangeSelection && (
|
|
@@ -2,6 +2,7 @@ import { memo, type ReactNode } from "react";
|
|
|
2
2
|
import { TimelineClip } from "./TimelineClip";
|
|
3
3
|
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
|
|
4
4
|
import { TimelineRuler } from "./TimelineRuler";
|
|
5
|
+
import { PlayheadIndicator } from "./PlayheadIndicator";
|
|
5
6
|
import {
|
|
6
7
|
getTimelineEditCapabilities,
|
|
7
8
|
resolveBlockedTimelineEditIntent,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
18
19
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
19
20
|
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
|
21
|
+
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
|
|
20
22
|
|
|
21
23
|
function ClipLabel({ element, color }: { element: TimelineElement; color: string }) {
|
|
22
24
|
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
|
|
@@ -91,6 +93,8 @@ interface TimelineCanvasProps {
|
|
|
91
93
|
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
92
94
|
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
93
95
|
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
96
|
+
onRazorSplit?: (element: TimelineElement, splitTime: number) => void;
|
|
97
|
+
onRazorSplitAll?: (splitTime: number) => void;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
export const TimelineCanvas = memo(function TimelineCanvas({
|
|
@@ -141,6 +145,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
141
145
|
onContextMenuKeyframe,
|
|
142
146
|
onContextMenuClip,
|
|
143
147
|
onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead,
|
|
148
|
+
onRazorSplit,
|
|
149
|
+
onRazorSplitAll,
|
|
144
150
|
}: TimelineCanvasProps) {
|
|
145
151
|
const draggedElement = draggedClip?.element ?? null;
|
|
146
152
|
const activeDraggedElement =
|
|
@@ -305,6 +311,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
305
311
|
}}
|
|
306
312
|
onPointerDown={(e) => {
|
|
307
313
|
if (e.button !== 0) return;
|
|
314
|
+
if (usePlayerStore.getState().activeTool === "razor") return;
|
|
308
315
|
if (e.shiftKey) {
|
|
309
316
|
shiftClickClipRef.current = {
|
|
310
317
|
element: el,
|
|
@@ -358,6 +365,27 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
358
365
|
onClick={(e) => {
|
|
359
366
|
e.stopPropagation();
|
|
360
367
|
if (suppressClickRef.current) return;
|
|
368
|
+
const { activeTool } = usePlayerStore.getState();
|
|
369
|
+
if (activeTool === "razor" && onRazorSplit) {
|
|
370
|
+
const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
371
|
+
const clickOffsetX = e.clientX - clipRect.left;
|
|
372
|
+
const splitTime = previewElement.start + clickOffsetX / pps;
|
|
373
|
+
const clampedTime = Math.max(
|
|
374
|
+
previewElement.start + SPLIT_BOUNDARY_EPSILON_S,
|
|
375
|
+
Math.min(
|
|
376
|
+
previewElement.start +
|
|
377
|
+
previewElement.duration -
|
|
378
|
+
SPLIT_BOUNDARY_EPSILON_S,
|
|
379
|
+
splitTime,
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
if (e.shiftKey && onRazorSplitAll) {
|
|
383
|
+
onRazorSplitAll(clampedTime);
|
|
384
|
+
} else {
|
|
385
|
+
onRazorSplit(el, clampedTime);
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
361
389
|
const nextElement = isSelected ? null : el;
|
|
362
390
|
setSelectedElementId(nextElement ? elementKey : null);
|
|
363
391
|
onSelectElement?.(nextElement);
|
|
@@ -457,28 +485,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
457
485
|
className="absolute top-0 bottom-0 pointer-events-none"
|
|
458
486
|
style={{ left: `${GUTTER}px`, zIndex: 100 }}
|
|
459
487
|
>
|
|
460
|
-
<
|
|
461
|
-
className="absolute top-0 bottom-0"
|
|
462
|
-
style={{
|
|
463
|
-
left: "50%",
|
|
464
|
-
width: 2,
|
|
465
|
-
marginLeft: -1,
|
|
466
|
-
background: "var(--hf-accent, #3CE6AC)",
|
|
467
|
-
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
|
|
468
|
-
}}
|
|
469
|
-
/>
|
|
470
|
-
<div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
|
|
471
|
-
<div
|
|
472
|
-
style={{
|
|
473
|
-
width: 0,
|
|
474
|
-
height: 0,
|
|
475
|
-
borderLeft: "6px solid transparent",
|
|
476
|
-
borderRight: "6px solid transparent",
|
|
477
|
-
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
|
|
478
|
-
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
|
|
479
|
-
}}
|
|
480
|
-
/>
|
|
481
|
-
</div>
|
|
488
|
+
<PlayheadIndicator />
|
|
482
489
|
</div>
|
|
483
490
|
</div>
|
|
484
491
|
);
|
|
@@ -123,7 +123,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
123
123
|
const kfKey = `${elementId}:${kf.percentage}`;
|
|
124
124
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
125
125
|
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
|
126
|
-
const
|
|
126
|
+
const isHighlighted = isKfSelected || atPlayhead;
|
|
127
|
+
const color = isHighlighted ? accentColor : "#a3a3a3";
|
|
127
128
|
return (
|
|
128
129
|
<button
|
|
129
130
|
key={`${i}-${kf.percentage}`}
|
|
@@ -135,6 +136,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
135
136
|
transform: "translateY(-50%)",
|
|
136
137
|
width: diamondSize,
|
|
137
138
|
height: diamondSize,
|
|
139
|
+
zIndex: isHighlighted ? 2 : 1,
|
|
138
140
|
pointerEvents: "auto",
|
|
139
141
|
background: "none",
|
|
140
142
|
border: "none",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
2
|
+
// fallow-ignore-file dead-code
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import type { BlockedTimelineEditIntent } from "./timelineEditing";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared callback signatures for timeline editing operations.
|
|
8
|
+
* Used by NLELayout, Timeline, and any component that passes through
|
|
9
|
+
* the standard set of timeline mutation handlers.
|
|
10
|
+
*/
|
|
11
|
+
export interface TimelineDropCallbacks {
|
|
12
|
+
onFileDrop?: (
|
|
13
|
+
files: File[],
|
|
14
|
+
placement?: { start: number; track: number },
|
|
15
|
+
) => Promise<void> | void;
|
|
16
|
+
onAssetDrop?: (
|
|
17
|
+
assetPath: string,
|
|
18
|
+
placement: { start: number; track: number },
|
|
19
|
+
) => Promise<void> | void;
|
|
20
|
+
onBlockDrop?: (
|
|
21
|
+
blockName: string,
|
|
22
|
+
placement: { start: number; track: number },
|
|
23
|
+
) => Promise<void> | void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TimelineEditCallbacks {
|
|
27
|
+
onMoveElement?: (
|
|
28
|
+
element: TimelineElement,
|
|
29
|
+
updates: Pick<TimelineElement, "start" | "track">,
|
|
30
|
+
) => Promise<void> | void;
|
|
31
|
+
onResizeElement?: (
|
|
32
|
+
element: TimelineElement,
|
|
33
|
+
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
34
|
+
) => Promise<void> | void;
|
|
35
|
+
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
36
|
+
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
37
|
+
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
38
|
+
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
|
|
39
|
+
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
|
|
40
|
+
onDeleteAllKeyframes?: (elementId: string) => void;
|
|
41
|
+
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
|
|
42
|
+
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
|
|
43
|
+
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
|
|
44
|
+
}
|
|
@@ -1,25 +1,13 @@
|
|
|
1
|
-
// fallow-ignore-file clone-families
|
|
2
1
|
import { useCallback, useState, type RefObject } from "react";
|
|
3
2
|
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
|
|
4
3
|
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
|
|
4
|
+
import type { TimelineDropCallbacks } from "./timelineCallbacks";
|
|
5
5
|
|
|
6
|
-
interface UseTimelineAssetDropOptions {
|
|
6
|
+
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
|
|
7
7
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
8
8
|
ppsRef: RefObject<number>;
|
|
9
9
|
durationRef: RefObject<number>;
|
|
10
10
|
trackOrderRef: RefObject<number[]>;
|
|
11
|
-
onFileDrop?: (
|
|
12
|
-
files: File[],
|
|
13
|
-
placement?: { start: number; track: number },
|
|
14
|
-
) => Promise<void> | void;
|
|
15
|
-
onAssetDrop?: (
|
|
16
|
-
assetPath: string,
|
|
17
|
-
placement: { start: number; track: number },
|
|
18
|
-
) => Promise<void> | void;
|
|
19
|
-
onBlockDrop?: (
|
|
20
|
-
blockName: string,
|
|
21
|
-
placement: { start: number; track: number },
|
|
22
|
-
) => Promise<void> | void;
|
|
23
11
|
}
|
|
24
12
|
|
|
25
13
|
export function useTimelineAssetDrop({
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// fallow-ignore-file dead-code
|
|
2
|
+
import { usePlayerStore, type ZoomMode } from "../store/playerStore";
|
|
3
|
+
|
|
4
|
+
export interface TimelineZoomState {
|
|
5
|
+
zoomMode: ZoomMode;
|
|
6
|
+
manualZoomPercent: number;
|
|
7
|
+
setZoomMode: (mode: ZoomMode) => void;
|
|
8
|
+
setManualZoomPercent: (percent: number) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Shared zoom-related store selectors used by Timeline and TimelineToolbar. */
|
|
12
|
+
export function useTimelineZoom(): TimelineZoomState {
|
|
13
|
+
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
14
|
+
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
15
|
+
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
16
|
+
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
17
|
+
return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent };
|
|
18
|
+
}
|
|
@@ -6,6 +6,10 @@ export interface KeyframeCacheEntry {
|
|
|
6
6
|
format: string;
|
|
7
7
|
keyframes: Array<{
|
|
8
8
|
percentage: number;
|
|
9
|
+
/** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */
|
|
10
|
+
tweenPercentage?: number;
|
|
11
|
+
/** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */
|
|
12
|
+
propertyGroup?: string;
|
|
9
13
|
properties: Record<string, number | string>;
|
|
10
14
|
ease?: string;
|
|
11
15
|
}>;
|
|
@@ -45,6 +49,7 @@ export interface TimelineElement {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
export type ZoomMode = "fit" | "manual";
|
|
52
|
+
type TimelineTool = "select" | "razor";
|
|
48
53
|
|
|
49
54
|
interface PlayerState {
|
|
50
55
|
isPlaying: boolean;
|
|
@@ -65,11 +70,19 @@ interface PlayerState {
|
|
|
65
70
|
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
|
|
66
71
|
outPoint: number | null;
|
|
67
72
|
|
|
73
|
+
activeTool: TimelineTool;
|
|
74
|
+
setActiveTool: (tool: TimelineTool) => void;
|
|
75
|
+
|
|
68
76
|
/** Set of selected keyframe keys in format `${elementId}:${percentage}`. */
|
|
69
77
|
selectedKeyframes: Set<string>;
|
|
70
78
|
toggleSelectedKeyframe: (key: string) => void;
|
|
71
79
|
clearSelectedKeyframes: () => void;
|
|
72
80
|
|
|
81
|
+
/** Tween-relative percentage of the last-clicked keyframe diamond. Operations
|
|
82
|
+
* (drag, resize, rotate) target this instead of recomputing from playhead. */
|
|
83
|
+
activeKeyframePct: number | null;
|
|
84
|
+
setActiveKeyframePct: (pct: number | null) => void;
|
|
85
|
+
|
|
73
86
|
/** Multi-select: additional selected elements beyond selectedElementId. */
|
|
74
87
|
selectedElementIds: Set<string>;
|
|
75
88
|
toggleSelectedElementId: (id: string) => void;
|
|
@@ -153,6 +166,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
153
166
|
inPoint: null,
|
|
154
167
|
outPoint: null,
|
|
155
168
|
|
|
169
|
+
activeTool: "select",
|
|
170
|
+
setActiveTool: (tool) => set({ activeTool: tool }),
|
|
171
|
+
|
|
156
172
|
selectedKeyframes: new Set(),
|
|
157
173
|
toggleSelectedKeyframe: (key) =>
|
|
158
174
|
set((s) => {
|
|
@@ -163,6 +179,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
163
179
|
}),
|
|
164
180
|
clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }),
|
|
165
181
|
|
|
182
|
+
activeKeyframePct: null,
|
|
183
|
+
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
|
184
|
+
|
|
166
185
|
keyframeClipboard: null,
|
|
167
186
|
setKeyframeClipboard: (data) => set({ keyframeClipboard: data }),
|
|
168
187
|
|
|
@@ -262,6 +281,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
262
281
|
selectedElementId: null,
|
|
263
282
|
inPoint: null,
|
|
264
283
|
outPoint: null,
|
|
284
|
+
activeTool: "select",
|
|
265
285
|
selectedKeyframes: new Set(),
|
|
266
286
|
selectedElementIds: new Set(),
|
|
267
287
|
expandedTimelineElements: new Set(),
|
|
@@ -103,8 +103,8 @@ describe("resolveTweenDuration", () => {
|
|
|
103
103
|
expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
test("missing duration defaults to
|
|
107
|
-
expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(
|
|
106
|
+
test("missing duration defaults to GSAP default (0.5)", () => {
|
|
107
|
+
expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(0.5);
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
110
|
|
|
@@ -27,6 +27,7 @@ export function isTimeWithinTween(
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function resolveTweenStart(animation: GsapAnimation): number | null {
|
|
30
|
+
if (animation.resolvedStart != null) return animation.resolvedStart;
|
|
30
31
|
if (typeof animation.position === "number") return animation.position;
|
|
31
32
|
const parsed = Number.parseFloat(animation.position as string);
|
|
32
33
|
if (!Number.isNaN(parsed)) return parsed;
|
|
@@ -34,7 +35,7 @@ export function resolveTweenStart(animation: GsapAnimation): number | null {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export function resolveTweenDuration(animation: GsapAnimation): number {
|
|
37
|
-
return animation.duration ??
|
|
38
|
+
return animation.duration ?? 0.5;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export function findTweenAtTime(
|
|
@@ -28,10 +28,26 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
|
|
|
28
28
|
...overrides,
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
// Intercept appendChild: when a <script> is appended, simulate execution by
|
|
32
|
+
// repopulating __timelines (mimicking what the real GSAP script would do).
|
|
33
|
+
const realAppendChild = container.appendChild.bind(container);
|
|
34
|
+
container.appendChild = <T extends Node>(node: T): T => {
|
|
35
|
+
const result = realAppendChild(node);
|
|
36
|
+
if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
|
|
37
|
+
// Simulate the script populating __timelines
|
|
38
|
+
const cw = contentWindow as { __timelines?: Record<string, unknown> };
|
|
39
|
+
if (cw.__timelines) {
|
|
40
|
+
cw.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
};
|
|
45
|
+
|
|
31
46
|
const contentDocument = {
|
|
32
47
|
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
|
|
33
48
|
createElement: (tag: string) => document.createElement(tag),
|
|
34
49
|
body: container,
|
|
50
|
+
head: document.createElement("div"),
|
|
35
51
|
};
|
|
36
52
|
|
|
37
53
|
return {
|
|
@@ -7,6 +7,8 @@ type IframeWindow = Window & {
|
|
|
7
7
|
gsap?: {
|
|
8
8
|
timeline?: (...args: unknown[]) => unknown;
|
|
9
9
|
registerPlugin?: (...plugins: unknown[]) => unknown;
|
|
10
|
+
set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
|
|
11
|
+
globalTimeline?: { getChildren?: (deep: boolean) => Array<{ kill?: () => void }> };
|
|
10
12
|
};
|
|
11
13
|
MotionPathPlugin?: unknown;
|
|
12
14
|
};
|
|
@@ -29,6 +31,14 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
|
|
|
29
31
|
return results;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
/** Check that the new script repopulated __timelines with at least one entry. */
|
|
35
|
+
function verifyTimelinesPopulated(win: IframeWindow): boolean {
|
|
36
|
+
const tlKeys = win.__timelines
|
|
37
|
+
? Object.keys(win.__timelines).filter((k) => k !== "__proxied")
|
|
38
|
+
: [];
|
|
39
|
+
return tlKeys.length > 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
/**
|
|
33
43
|
* Replace the GSAP script in the live iframe without reloading. This preserves
|
|
34
44
|
* the WebGL context and shader transition cache.
|
|
@@ -56,24 +66,30 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
|
|
56
66
|
|
|
57
67
|
const currentTime = win.__player?.getTime?.() ?? 0;
|
|
58
68
|
|
|
69
|
+
// Track whether the MotionPath async path was taken. When it is, the script
|
|
70
|
+
// executes inside pluginScript.onload — after applySoftReload has already
|
|
71
|
+
// returned. We optimistically return true because the script WILL execute
|
|
72
|
+
// once the plugin loads; the alternative (returning false) would trigger a
|
|
73
|
+
// full iframe reload that destroys the very WebGL context we're preserving.
|
|
74
|
+
let deferredToAsync = false;
|
|
75
|
+
|
|
59
76
|
const doReload = () => {
|
|
60
77
|
const timelines = win.__timelines;
|
|
78
|
+
const allTargets: Element[] = [];
|
|
79
|
+
|
|
61
80
|
if (timelines) {
|
|
62
81
|
for (const key of Object.keys(timelines)) {
|
|
82
|
+
if (key === "__proxied") continue;
|
|
63
83
|
try {
|
|
64
84
|
const tl = timelines[key] as {
|
|
65
85
|
kill?: () => void;
|
|
66
86
|
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
|
67
87
|
};
|
|
68
|
-
const allTargets: Element[] = [];
|
|
69
88
|
if (tl?.getChildren) {
|
|
70
89
|
try {
|
|
71
90
|
for (const child of tl.getChildren(true)) {
|
|
72
91
|
if (typeof child.targets === "function") {
|
|
73
|
-
for (const t of child.targets())
|
|
74
|
-
allTargets.push(t);
|
|
75
|
-
delete (t as unknown as Record<string, unknown>)._gsap;
|
|
76
|
-
}
|
|
92
|
+
for (const t of child.targets()) allTargets.push(t);
|
|
77
93
|
}
|
|
78
94
|
}
|
|
79
95
|
} catch {}
|
|
@@ -84,6 +100,23 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
// Kill bare gsap.to/from tweens not registered on __timelines
|
|
104
|
+
if (win.gsap?.globalTimeline?.getChildren) {
|
|
105
|
+
try {
|
|
106
|
+
for (const child of win.gsap.globalTimeline.getChildren(false)) {
|
|
107
|
+
child.kill?.();
|
|
108
|
+
}
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Clear residual inline transforms left by killed tweens so from() tweens
|
|
113
|
+
// don't read stale end values from the DOM on re-execution
|
|
114
|
+
if (allTargets.length > 0 && win.gsap?.set) {
|
|
115
|
+
try {
|
|
116
|
+
win.gsap.set(allTargets, { clearProps: "all" });
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
oldScriptEl.remove();
|
|
88
121
|
|
|
89
122
|
const executeScript = () => {
|
|
@@ -98,10 +131,9 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
|
|
98
131
|
win.__hfStudioManualEditsApply?.();
|
|
99
132
|
};
|
|
100
133
|
|
|
101
|
-
// Load MotionPathPlugin on demand if the script uses motionPath.
|
|
102
|
-
// Uses the same CDN as composition templates (GSAP_CDN in constants.ts).
|
|
103
134
|
const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
|
|
104
135
|
if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
|
|
136
|
+
deferredToAsync = true;
|
|
105
137
|
const pluginScript = doc.createElement("script");
|
|
106
138
|
pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
|
|
107
139
|
pluginScript.onload = () => executeScript();
|
|
@@ -119,7 +151,10 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
|
|
|
119
151
|
} else {
|
|
120
152
|
doReload();
|
|
121
153
|
}
|
|
122
|
-
|
|
154
|
+
// When MotionPath needs async loading, the script hasn't executed yet —
|
|
155
|
+
// skip the __timelines check and return true optimistically.
|
|
156
|
+
if (deferredToAsync) return true;
|
|
157
|
+
return verifyTimelinesPopulated(win);
|
|
123
158
|
} catch {
|
|
124
159
|
return false;
|
|
125
160
|
}
|