@hyperframes/studio 0.6.86 → 0.6.87
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-BA19FAPN.js +143 -0
- package/dist/assets/index-CGlIm_-E.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +159 -6
- package/src/components/StudioHeader.tsx +20 -7
- package/src/components/StudioPreviewArea.tsx +6 -1
- package/src/components/StudioRightPanel.tsx +13 -0
- package/src/components/StudioToast.tsx +47 -7
- package/src/components/TimelineToolbar.tsx +12 -122
- package/src/components/editor/AnimationCard.tsx +64 -10
- package/src/components/editor/ArcPathControls.tsx +131 -0
- package/src/components/editor/BorderRadiusEditor.tsx +209 -0
- package/src/components/editor/DomEditOverlay.tsx +70 -11
- package/src/components/editor/DopesheetStrip.tsx +141 -0
- package/src/components/editor/EaseCurveSection.tsx +82 -7
- package/src/components/editor/GestureTrailOverlay.tsx +132 -0
- package/src/components/editor/GsapAnimationSection.tsx +14 -1
- package/src/components/editor/KeyframeDiamond.tsx +27 -12
- package/src/components/editor/LayersPanel.tsx +14 -12
- package/src/components/editor/MotionPathOverlay.tsx +146 -0
- package/src/components/editor/PropertyPanel.tsx +196 -66
- package/src/components/editor/SourceEditor.tsx +0 -1
- package/src/components/editor/StaggerControls.tsx +61 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
- package/src/components/editor/domEditOverlayGeometry.ts +2 -1
- package/src/components/editor/domEditing.test.ts +43 -0
- package/src/components/editor/domEditing.ts +2 -0
- package/src/components/editor/domEditingElement.ts +25 -2
- package/src/components/editor/domEditingLayers.test.ts +78 -0
- package/src/components/editor/domEditingLayers.ts +33 -13
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/manualEditingAvailability.ts +1 -1
- package/src/components/editor/manualEdits.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +23 -5
- package/src/components/editor/manualOffsetDrag.ts +59 -0
- package/src/components/editor/panelTokens.ts +10 -0
- package/src/components/editor/propertyPanelColor.tsx +2 -2
- package/src/components/editor/propertyPanelFill.tsx +1 -1
- package/src/components/editor/propertyPanelHelpers.ts +18 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
- package/src/components/editor/propertyPanelSections.tsx +4 -6
- package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
- package/src/components/editor/useDomEditOverlayRects.ts +46 -2
- package/src/components/renders/RenderQueue.tsx +121 -100
- package/src/components/renders/RenderQueueItem.tsx +13 -13
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +0 -4
- package/src/hooks/gsapKeyframeCommit.ts +92 -0
- package/src/hooks/gsapRuntimeBridge.ts +147 -85
- package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
- package/src/hooks/gsapRuntimePreview.ts +19 -0
- package/src/hooks/useAppHotkeys.ts +18 -0
- package/src/hooks/useAskAgentModal.ts +2 -4
- package/src/hooks/useDomEditCommits.ts +11 -17
- package/src/hooks/useDomEditSession.ts +47 -4
- package/src/hooks/useEnableKeyframes.ts +171 -0
- package/src/hooks/useFileManager.ts +7 -0
- package/src/hooks/useGestureRecording.ts +340 -0
- package/src/hooks/useGsapScriptCommits.ts +171 -35
- package/src/hooks/useGsapSelectionHandlers.ts +27 -8
- package/src/hooks/useGsapTweenCache.ts +169 -11
- package/src/hooks/useKeyframeKeyboard.ts +103 -0
- package/src/hooks/useStudioContextValue.ts +5 -4
- package/src/hooks/useStudioUrlState.ts +1 -2
- package/src/hooks/useTimelineEditing.ts +50 -3
- package/src/hooks/useToast.ts +6 -1
- package/src/player/components/ShortcutsPanel.tsx +40 -0
- package/src/player/components/TimelineClipDiamonds.tsx +3 -3
- package/src/player/components/TimelinePropertyRows.tsx +120 -0
- package/src/player/lib/timelineDOM.test.ts +55 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +43 -0
- package/src/utils/audioBeatDetection.ts +58 -0
- package/src/utils/globalTimeCompiler.test.ts +169 -0
- package/src/utils/globalTimeCompiler.ts +77 -0
- package/src/utils/gsapSoftReload.ts +30 -10
- package/src/utils/keyframeSnapping.test.ts +74 -0
- package/src/utils/keyframeSnapping.ts +63 -0
- package/src/utils/rdpSimplify.ts +183 -0
- package/src/utils/sourcePatcher.ts +2 -0
- package/dist/assets/index-BT9VHgSy.js +0 -140
- package/dist/assets/index-DHcptK1_.css +0 -1
|
@@ -1,8 +1,72 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
2
|
-
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
|
3
4
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
5
|
import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
|
|
5
6
|
|
|
7
|
+
function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
|
|
8
|
+
const byPct = new Map<number, GsapPercentageKeyframe>();
|
|
9
|
+
for (const kf of keyframes) {
|
|
10
|
+
const existing = byPct.get(kf.percentage);
|
|
11
|
+
if (existing) {
|
|
12
|
+
existing.properties = { ...existing.properties, ...kf.properties };
|
|
13
|
+
if (kf.ease) existing.ease = kf.ease;
|
|
14
|
+
} else {
|
|
15
|
+
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const PROPERTY_DEFAULTS: Record<string, number> = {
|
|
22
|
+
opacity: 1,
|
|
23
|
+
x: 0,
|
|
24
|
+
y: 0,
|
|
25
|
+
scale: 1,
|
|
26
|
+
scaleX: 1,
|
|
27
|
+
scaleY: 1,
|
|
28
|
+
rotation: 0,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframesData | null {
|
|
32
|
+
if (anim.method === "set") {
|
|
33
|
+
return {
|
|
34
|
+
format: "percentage",
|
|
35
|
+
keyframes: [{ percentage: 0, properties: { ...anim.properties } }],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const toProps = anim.properties;
|
|
39
|
+
const fromProps = anim.fromProperties;
|
|
40
|
+
if (!toProps || Object.keys(toProps).length === 0) return null;
|
|
41
|
+
|
|
42
|
+
const startProps: Record<string, number | string> = {};
|
|
43
|
+
const endProps: Record<string, number | string> = {};
|
|
44
|
+
|
|
45
|
+
if (anim.method === "from") {
|
|
46
|
+
for (const [k, v] of Object.entries(toProps)) {
|
|
47
|
+
startProps[k] = v;
|
|
48
|
+
endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
|
49
|
+
}
|
|
50
|
+
} else if (anim.method === "fromTo" && fromProps) {
|
|
51
|
+
Object.assign(startProps, fromProps);
|
|
52
|
+
Object.assign(endProps, toProps);
|
|
53
|
+
} else {
|
|
54
|
+
for (const [k, v] of Object.entries(toProps)) {
|
|
55
|
+
startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
|
56
|
+
endProps[k] = v;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
format: "percentage",
|
|
62
|
+
keyframes: [
|
|
63
|
+
{ percentage: 0, properties: startProps },
|
|
64
|
+
{ percentage: 100, properties: endProps },
|
|
65
|
+
],
|
|
66
|
+
...(anim.ease ? { ease: anim.ease } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
6
70
|
function extractIdFromSelector(selector: string): string | null {
|
|
7
71
|
const match = selector.match(/^#([\w-]+)/);
|
|
8
72
|
return match ? match[1] : null;
|
|
@@ -31,7 +95,12 @@ export function getAnimationsForElement(
|
|
|
31
95
|
if (target.selector) matchers.add(target.selector);
|
|
32
96
|
if (matchers.size === 0) return [];
|
|
33
97
|
return animations.filter((a) =>
|
|
34
|
-
a.targetSelector.split(",").some((part) =>
|
|
98
|
+
a.targetSelector.split(",").some((part) => {
|
|
99
|
+
const trimmed = part.trim();
|
|
100
|
+
if (matchers.has(trimmed)) return true;
|
|
101
|
+
const lastSimple = trimmed.split(/\s+/).pop();
|
|
102
|
+
return lastSimple ? matchers.has(lastSimple) : false;
|
|
103
|
+
}),
|
|
35
104
|
);
|
|
36
105
|
}
|
|
37
106
|
|
|
@@ -182,12 +251,60 @@ export function useGsapAnimationsForElement(
|
|
|
182
251
|
|
|
183
252
|
// Populate keyframe cache for the selected element.
|
|
184
253
|
// Key format must match timeline element keys: "sourceFile#domId".
|
|
254
|
+
// Merges keyframes from ALL animations targeting this element and synthesizes
|
|
255
|
+
// flat tweens so the cache is never downgraded vs the bulk populate.
|
|
185
256
|
const elementId = target?.id ?? null;
|
|
186
257
|
useEffect(() => {
|
|
187
258
|
if (!elementId) return;
|
|
259
|
+
|
|
260
|
+
// Resolve the element's time range from the player store so we can
|
|
261
|
+
// convert tween-relative keyframe percentages to clip-relative ones.
|
|
262
|
+
const { elements } = usePlayerStore.getState();
|
|
263
|
+
const timelineEl = elements.find(
|
|
264
|
+
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
|
265
|
+
);
|
|
266
|
+
const elStart = timelineEl?.start ?? 0;
|
|
267
|
+
const elDuration = timelineEl?.duration ?? 4;
|
|
268
|
+
|
|
269
|
+
const allKeyframes: GsapKeyframesData["keyframes"] = [];
|
|
270
|
+
let format: GsapKeyframesData["format"] = "percentage";
|
|
271
|
+
let ease: string | undefined;
|
|
272
|
+
let easeEach: string | undefined;
|
|
273
|
+
for (const anim of animations) {
|
|
274
|
+
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
275
|
+
if (!kf) continue;
|
|
276
|
+
// Convert tween-relative percentages to clip-relative so diamonds
|
|
277
|
+
// render at the correct position within the timeline clip.
|
|
278
|
+
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
|
|
279
|
+
const tweenDur = anim.duration ?? elDuration;
|
|
280
|
+
for (const k of kf.keyframes) {
|
|
281
|
+
const absTime = tweenPos + (k.percentage / 100) * tweenDur;
|
|
282
|
+
const clipPct =
|
|
283
|
+
elDuration > 0
|
|
284
|
+
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
|
285
|
+
: k.percentage;
|
|
286
|
+
allKeyframes.push({ ...k, percentage: clipPct });
|
|
287
|
+
}
|
|
288
|
+
format = kf.format;
|
|
289
|
+
if (kf.ease) ease = kf.ease;
|
|
290
|
+
if (kf.easeEach) easeEach = kf.easeEach;
|
|
291
|
+
}
|
|
292
|
+
if (allKeyframes.length === 0) {
|
|
293
|
+
const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
|
|
294
|
+
if (keyframeCache.has(`${sourceFile}#${elementId}`)) {
|
|
295
|
+
setKeyframeCache(`${sourceFile}#${elementId}`, undefined);
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
|
|
300
|
+
const merged: GsapKeyframesData = {
|
|
301
|
+
format,
|
|
302
|
+
keyframes: dedupedKeyframes,
|
|
303
|
+
...(ease ? { ease } : {}),
|
|
304
|
+
...(easeEach ? { easeEach } : {}),
|
|
305
|
+
};
|
|
188
306
|
const { setKeyframeCache } = usePlayerStore.getState();
|
|
189
|
-
|
|
190
|
-
setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined);
|
|
307
|
+
setKeyframeCache(`${sourceFile}#${elementId}`, merged);
|
|
191
308
|
}, [elementId, sourceFile, animations]);
|
|
192
309
|
|
|
193
310
|
return { animations, multipleTimelines, unsupportedTimelinePattern };
|
|
@@ -213,25 +330,63 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
213
330
|
const lastFetchKeyRef = useRef("");
|
|
214
331
|
|
|
215
332
|
const runtimeScanDoneRef = useRef("");
|
|
333
|
+
const astFetchDoneRef = useRef("");
|
|
216
334
|
|
|
217
335
|
useEffect(() => {
|
|
218
336
|
const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
|
|
219
337
|
if (fetchKey === lastFetchKeyRef.current) return;
|
|
220
338
|
lastFetchKeyRef.current = fetchKey;
|
|
221
339
|
runtimeScanDoneRef.current = "";
|
|
340
|
+
astFetchDoneRef.current = "";
|
|
222
341
|
if (!projectId) return;
|
|
223
342
|
|
|
224
343
|
const sf = sourceFile;
|
|
225
344
|
fetchParsedAnimations(projectId, sf).then((parsed) => {
|
|
226
345
|
if (!parsed) return;
|
|
227
|
-
const { setKeyframeCache } = usePlayerStore.getState();
|
|
346
|
+
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
|
|
347
|
+
const sfPrefix = `${sf}#`;
|
|
348
|
+
const fallbackPrefix = "index.html#";
|
|
349
|
+
for (const key of keyframeCache.keys()) {
|
|
350
|
+
if (key.startsWith(sfPrefix) || (sf !== "index.html" && key.startsWith(fallbackPrefix))) {
|
|
351
|
+
setKeyframeCache(key, undefined);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const { elements } = usePlayerStore.getState();
|
|
355
|
+
const mergedByElement = new Map<string, GsapKeyframesData>();
|
|
228
356
|
for (const anim of parsed.animations) {
|
|
229
357
|
const id = extractIdFromSelector(anim.targetSelector);
|
|
230
|
-
if (!id
|
|
231
|
-
|
|
232
|
-
if (
|
|
358
|
+
if (!id) continue;
|
|
359
|
+
const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
360
|
+
if (!kfData) continue;
|
|
361
|
+
// Convert tween-relative percentages to clip-relative.
|
|
362
|
+
const tweenPos = typeof anim.position === "number" ? anim.position : 0;
|
|
363
|
+
const tweenDur = anim.duration ?? 1;
|
|
364
|
+
const timelineEl = elements.find(
|
|
365
|
+
(el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`,
|
|
366
|
+
);
|
|
367
|
+
const elStart = timelineEl?.start ?? 0;
|
|
368
|
+
const elDuration = timelineEl?.duration ?? 4;
|
|
369
|
+
const clipKeyframes = kfData.keyframes.map((kf) => {
|
|
370
|
+
const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
|
|
371
|
+
const clipPct =
|
|
372
|
+
elDuration > 0
|
|
373
|
+
? Math.round(((absTime - elStart) / elDuration) * 1000) / 10
|
|
374
|
+
: kf.percentage;
|
|
375
|
+
return { ...kf, percentage: clipPct };
|
|
376
|
+
});
|
|
377
|
+
const existing = mergedByElement.get(id);
|
|
378
|
+
if (existing) {
|
|
379
|
+
existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]);
|
|
380
|
+
} else {
|
|
381
|
+
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const [id, kfData] of mergedByElement) {
|
|
385
|
+
setKeyframeCache(`${sf}#${id}`, kfData);
|
|
386
|
+
setKeyframeCache(id, kfData);
|
|
387
|
+
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
|
|
233
388
|
}
|
|
234
|
-
|
|
389
|
+
astFetchDoneRef.current = fetchKey;
|
|
235
390
|
});
|
|
236
391
|
}, [projectId, sourceFile, version]);
|
|
237
392
|
|
|
@@ -246,7 +401,8 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
246
401
|
|
|
247
402
|
const tryRuntimeScan = () => {
|
|
248
403
|
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
|
|
249
|
-
const iframe =
|
|
404
|
+
const iframe =
|
|
405
|
+
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
|
|
250
406
|
if (!iframe) return false;
|
|
251
407
|
const scanned = scanAllRuntimeKeyframes(iframe);
|
|
252
408
|
if (scanned.size === 0) return false;
|
|
@@ -254,7 +410,8 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
254
410
|
for (const [id, data] of scanned) {
|
|
255
411
|
const cacheKey = `${sf}#${id}`;
|
|
256
412
|
const fallbackKey = `index.html#${id}`;
|
|
257
|
-
if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey))
|
|
413
|
+
if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey) || keyframeCache.has(id))
|
|
414
|
+
continue;
|
|
258
415
|
const entry = {
|
|
259
416
|
format: "percentage" as const,
|
|
260
417
|
keyframes: data.keyframes,
|
|
@@ -262,6 +419,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
262
419
|
};
|
|
263
420
|
setKeyframeCache(cacheKey, entry);
|
|
264
421
|
if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
|
|
422
|
+
setKeyframeCache(id, entry);
|
|
265
423
|
}
|
|
266
424
|
runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
|
|
267
425
|
return true;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useEffect, useCallback } from "react";
|
|
2
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
3
|
+
|
|
4
|
+
interface KeyframeKeyboardOptions {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
onAddKeyframe?: () => void;
|
|
7
|
+
onDeleteKeyframe?: () => void;
|
|
8
|
+
onPrevKeyframe?: () => void;
|
|
9
|
+
onNextKeyframe?: () => void;
|
|
10
|
+
onToggleHold?: () => void;
|
|
11
|
+
onToggleExpand?: () => void;
|
|
12
|
+
onNudgeKeyframe?: (direction: -1 | 1, large: boolean) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isTextInput(el: Element | null): boolean {
|
|
16
|
+
if (!el) return false;
|
|
17
|
+
const tag = el.tagName;
|
|
18
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
19
|
+
return (el as HTMLElement).isContentEditable === true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useKeyframeKeyboard({
|
|
23
|
+
enabled,
|
|
24
|
+
onAddKeyframe,
|
|
25
|
+
onDeleteKeyframe,
|
|
26
|
+
onPrevKeyframe,
|
|
27
|
+
onNextKeyframe,
|
|
28
|
+
onToggleHold,
|
|
29
|
+
onToggleExpand,
|
|
30
|
+
onNudgeKeyframe,
|
|
31
|
+
}: KeyframeKeyboardOptions): void {
|
|
32
|
+
const handler = useCallback(
|
|
33
|
+
(e: KeyboardEvent) => {
|
|
34
|
+
if (!enabled) return;
|
|
35
|
+
if (isTextInput(document.activeElement)) return;
|
|
36
|
+
|
|
37
|
+
const hasSelectedKeyframes = usePlayerStore.getState().selectedKeyframes.size > 0;
|
|
38
|
+
|
|
39
|
+
switch (e.key.toLowerCase()) {
|
|
40
|
+
case "k":
|
|
41
|
+
if (!e.metaKey && !e.ctrlKey) {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
onAddKeyframe?.();
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
case "delete":
|
|
47
|
+
case "backspace":
|
|
48
|
+
if (hasSelectedKeyframes) {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
onDeleteKeyframe?.();
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
case "j":
|
|
54
|
+
if (!e.metaKey && !e.ctrlKey) {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
if (e.shiftKey) onNextKeyframe?.();
|
|
57
|
+
else onPrevKeyframe?.();
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
case "h":
|
|
61
|
+
if (!e.metaKey && !e.ctrlKey && hasSelectedKeyframes) {
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
onToggleHold?.();
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
case "u":
|
|
67
|
+
if (!e.metaKey && !e.ctrlKey) {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
onToggleExpand?.();
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
case "arrowleft":
|
|
73
|
+
if (hasSelectedKeyframes && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
onNudgeKeyframe?.(-1, e.shiftKey);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
case "arrowright":
|
|
79
|
+
if (hasSelectedKeyframes && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
onNudgeKeyframe?.(1, e.shiftKey);
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[
|
|
87
|
+
enabled,
|
|
88
|
+
onAddKeyframe,
|
|
89
|
+
onDeleteKeyframe,
|
|
90
|
+
onPrevKeyframe,
|
|
91
|
+
onNextKeyframe,
|
|
92
|
+
onToggleHold,
|
|
93
|
+
onToggleExpand,
|
|
94
|
+
onNudgeKeyframe,
|
|
95
|
+
],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!enabled) return;
|
|
100
|
+
window.addEventListener("keydown", handler);
|
|
101
|
+
return () => window.removeEventListener("keydown", handler);
|
|
102
|
+
}, [enabled, handler]);
|
|
103
|
+
}
|
|
@@ -17,7 +17,6 @@ interface StudioContextInput {
|
|
|
17
17
|
compositionLoading: boolean;
|
|
18
18
|
refreshKey: number;
|
|
19
19
|
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
|
20
|
-
currentTime: number;
|
|
21
20
|
timelineElements: StudioContextValue["timelineElements"];
|
|
22
21
|
isPlaying: boolean;
|
|
23
22
|
editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string };
|
|
@@ -50,7 +49,7 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
|
|
|
50
49
|
compositionLoading: input.compositionLoading,
|
|
51
50
|
refreshKey: input.refreshKey,
|
|
52
51
|
setRefreshKey: input.setRefreshKey,
|
|
53
|
-
|
|
52
|
+
|
|
54
53
|
timelineElements: input.timelineElements,
|
|
55
54
|
isPlaying: input.isPlaying,
|
|
56
55
|
editHistory: input.editHistory,
|
|
@@ -81,6 +80,7 @@ export function useInspectorState(
|
|
|
81
80
|
rightCollapsed: boolean,
|
|
82
81
|
isPlaying: boolean,
|
|
83
82
|
domEditSelection: DomEditSelection | null,
|
|
83
|
+
isGestureRecording?: boolean,
|
|
84
84
|
): InspectorState {
|
|
85
85
|
// fallow-ignore-next-line complexity
|
|
86
86
|
return useMemo(() => {
|
|
@@ -101,9 +101,10 @@ export function useInspectorState(
|
|
|
101
101
|
inspectorPanelActive,
|
|
102
102
|
inspectorButtonActive:
|
|
103
103
|
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
|
|
104
|
-
shouldShowSelectedDomBounds:
|
|
104
|
+
shouldShowSelectedDomBounds:
|
|
105
|
+
inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording,
|
|
105
106
|
};
|
|
106
|
-
}, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection]);
|
|
107
|
+
}, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection, isGestureRecording]);
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
// fallow-ignore-next-line complexity
|
|
@@ -11,7 +11,6 @@ import {
|
|
|
11
11
|
interface UseStudioUrlStateParams {
|
|
12
12
|
projectId: string | null;
|
|
13
13
|
activeCompPath: string | null;
|
|
14
|
-
currentTime: number;
|
|
15
14
|
duration: number;
|
|
16
15
|
isPlaying: boolean;
|
|
17
16
|
compositionLoading: boolean;
|
|
@@ -57,7 +56,6 @@ function replaceHash(nextHash: string) {
|
|
|
57
56
|
export function useStudioUrlState({
|
|
58
57
|
projectId,
|
|
59
58
|
activeCompPath,
|
|
60
|
-
currentTime,
|
|
61
59
|
duration,
|
|
62
60
|
isPlaying,
|
|
63
61
|
compositionLoading,
|
|
@@ -72,6 +70,7 @@ export function useStudioUrlState({
|
|
|
72
70
|
applyDomSelection,
|
|
73
71
|
initialState,
|
|
74
72
|
}: UseStudioUrlStateParams) {
|
|
73
|
+
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
75
74
|
const hydratedSeekRef = useRef(initialState.currentTime == null);
|
|
76
75
|
const hydratedInitialTimeRef = useRef(initialState.currentTime == null);
|
|
77
76
|
const hydratedSelectionRef = useRef(initialState.selection == null);
|
|
@@ -41,13 +41,27 @@ interface UseTimelineEditingOptions {
|
|
|
41
41
|
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
42
42
|
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
43
43
|
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
44
|
+
isRecordingRef?: React.RefObject<boolean>;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// ── Helpers ──
|
|
47
48
|
|
|
48
|
-
function buildPatchTarget(element: {
|
|
49
|
+
function buildPatchTarget(element: {
|
|
50
|
+
domId?: string;
|
|
51
|
+
hfId?: string;
|
|
52
|
+
selector?: string;
|
|
53
|
+
selectorIndex?: number;
|
|
54
|
+
}) {
|
|
49
55
|
if (element.domId) {
|
|
50
|
-
return {
|
|
56
|
+
return {
|
|
57
|
+
id: element.domId,
|
|
58
|
+
hfId: element.hfId,
|
|
59
|
+
selector: element.selector,
|
|
60
|
+
selectorIndex: element.selectorIndex,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (element.hfId) {
|
|
64
|
+
return { hfId: element.hfId, selector: element.selector, selectorIndex: element.selectorIndex };
|
|
51
65
|
}
|
|
52
66
|
if (element.selector) {
|
|
53
67
|
return { selector: element.selector, selectorIndex: element.selectorIndex };
|
|
@@ -174,6 +188,7 @@ export function useTimelineEditing({
|
|
|
174
188
|
previewIframeRef,
|
|
175
189
|
pendingTimelineEditPathRef,
|
|
176
190
|
uploadProjectFiles,
|
|
191
|
+
isRecordingRef,
|
|
177
192
|
}: UseTimelineEditingOptions) {
|
|
178
193
|
const projectIdRef = useRef(projectId);
|
|
179
194
|
projectIdRef.current = projectId;
|
|
@@ -187,6 +202,10 @@ export function useTimelineEditing({
|
|
|
187
202
|
label: string,
|
|
188
203
|
buildPatches: PersistTimelineEditInput["buildPatches"],
|
|
189
204
|
): Promise<void> => {
|
|
205
|
+
if (isRecordingRef?.current) {
|
|
206
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
207
|
+
return Promise.resolve();
|
|
208
|
+
}
|
|
190
209
|
const pid = projectIdRef.current;
|
|
191
210
|
if (!pid) return Promise.resolve();
|
|
192
211
|
const queued = editQueueRef.current.then(() =>
|
|
@@ -213,6 +232,8 @@ export function useTimelineEditing({
|
|
|
213
232
|
writeProjectFile,
|
|
214
233
|
domEditSaveTimestampRef,
|
|
215
234
|
pendingTimelineEditPathRef,
|
|
235
|
+
showToast,
|
|
236
|
+
isRecordingRef,
|
|
216
237
|
],
|
|
217
238
|
);
|
|
218
239
|
|
|
@@ -274,6 +295,10 @@ export function useTimelineEditing({
|
|
|
274
295
|
|
|
275
296
|
const handleTimelineElementDelete = useCallback(
|
|
276
297
|
async (element: TimelineElement) => {
|
|
298
|
+
if (isRecordingRef?.current) {
|
|
299
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
277
302
|
const pid = projectIdRef.current;
|
|
278
303
|
if (!pid) throw new Error("No active project");
|
|
279
304
|
const label = getTimelineElementLabel(element);
|
|
@@ -338,6 +363,7 @@ export function useTimelineEditing({
|
|
|
338
363
|
writeProjectFile,
|
|
339
364
|
domEditSaveTimestampRef,
|
|
340
365
|
reloadPreview,
|
|
366
|
+
isRecordingRef,
|
|
341
367
|
],
|
|
342
368
|
);
|
|
343
369
|
|
|
@@ -347,6 +373,10 @@ export function useTimelineEditing({
|
|
|
347
373
|
placement: Pick<TimelineElement, "start" | "track">,
|
|
348
374
|
durationOverride?: number,
|
|
349
375
|
) => {
|
|
376
|
+
if (isRecordingRef?.current) {
|
|
377
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
350
380
|
const pid = projectIdRef.current;
|
|
351
381
|
if (!pid) throw new Error("No active project");
|
|
352
382
|
|
|
@@ -415,11 +445,16 @@ export function useTimelineEditing({
|
|
|
415
445
|
writeProjectFile,
|
|
416
446
|
domEditSaveTimestampRef,
|
|
417
447
|
reloadPreview,
|
|
448
|
+
isRecordingRef,
|
|
418
449
|
],
|
|
419
450
|
);
|
|
420
451
|
|
|
421
452
|
const handleTimelineFileDrop = useCallback(
|
|
422
453
|
async (files: File[], placement?: Pick<TimelineElement, "start" | "track">) => {
|
|
454
|
+
if (isRecordingRef?.current) {
|
|
455
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
423
458
|
const pid = projectIdRef.current;
|
|
424
459
|
if (!pid) return;
|
|
425
460
|
const uploaded = await uploadProjectFiles(files);
|
|
@@ -453,7 +488,14 @@ export function useTimelineEditing({
|
|
|
453
488
|
);
|
|
454
489
|
}
|
|
455
490
|
},
|
|
456
|
-
[
|
|
491
|
+
[
|
|
492
|
+
activeCompPath,
|
|
493
|
+
handleTimelineAssetDrop,
|
|
494
|
+
timelineElements,
|
|
495
|
+
uploadProjectFiles,
|
|
496
|
+
isRecordingRef,
|
|
497
|
+
showToast,
|
|
498
|
+
],
|
|
457
499
|
);
|
|
458
500
|
|
|
459
501
|
const handleBlockedTimelineEdit = useCallback(
|
|
@@ -468,6 +510,10 @@ export function useTimelineEditing({
|
|
|
468
510
|
|
|
469
511
|
const handleTimelineElementSplit = useCallback(
|
|
470
512
|
async (element: TimelineElement, splitTime: number) => {
|
|
513
|
+
if (isRecordingRef?.current) {
|
|
514
|
+
showToast("Cannot edit timeline while recording", "error");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
471
517
|
const pid = projectIdRef.current;
|
|
472
518
|
if (!pid) return;
|
|
473
519
|
|
|
@@ -555,6 +601,7 @@ export function useTimelineEditing({
|
|
|
555
601
|
writeProjectFile,
|
|
556
602
|
domEditSaveTimestampRef,
|
|
557
603
|
reloadPreview,
|
|
604
|
+
isRecordingRef,
|
|
558
605
|
],
|
|
559
606
|
);
|
|
560
607
|
|
package/src/hooks/useToast.ts
CHANGED
|
@@ -16,5 +16,10 @@ export function useToast() {
|
|
|
16
16
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
const dismissToast = useCallback(() => {
|
|
20
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
21
|
+
setAppToast(null);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
return { appToast, showToast, dismissToast };
|
|
20
25
|
}
|
|
@@ -17,6 +17,46 @@ const SHORTCUT_SECTIONS = [
|
|
|
17
17
|
{ key: "F", label: "Toggle fullscreen" },
|
|
18
18
|
],
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
title: "Keyframes",
|
|
22
|
+
hints: [
|
|
23
|
+
{ key: "K", label: "Add keyframe at playhead" },
|
|
24
|
+
{ key: "Del", label: "Delete selected keyframe" },
|
|
25
|
+
{ key: "H", label: "Toggle hold / bezier" },
|
|
26
|
+
{ key: "U", label: "Expand / collapse properties" },
|
|
27
|
+
{ key: "R", label: "Record gesture" },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
title: "Editing",
|
|
32
|
+
hints: [
|
|
33
|
+
{ key: "⌘Z", label: "Undo" },
|
|
34
|
+
{ key: "⌘⇧Z", label: "Redo" },
|
|
35
|
+
{ key: "⌘C", label: "Copy element" },
|
|
36
|
+
{ key: "⌘V", label: "Paste element" },
|
|
37
|
+
{ key: "⌘X", label: "Cut element" },
|
|
38
|
+
{ key: "S", label: "Split clip at playhead" },
|
|
39
|
+
{ key: "Del", label: "Delete selected element" },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
title: "Gesture recording modifiers",
|
|
44
|
+
hints: [
|
|
45
|
+
{ key: "Drag", label: "Record x / y position" },
|
|
46
|
+
{ key: "Scroll", label: "Record z depth" },
|
|
47
|
+
{ key: "⇧ Drag", label: "Record rotationX / rotationY" },
|
|
48
|
+
{ key: "⌥ Drag", label: "Record rotation" },
|
|
49
|
+
{ key: "⌘ Drag↕", label: "Record opacity" },
|
|
50
|
+
{ key: "⌘ Scroll", label: "Record scale" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
title: "Panels",
|
|
55
|
+
hints: [
|
|
56
|
+
{ key: "⌘1", label: "Compositions tab" },
|
|
57
|
+
{ key: "⌘2", label: "Assets tab" },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
20
60
|
{
|
|
21
61
|
title: "Work area",
|
|
22
62
|
hints: [
|
|
@@ -102,7 +102,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
102
102
|
const x2 = (kf.percentage / 100) * clipWidthPx;
|
|
103
103
|
return (
|
|
104
104
|
<div
|
|
105
|
-
key={`line-${prev.percentage}-${kf.percentage}`}
|
|
105
|
+
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
|
|
106
106
|
className="absolute"
|
|
107
107
|
style={{
|
|
108
108
|
left: x1,
|
|
@@ -118,7 +118,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
118
118
|
);
|
|
119
119
|
})}
|
|
120
120
|
|
|
121
|
-
{sorted.map((kf) => {
|
|
121
|
+
{sorted.map((kf, i) => {
|
|
122
122
|
const leftPx = (kf.percentage / 100) * clipWidthPx - half;
|
|
123
123
|
const kfKey = `${elementId}:${kf.percentage}`;
|
|
124
124
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
@@ -126,7 +126,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
126
126
|
const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3";
|
|
127
127
|
return (
|
|
128
128
|
<button
|
|
129
|
-
key={kf.percentage}
|
|
129
|
+
key={`${i}-${kf.percentage}`}
|
|
130
130
|
type="button"
|
|
131
131
|
className="absolute"
|
|
132
132
|
style={{
|