@hyperframes/studio 0.6.73 → 0.6.75
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-DcyZuBcU.css +1 -0
- package/dist/assets/index-uB_W2GDl.js +140 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +30 -24
- package/src/components/StudioPreviewArea.tsx +101 -26
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/StudioToast.tsx +18 -0
- package/src/components/TimelineToolbar.tsx +230 -4
- package/src/components/editor/AnimationCard.tsx +68 -4
- package/src/components/editor/DomEditOverlay.tsx +70 -1
- package/src/components/editor/GridOverlay.tsx +50 -0
- package/src/components/editor/KeyframeDiamond.tsx +49 -0
- package/src/components/editor/KeyframeNavigation.tsx +139 -0
- package/src/components/editor/LayersPanel.test.ts +135 -0
- package/src/components/editor/LayersPanel.tsx +151 -15
- package/src/components/editor/PropertyPanel.tsx +293 -140
- package/src/components/editor/SnapGuideOverlay.tsx +166 -0
- package/src/components/editor/SnapToolbar.tsx +163 -0
- package/src/components/editor/SpringEaseEditor.tsx +256 -0
- package/src/components/editor/domEditOverlayGestures.ts +7 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
- package/src/components/editor/gsapAnimationConstants.ts +42 -0
- package/src/components/editor/gsapAnimationHelpers.ts +2 -1
- package/src/components/editor/manualEditingAvailability.ts +6 -0
- package/src/components/editor/manualEditsDom.ts +56 -2
- package/src/components/editor/manualOffsetDrag.ts +19 -3
- package/src/components/editor/propertyPanelHelpers.ts +90 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
- package/src/components/editor/snapEngine.test.ts +657 -0
- package/src/components/editor/snapEngine.ts +575 -0
- package/src/components/editor/snapTargetCollection.ts +147 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
- package/src/components/editor/useLayerDrag.ts +213 -0
- package/src/components/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +27 -0
- package/src/hooks/gsapRuntimeBridge.ts +585 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
- package/src/hooks/useAppHotkeys.ts +63 -1
- package/src/hooks/useDomEditCommits.ts +88 -4
- package/src/hooks/useDomEditSession.ts +179 -65
- package/src/hooks/useGsapScriptCommits.ts +144 -7
- package/src/hooks/useGsapSelectionHandlers.ts +202 -0
- package/src/hooks/useGsapTweenCache.ts +174 -3
- package/src/hooks/useTimelineEditing.ts +93 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/ClipContextMenu.tsx +99 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
- package/src/player/components/Timeline.test.ts +2 -1
- package/src/player/components/Timeline.tsx +108 -68
- package/src/player/components/TimelineCanvas.tsx +47 -1
- package/src/player/components/TimelineClip.tsx +8 -3
- package/src/player/components/TimelineClipDiamonds.tsx +174 -0
- package/src/player/components/timelineDragDrop.ts +103 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/store/playerStore.ts +42 -0
- package/src/utils/editHistory.ts +1 -1
- package/src/utils/optimisticUpdate.test.ts +53 -0
- package/src/utils/optimisticUpdate.ts +18 -0
- package/src/utils/studioUiPreferences.ts +17 -0
- package/dist/assets/index-CrxThtSJ.css +0 -1
- package/dist/assets/index-Dc2HfqON.js +0 -140
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-uB_W2GDl.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DcyZuBcU.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.75",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@codemirror/view": "6.40.0",
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"mediabunny": "^1.45.3",
|
|
34
|
-
"@hyperframes/core": "0.6.
|
|
35
|
-
"@hyperframes/player": "0.6.
|
|
34
|
+
"@hyperframes/core": "0.6.75",
|
|
35
|
+
"@hyperframes/player": "0.6.75"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/react": "19",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"vite": "^6.4.2",
|
|
47
47
|
"vitest": "^3.2.4",
|
|
48
48
|
"zustand": "^5.0.0",
|
|
49
|
-
"@hyperframes/producer": "0.6.
|
|
49
|
+
"@hyperframes/producer": "0.6.75"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -44,6 +44,7 @@ import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
|
|
|
44
44
|
import { FileManagerProvider } from "./contexts/FileManagerContext";
|
|
45
45
|
import { DomEditProvider } from "./contexts/DomEditContext";
|
|
46
46
|
import { StudioSplash } from "./components/StudioSplash";
|
|
47
|
+
import { StudioToast } from "./components/StudioToast";
|
|
47
48
|
import { useServerConnection } from "./hooks/useServerConnection";
|
|
48
49
|
import {
|
|
49
50
|
normalizeStudioCompositionPath,
|
|
@@ -266,8 +267,10 @@ export function StudioApp() {
|
|
|
266
267
|
const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
|
|
267
268
|
async () => {},
|
|
268
269
|
);
|
|
269
|
-
const domEditDeleteBridge =
|
|
270
|
-
|
|
270
|
+
const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
|
|
271
|
+
const resetKeyframesRef = useRef<() => boolean>(() => false);
|
|
272
|
+
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
|
|
273
|
+
const invalidateGsapCacheRef = useRef<() => void>(() => {});
|
|
271
274
|
const { handleCopy, handlePaste, handleCut } = useClipboard({
|
|
272
275
|
projectId,
|
|
273
276
|
activeCompPath,
|
|
@@ -284,6 +287,7 @@ export function StudioApp() {
|
|
|
284
287
|
const appHotkeys = useAppHotkeys({
|
|
285
288
|
toggleTimelineVisibility,
|
|
286
289
|
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
|
|
290
|
+
handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit,
|
|
287
291
|
handleDomEditElementDelete: domEditDeleteBridge,
|
|
288
292
|
domEditSelectionRef: domEditSelectionBridgeRef,
|
|
289
293
|
clearDomSelectionRef,
|
|
@@ -299,8 +303,10 @@ export function StudioApp() {
|
|
|
299
303
|
handleCopy,
|
|
300
304
|
handlePaste,
|
|
301
305
|
handleCut,
|
|
306
|
+
onResetKeyframes: () => resetKeyframesRef.current(),
|
|
307
|
+
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
|
|
308
|
+
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
|
|
302
309
|
});
|
|
303
|
-
|
|
304
310
|
const selectSidebarTabStable = useCallback(
|
|
305
311
|
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
|
|
306
312
|
[],
|
|
@@ -345,11 +351,20 @@ export function StudioApp() {
|
|
|
345
351
|
selectSidebarTab: selectSidebarTabStable,
|
|
346
352
|
getSidebarTab: getSidebarTabStable,
|
|
347
353
|
});
|
|
348
|
-
|
|
349
354
|
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
|
350
355
|
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
|
351
356
|
handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;
|
|
352
|
-
|
|
357
|
+
resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
|
|
358
|
+
invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
|
|
359
|
+
deleteSelectedKeyframesRef.current = () => {
|
|
360
|
+
const sk = usePlayerStore.getState().selectedKeyframes;
|
|
361
|
+
const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes);
|
|
362
|
+
if (!a || sk.size === 0) return;
|
|
363
|
+
sk.forEach((k) => {
|
|
364
|
+
const p = Number(k.split(":")[1]);
|
|
365
|
+
if (Number.isFinite(p)) domEditSession.handleGsapRemoveKeyframe(a.id, p);
|
|
366
|
+
});
|
|
367
|
+
};
|
|
353
368
|
useCaptionDetection({
|
|
354
369
|
projectId,
|
|
355
370
|
activeCompPath,
|
|
@@ -470,12 +485,15 @@ export function StudioApp() {
|
|
|
470
485
|
timelineVisible,
|
|
471
486
|
toggleTimelineVisibility,
|
|
472
487
|
});
|
|
473
|
-
|
|
474
|
-
if (resolving || waitingForServer || !projectId) {
|
|
488
|
+
if (resolving || waitingForServer || !projectId)
|
|
475
489
|
return <StudioSplash waiting={waitingForServer} />;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
490
|
+
const timelineToolbar = (
|
|
491
|
+
<TimelineToolbar
|
|
492
|
+
toggleTimelineVisibility={toggleTimelineVisibility}
|
|
493
|
+
domEditSession={domEditSession}
|
|
494
|
+
onSplitElement={timelineEditing.handleTimelineElementSplit}
|
|
495
|
+
/>
|
|
496
|
+
);
|
|
479
497
|
return (
|
|
480
498
|
<StudioProvider value={studioCtxValue}>
|
|
481
499
|
<PanelLayoutProvider value={panelLayout}>
|
|
@@ -517,6 +535,7 @@ export function StudioApp() {
|
|
|
517
535
|
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
|
|
518
536
|
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
|
519
537
|
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
|
538
|
+
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
|
520
539
|
setCompIdToSrc={setCompIdToSrc}
|
|
521
540
|
setCompositionLoading={setCompositionLoading}
|
|
522
541
|
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
|
@@ -540,7 +559,6 @@ export function StudioApp() {
|
|
|
540
559
|
{lintModal !== null && (
|
|
541
560
|
<LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
|
|
542
561
|
)}
|
|
543
|
-
|
|
544
562
|
{consoleErrors !== null && consoleErrors.length > 0 && (
|
|
545
563
|
<LintModal
|
|
546
564
|
findings={consoleErrors}
|
|
@@ -548,7 +566,6 @@ export function StudioApp() {
|
|
|
548
566
|
onClose={() => setConsoleErrors(null)}
|
|
549
567
|
/>
|
|
550
568
|
)}
|
|
551
|
-
|
|
552
569
|
{domEditSession.agentModalOpen && domEditSession.domEditSelection && (
|
|
553
570
|
<AskAgentModal
|
|
554
571
|
selectionLabel={domEditSession.domEditSelection.label}
|
|
@@ -567,18 +584,7 @@ export function StudioApp() {
|
|
|
567
584
|
)}
|
|
568
585
|
|
|
569
586
|
{dragOverlay.active && <StudioGlobalDragOverlay />}
|
|
570
|
-
|
|
571
|
-
{appToast && (
|
|
572
|
-
<div
|
|
573
|
-
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
|
|
574
|
-
appToast.tone === "error"
|
|
575
|
-
? "bg-red-900/90 border-red-700/50 text-red-200"
|
|
576
|
-
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
|
|
577
|
-
}`}
|
|
578
|
-
>
|
|
579
|
-
{appToast.message}
|
|
580
|
-
</div>
|
|
581
|
-
)}
|
|
587
|
+
{appToast && <StudioToast message={appToast.message} tone={appToast.tone} />}
|
|
582
588
|
</div>
|
|
583
589
|
</DomEditProvider>
|
|
584
590
|
</FileManagerProvider>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { NLELayout } from "./nle/NLELayout";
|
|
3
3
|
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
|
|
4
4
|
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
|
|
5
5
|
import { DomEditOverlay } from "./editor/DomEditOverlay";
|
|
6
|
+
import { SnapToolbar } from "./editor/SnapToolbar";
|
|
6
7
|
import { StudioFeedbackBar } from "./StudioFeedbackBar";
|
|
7
8
|
import type { TimelineElement } from "../player";
|
|
9
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
10
|
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
|
|
9
11
|
import {
|
|
10
12
|
STUDIO_INSPECTOR_PANELS_ENABLED,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
import { useStudioContext } from "../contexts/StudioContext";
|
|
15
17
|
import { useDomEditContext } from "../contexts/DomEditContext";
|
|
16
18
|
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
|
|
19
|
+
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
17
20
|
|
|
18
21
|
export interface StudioPreviewAreaProps {
|
|
19
22
|
timelineToolbar: ReactNode;
|
|
@@ -48,6 +51,7 @@ export interface StudioPreviewAreaProps {
|
|
|
48
51
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
49
52
|
) => Promise<void> | void;
|
|
50
53
|
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
54
|
+
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
51
55
|
setCompIdToSrc: (map: Map<string, string>) => void;
|
|
52
56
|
setCompositionLoading: (loading: boolean) => void;
|
|
53
57
|
shouldShowSelectedDomBounds: boolean;
|
|
@@ -66,6 +70,7 @@ export function StudioPreviewArea({
|
|
|
66
70
|
handleTimelineElementMove,
|
|
67
71
|
handleTimelineElementResize,
|
|
68
72
|
handleBlockedTimelineEdit,
|
|
73
|
+
handleTimelineElementSplit,
|
|
69
74
|
setCompIdToSrc,
|
|
70
75
|
setCompositionLoading,
|
|
71
76
|
shouldShowSelectedDomBounds,
|
|
@@ -101,8 +106,24 @@ export function StudioPreviewArea({
|
|
|
101
106
|
handleDomGroupPathOffsetCommit,
|
|
102
107
|
handleDomBoxSizeCommit,
|
|
103
108
|
handleDomRotationCommit,
|
|
109
|
+
selectedGsapAnimations,
|
|
110
|
+
handleGsapRemoveKeyframe,
|
|
111
|
+
handleGsapUpdateMeta,
|
|
112
|
+
handleGsapAddKeyframe,
|
|
113
|
+
handleGsapConvertToKeyframes,
|
|
114
|
+
handleGsapDeleteAnimation,
|
|
104
115
|
} = useDomEditContext();
|
|
105
116
|
|
|
117
|
+
const [snapPrefs, setSnapPrefs] = useState(() => {
|
|
118
|
+
const p = readStudioUiPreferences();
|
|
119
|
+
return {
|
|
120
|
+
snapEnabled: p.snapEnabled ?? true,
|
|
121
|
+
gridVisible: p.gridVisible ?? false,
|
|
122
|
+
gridSpacing: p.gridSpacing ?? 50,
|
|
123
|
+
snapToGrid: p.snapToGrid ?? false,
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
106
127
|
return (
|
|
107
128
|
<div className="flex-1 flex flex-col relative min-w-0">
|
|
108
129
|
<div className="flex-1 min-h-0 relative">
|
|
@@ -120,7 +141,56 @@ export function StudioPreviewArea({
|
|
|
120
141
|
onMoveElement={handleTimelineElementMove}
|
|
121
142
|
onResizeElement={handleTimelineElementResize}
|
|
122
143
|
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
|
144
|
+
onSplitElement={handleTimelineElementSplit}
|
|
123
145
|
onSelectTimelineElement={handleTimelineElementSelect}
|
|
146
|
+
onDeleteAllKeyframes={(_elId) => {
|
|
147
|
+
const anim =
|
|
148
|
+
selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
|
|
149
|
+
if (anim) handleGsapDeleteAnimation(anim.id);
|
|
150
|
+
}}
|
|
151
|
+
onDeleteKeyframe={(_elId, pct) => {
|
|
152
|
+
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
|
153
|
+
if (anim) handleGsapRemoveKeyframe(anim.id, pct);
|
|
154
|
+
}}
|
|
155
|
+
onChangeKeyframeEase={(_elId, _pct, ease) => {
|
|
156
|
+
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
|
157
|
+
if (anim) handleGsapUpdateMeta(anim.id, { ease });
|
|
158
|
+
}}
|
|
159
|
+
// fallow-ignore-next-line complexity
|
|
160
|
+
onMoveKeyframe={(_el, oldPct, newPct) => {
|
|
161
|
+
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
|
162
|
+
if (!anim?.keyframes) return;
|
|
163
|
+
const kf = anim.keyframes.keyframes.find((k) => k.percentage === oldPct);
|
|
164
|
+
if (!kf) return;
|
|
165
|
+
handleGsapRemoveKeyframe(anim.id, oldPct);
|
|
166
|
+
for (const [prop, val] of Object.entries(kf.properties)) {
|
|
167
|
+
handleGsapAddKeyframe(anim.id, newPct, prop, val);
|
|
168
|
+
}
|
|
169
|
+
}}
|
|
170
|
+
onToggleKeyframeAtPlayhead={(el) => {
|
|
171
|
+
const currentTime = usePlayerStore.getState().currentTime;
|
|
172
|
+
const pct =
|
|
173
|
+
el.duration > 0
|
|
174
|
+
? Math.max(
|
|
175
|
+
0,
|
|
176
|
+
Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)),
|
|
177
|
+
)
|
|
178
|
+
: 0;
|
|
179
|
+
const anim = selectedGsapAnimations.find((a) => a.keyframes);
|
|
180
|
+
if (anim?.keyframes) {
|
|
181
|
+
const existing = anim.keyframes.keyframes.find(
|
|
182
|
+
(k) => Math.abs(k.percentage - pct) <= 1,
|
|
183
|
+
);
|
|
184
|
+
if (existing) {
|
|
185
|
+
handleGsapRemoveKeyframe(anim.id, existing.percentage);
|
|
186
|
+
} else {
|
|
187
|
+
handleGsapAddKeyframe(anim.id, pct, "x", 0);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
|
|
191
|
+
if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
|
|
192
|
+
}
|
|
193
|
+
}}
|
|
124
194
|
onCompIdToSrcChange={setCompIdToSrc}
|
|
125
195
|
onCompositionLoadingChange={setCompositionLoading}
|
|
126
196
|
onCompositionChange={(compPath) => {
|
|
@@ -157,31 +227,36 @@ export function StudioPreviewArea({
|
|
|
157
227
|
) : captionEditMode ? (
|
|
158
228
|
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
159
229
|
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
230
|
+
<>
|
|
231
|
+
<DomEditOverlay
|
|
232
|
+
iframeRef={previewIframeRef}
|
|
233
|
+
activeCompositionPath={activeCompPath}
|
|
234
|
+
hoverSelection={
|
|
235
|
+
STUDIO_PREVIEW_SELECTION_ENABLED &&
|
|
236
|
+
!captionEditMode &&
|
|
237
|
+
!compositionLoading &&
|
|
238
|
+
!isPlaying
|
|
239
|
+
? domEditHoverSelection
|
|
240
|
+
: null
|
|
241
|
+
}
|
|
242
|
+
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
|
243
|
+
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
|
244
|
+
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
|
|
245
|
+
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
246
|
+
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
|
247
|
+
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
|
248
|
+
onSelectionChange={applyDomSelection}
|
|
249
|
+
onBlockedMove={handleBlockedDomMove}
|
|
250
|
+
onManualDragStart={handleDomManualDragStart}
|
|
251
|
+
onPathOffsetCommit={handleDomPathOffsetCommit}
|
|
252
|
+
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
253
|
+
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
254
|
+
onRotationCommit={handleDomRotationCommit}
|
|
255
|
+
gridVisible={snapPrefs.gridVisible}
|
|
256
|
+
gridSpacing={snapPrefs.gridSpacing}
|
|
257
|
+
/>
|
|
258
|
+
<SnapToolbar onSnapChange={setSnapPrefs} />
|
|
259
|
+
</>
|
|
185
260
|
) : null
|
|
186
261
|
}
|
|
187
262
|
timelineFooter={
|
|
@@ -91,6 +91,7 @@ export function StudioRightPanel({
|
|
|
91
91
|
handleGsapUpdateFromProperty,
|
|
92
92
|
handleGsapAddFromProperty,
|
|
93
93
|
handleGsapRemoveFromProperty,
|
|
94
|
+
commitAnimatedProperty,
|
|
94
95
|
} = useDomEditContext();
|
|
95
96
|
|
|
96
97
|
const { assets, fontAssets, projectDir, handleImportFiles, handleImportFonts } =
|
|
@@ -211,6 +212,7 @@ export function StudioRightPanel({
|
|
|
211
212
|
onImportAssets={handleImportFiles}
|
|
212
213
|
fontAssets={fontAssets}
|
|
213
214
|
onImportFonts={handleImportFonts}
|
|
215
|
+
previewIframeRef={previewIframeRef}
|
|
214
216
|
gsapAnimations={selectedGsapAnimations}
|
|
215
217
|
gsapMultipleTimelines={gsapMultipleTimelines}
|
|
216
218
|
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
|
|
@@ -223,6 +225,7 @@ export function StudioRightPanel({
|
|
|
223
225
|
onAddGsapFromProperty={handleGsapAddFromProperty}
|
|
224
226
|
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
|
|
225
227
|
onAddGsapAnimation={handleGsapAddAnimation}
|
|
228
|
+
onCommitAnimatedProperty={commitAnimatedProperty}
|
|
226
229
|
/>
|
|
227
230
|
) : motionPanelActive ? (
|
|
228
231
|
<MotionPanel
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface StudioToastProps {
|
|
2
|
+
message: string;
|
|
3
|
+
tone?: "error" | "info";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function StudioToast({ message, tone }: StudioToastProps) {
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
|
|
10
|
+
tone === "error"
|
|
11
|
+
? "bg-red-900/90 border-red-700/50 text-red-200"
|
|
12
|
+
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
|
|
13
|
+
}`}
|
|
14
|
+
>
|
|
15
|
+
{message}
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -3,25 +3,251 @@ import {
|
|
|
3
3
|
getTimelineZoomPercent,
|
|
4
4
|
} from "../player/components/timelineZoom";
|
|
5
5
|
import { getTimelineToggleTitle } from "../utils/timelineDiscovery";
|
|
6
|
-
import { usePlayerStore } from "../player";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
7
|
+
import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability";
|
|
7
8
|
import { Tooltip } from "./ui";
|
|
9
|
+
import { Scissors } from "../icons/SystemIcons";
|
|
10
|
+
import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
11
|
+
import type { DomEditSelection } from "./editor/domEditingTypes";
|
|
12
|
+
|
|
13
|
+
function interpolateKeyframeProperties(
|
|
14
|
+
keyframes: GsapPercentageKeyframe[],
|
|
15
|
+
pct: number,
|
|
16
|
+
): Record<string, number> {
|
|
17
|
+
const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage);
|
|
18
|
+
const allProps = new Set<string>();
|
|
19
|
+
for (const kf of sorted) {
|
|
20
|
+
for (const p of Object.keys(kf.properties)) {
|
|
21
|
+
if (typeof kf.properties[p] === "number") allProps.add(p);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const result: Record<string, number> = {};
|
|
25
|
+
for (const prop of allProps) {
|
|
26
|
+
let prev: { pct: number; val: number } | null = null;
|
|
27
|
+
let next: { pct: number; val: number } | null = null;
|
|
28
|
+
for (const kf of sorted) {
|
|
29
|
+
const v = kf.properties[prop];
|
|
30
|
+
if (typeof v !== "number") continue;
|
|
31
|
+
if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v };
|
|
32
|
+
if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v };
|
|
33
|
+
}
|
|
34
|
+
if (prev && next && prev.pct !== next.pct) {
|
|
35
|
+
const t = (pct - prev.pct) / (next.pct - prev.pct);
|
|
36
|
+
result[prop] = Math.round(prev.val + t * (next.val - prev.val));
|
|
37
|
+
} else if (prev) {
|
|
38
|
+
result[prop] = Math.round(prev.val);
|
|
39
|
+
} else if (next) {
|
|
40
|
+
result[prop] = Math.round(next.val);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readRuntimeKeyframeValues(
|
|
47
|
+
iframe: HTMLIFrameElement | null,
|
|
48
|
+
sel: DomEditSelection,
|
|
49
|
+
keyframes: GsapPercentageKeyframe[],
|
|
50
|
+
): Record<string, number> {
|
|
51
|
+
if (!iframe?.contentWindow) return {};
|
|
52
|
+
let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined;
|
|
53
|
+
try {
|
|
54
|
+
gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap;
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
if (!gsap?.getProperty) return {};
|
|
59
|
+
const selector = sel.id ? `#${sel.id}` : sel.selector;
|
|
60
|
+
if (!selector) return {};
|
|
61
|
+
let doc: Document | null = null;
|
|
62
|
+
try {
|
|
63
|
+
doc = iframe.contentDocument;
|
|
64
|
+
} catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
const element = doc?.querySelector(selector);
|
|
68
|
+
if (!element) return {};
|
|
69
|
+
const allProps = new Set<string>();
|
|
70
|
+
for (const kf of keyframes) {
|
|
71
|
+
for (const p of Object.keys(kf.properties)) {
|
|
72
|
+
if (typeof kf.properties[p] === "number") allProps.add(p);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const result: Record<string, number> = {};
|
|
76
|
+
for (const prop of allProps) {
|
|
77
|
+
const val = Number(gsap.getProperty(element, prop));
|
|
78
|
+
if (Number.isFinite(val)) result[prop] = Math.round(val);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface DomEditSessionSlice {
|
|
84
|
+
domEditSelection: DomEditSelection | null;
|
|
85
|
+
selectedGsapAnimations: GsapAnimation[];
|
|
86
|
+
handleGsapRemoveKeyframe: (animId: string, pct: number) => void;
|
|
87
|
+
handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void;
|
|
88
|
+
handleGsapConvertToKeyframes: (animId: string) => void;
|
|
89
|
+
handleGsapMaterializeKeyframes?: (animId: string) => Promise<void>;
|
|
90
|
+
handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
|
|
91
|
+
previewIframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
|
92
|
+
}
|
|
8
93
|
|
|
9
94
|
interface TimelineToolbarProps {
|
|
10
95
|
toggleTimelineVisibility: () => void;
|
|
96
|
+
domEditSession?: DomEditSessionSlice;
|
|
97
|
+
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
11
98
|
}
|
|
12
99
|
|
|
13
|
-
|
|
100
|
+
// fallow-ignore-next-line complexity
|
|
101
|
+
function useKeyframeToggle(session?: DomEditSessionSlice) {
|
|
102
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
103
|
+
if (!session) return { state: "none" as const, onToggle: undefined };
|
|
104
|
+
|
|
105
|
+
const sel = session.domEditSelection;
|
|
106
|
+
const anims = session.selectedGsapAnimations;
|
|
107
|
+
const kfAnim = anims.find((a) => a.keyframes);
|
|
108
|
+
const flatAnim = anims.find((a) => !a.keyframes);
|
|
109
|
+
|
|
110
|
+
let state: "active" | "inactive" | "none" = "none";
|
|
111
|
+
if (kfAnim?.keyframes && sel) {
|
|
112
|
+
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
|
113
|
+
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
|
114
|
+
const pct =
|
|
115
|
+
elDuration > 0
|
|
116
|
+
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
|
117
|
+
: 0;
|
|
118
|
+
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
|
|
119
|
+
? "active"
|
|
120
|
+
: "inactive";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// fallow-ignore-next-line complexity
|
|
124
|
+
const onToggle = sel
|
|
125
|
+
? async () => {
|
|
126
|
+
const t = usePlayerStore.getState().currentTime;
|
|
127
|
+
if (kfAnim?.keyframes) {
|
|
128
|
+
if (kfAnim.hasUnresolvedKeyframes) {
|
|
129
|
+
await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
|
|
130
|
+
}
|
|
131
|
+
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
|
|
132
|
+
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
|
|
133
|
+
const pct =
|
|
134
|
+
elDuration > 0
|
|
135
|
+
? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10))
|
|
136
|
+
: 0;
|
|
137
|
+
const existing = kfAnim.keyframes.keyframes.find(
|
|
138
|
+
(k) => Math.abs(k.percentage - pct) <= 1,
|
|
139
|
+
);
|
|
140
|
+
if (existing) {
|
|
141
|
+
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
|
142
|
+
} else {
|
|
143
|
+
const runtimeValues = readRuntimeKeyframeValues(
|
|
144
|
+
session.previewIframeRef?.current ?? null,
|
|
145
|
+
sel,
|
|
146
|
+
kfAnim.keyframes.keyframes,
|
|
147
|
+
);
|
|
148
|
+
const values =
|
|
149
|
+
Object.keys(runtimeValues).length > 0
|
|
150
|
+
? runtimeValues
|
|
151
|
+
: interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct);
|
|
152
|
+
for (const [prop, val] of Object.entries(values)) {
|
|
153
|
+
session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else if (flatAnim) {
|
|
157
|
+
session.handleGsapConvertToKeyframes(flatAnim.id);
|
|
158
|
+
} else {
|
|
159
|
+
session.handleGsapAddAnimation("to");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
: undefined;
|
|
163
|
+
|
|
164
|
+
return { state, onToggle };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function TimelineToolbar({
|
|
168
|
+
toggleTimelineVisibility,
|
|
169
|
+
domEditSession,
|
|
170
|
+
onSplitElement,
|
|
171
|
+
}: TimelineToolbarProps) {
|
|
14
172
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
15
173
|
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
16
174
|
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
17
175
|
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
18
176
|
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
177
|
+
const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession);
|
|
19
178
|
|
|
20
179
|
return (
|
|
21
180
|
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
|
|
22
181
|
<div className="flex items-center justify-between px-3 py-2">
|
|
23
|
-
<div className="
|
|
24
|
-
|
|
182
|
+
<div className="flex items-center gap-3">
|
|
183
|
+
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
184
|
+
Timeline
|
|
185
|
+
</div>
|
|
186
|
+
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && (
|
|
187
|
+
<Tooltip
|
|
188
|
+
label={
|
|
189
|
+
keyframeState === "active"
|
|
190
|
+
? "Remove keyframe at playhead"
|
|
191
|
+
: keyframeState === "inactive"
|
|
192
|
+
? "Add keyframe at playhead"
|
|
193
|
+
: "Enable keyframes"
|
|
194
|
+
}
|
|
195
|
+
>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onClick={onToggleKeyframe}
|
|
199
|
+
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
|
200
|
+
keyframeState === "active"
|
|
201
|
+
? "text-studio-accent"
|
|
202
|
+
: keyframeState === "inactive"
|
|
203
|
+
? "text-neutral-400 hover:text-studio-accent"
|
|
204
|
+
: "text-neutral-600 hover:text-neutral-400"
|
|
205
|
+
}`}
|
|
206
|
+
>
|
|
207
|
+
<svg width="18" height="18" viewBox="0 0 10 10" fill="currentColor">
|
|
208
|
+
{keyframeState === "active" ? (
|
|
209
|
+
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
|
|
210
|
+
) : (
|
|
211
|
+
<path
|
|
212
|
+
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
|
|
213
|
+
fill="none"
|
|
214
|
+
stroke="currentColor"
|
|
215
|
+
strokeWidth="1.2"
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
</svg>
|
|
219
|
+
</button>
|
|
220
|
+
</Tooltip>
|
|
221
|
+
)}
|
|
222
|
+
{onSplitElement &&
|
|
223
|
+
(() => {
|
|
224
|
+
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
|
225
|
+
const el = selectedElementId
|
|
226
|
+
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
|
|
227
|
+
: null;
|
|
228
|
+
const splittable =
|
|
229
|
+
el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag);
|
|
230
|
+
if (!splittable) return null;
|
|
231
|
+
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
|
|
232
|
+
return (
|
|
233
|
+
<Tooltip label="Split clip at playhead (S)">
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
disabled={!canSplit}
|
|
237
|
+
onClick={() => {
|
|
238
|
+
if (canSplit) onSplitElement(el, currentTime);
|
|
239
|
+
}}
|
|
240
|
+
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
|
241
|
+
canSplit
|
|
242
|
+
? "text-neutral-500 hover:text-neutral-200"
|
|
243
|
+
: "text-neutral-700 cursor-not-allowed"
|
|
244
|
+
}`}
|
|
245
|
+
>
|
|
246
|
+
<Scissors size={15} />
|
|
247
|
+
</button>
|
|
248
|
+
</Tooltip>
|
|
249
|
+
);
|
|
250
|
+
})()}
|
|
25
251
|
</div>
|
|
26
252
|
<div className="flex items-center gap-1">
|
|
27
253
|
<Tooltip label="Fit timeline to width">
|