@hyperframes/studio 0.6.73 → 0.6.74
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-BcJO6Ej5.js +140 -0
- package/dist/assets/index-C2gBZ2km.css +1 -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/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/nle/NLELayout.tsx +18 -0
- package/src/contexts/DomEditContext.tsx +24 -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 +39 -4
- package/src/hooks/useDomEditSession.ts +177 -63
- 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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read GSAP keyframe data from the live runtime in the preview iframe.
|
|
3
|
+
* Used to discover dynamic keyframes that the AST parser can't resolve
|
|
4
|
+
* (loops, variables, computed selectors).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface RuntimeTween {
|
|
8
|
+
targets?: () => Element[];
|
|
9
|
+
vars?: Record<string, unknown>;
|
|
10
|
+
duration?: () => number;
|
|
11
|
+
startTime?: () => number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RuntimeTimeline {
|
|
15
|
+
getChildren?: (deep: boolean) => RuntimeTween[];
|
|
16
|
+
duration?: () => number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readRuntimeKeyframes(
|
|
20
|
+
iframe: HTMLIFrameElement | null,
|
|
21
|
+
selector: string,
|
|
22
|
+
compositionId?: string,
|
|
23
|
+
): {
|
|
24
|
+
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
|
25
|
+
easeEach?: string;
|
|
26
|
+
} | null {
|
|
27
|
+
if (!iframe?.contentWindow) return null;
|
|
28
|
+
|
|
29
|
+
let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
|
|
30
|
+
try {
|
|
31
|
+
timelines = (
|
|
32
|
+
iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
|
|
33
|
+
).__timelines;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (!timelines) return null;
|
|
38
|
+
|
|
39
|
+
const tlId = compositionId || Object.keys(timelines)[0];
|
|
40
|
+
if (!tlId) return null;
|
|
41
|
+
const timeline = timelines[tlId];
|
|
42
|
+
if (!timeline?.getChildren) return null;
|
|
43
|
+
|
|
44
|
+
let doc: Document | null = null;
|
|
45
|
+
try {
|
|
46
|
+
doc = iframe.contentDocument;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (!doc) return null;
|
|
51
|
+
|
|
52
|
+
const targetEl = doc.querySelector(selector);
|
|
53
|
+
if (!targetEl) return null;
|
|
54
|
+
|
|
55
|
+
for (const tween of timeline.getChildren(true)) {
|
|
56
|
+
if (!tween.targets || !tween.vars) continue;
|
|
57
|
+
let matches = false;
|
|
58
|
+
for (const t of tween.targets()) {
|
|
59
|
+
if (t === targetEl || (targetEl.id && t.id === targetEl.id)) {
|
|
60
|
+
matches = true;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!matches) continue;
|
|
65
|
+
|
|
66
|
+
const vars = tween.vars;
|
|
67
|
+
if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
|
|
68
|
+
|
|
69
|
+
const kfObj = vars.keyframes as Record<string, unknown>;
|
|
70
|
+
const result: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
|
|
71
|
+
let easeEach: string | undefined;
|
|
72
|
+
|
|
73
|
+
for (const [key, val] of Object.entries(kfObj)) {
|
|
74
|
+
if (key === "easeEach") {
|
|
75
|
+
if (typeof val === "string") easeEach = val;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
|
|
79
|
+
if (!pctMatch || !val || typeof val !== "object") continue;
|
|
80
|
+
const percentage = parseFloat(pctMatch[1]);
|
|
81
|
+
const properties: Record<string, number | string> = {};
|
|
82
|
+
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
|
|
83
|
+
if (pk === "ease") continue;
|
|
84
|
+
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
|
85
|
+
else if (typeof pv === "string") properties[pk] = pv;
|
|
86
|
+
}
|
|
87
|
+
if (Object.keys(properties).length > 0) {
|
|
88
|
+
result.push({ percentage, properties });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (result.length > 0) {
|
|
93
|
+
result.sort((a, b) => a.percentage - b.percentage);
|
|
94
|
+
return { keyframes: result, easeEach };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// fallow-ignore-next-line complexity
|
|
101
|
+
export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map<
|
|
102
|
+
string,
|
|
103
|
+
{
|
|
104
|
+
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
|
105
|
+
easeEach?: string;
|
|
106
|
+
}
|
|
107
|
+
> {
|
|
108
|
+
const result = new Map<
|
|
109
|
+
string,
|
|
110
|
+
{
|
|
111
|
+
keyframes: Array<{ percentage: number; properties: Record<string, number | string> }>;
|
|
112
|
+
easeEach?: string;
|
|
113
|
+
}
|
|
114
|
+
>();
|
|
115
|
+
if (!iframe?.contentWindow) return result;
|
|
116
|
+
|
|
117
|
+
let timelines: Record<string, RuntimeTimeline | undefined> | undefined;
|
|
118
|
+
try {
|
|
119
|
+
timelines = (
|
|
120
|
+
iframe.contentWindow as unknown as { __timelines?: Record<string, RuntimeTimeline> }
|
|
121
|
+
).__timelines;
|
|
122
|
+
} catch {
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
if (!timelines) return result;
|
|
126
|
+
|
|
127
|
+
for (const timeline of Object.values(timelines)) {
|
|
128
|
+
if (!timeline?.getChildren) continue;
|
|
129
|
+
for (const tween of timeline.getChildren(true)) {
|
|
130
|
+
if (!tween.targets || !tween.vars) continue;
|
|
131
|
+
const vars = tween.vars;
|
|
132
|
+
if (!vars.keyframes || typeof vars.keyframes !== "object") continue;
|
|
133
|
+
|
|
134
|
+
const kfObj = vars.keyframes as Record<string, unknown>;
|
|
135
|
+
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
136
|
+
[];
|
|
137
|
+
let easeEach: string | undefined;
|
|
138
|
+
|
|
139
|
+
for (const [key, val] of Object.entries(kfObj)) {
|
|
140
|
+
if (key === "easeEach") {
|
|
141
|
+
if (typeof val === "string") easeEach = val;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
|
|
145
|
+
if (!pctMatch || !val || typeof val !== "object") continue;
|
|
146
|
+
const percentage = parseFloat(pctMatch[1]);
|
|
147
|
+
const properties: Record<string, number | string> = {};
|
|
148
|
+
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
|
|
149
|
+
if (pk === "ease") continue;
|
|
150
|
+
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
|
151
|
+
else if (typeof pv === "string") properties[pk] = pv;
|
|
152
|
+
}
|
|
153
|
+
if (Object.keys(properties).length > 0) {
|
|
154
|
+
keyframes.push({ percentage, properties });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (keyframes.length === 0) continue;
|
|
159
|
+
keyframes.sort((a, b) => a.percentage - b.percentage);
|
|
160
|
+
|
|
161
|
+
for (const target of tween.targets()) {
|
|
162
|
+
const id = (target as HTMLElement).id;
|
|
163
|
+
if (id && !result.has(id)) {
|
|
164
|
+
result.set(id, { keyframes, easeEach });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified helper for committing any GSAP property value from the design panel.
|
|
3
|
+
*
|
|
4
|
+
* Handles three cases:
|
|
5
|
+
* 1. Animation with keyframes → add-keyframe at current percentage
|
|
6
|
+
* 2. Flat animation (no keyframes) → convert to keyframes, then add-keyframe
|
|
7
|
+
* 3. No animation → create tl.to(), convert to keyframes, then add-keyframe
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback } from "react";
|
|
10
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
11
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
12
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
13
|
+
import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge";
|
|
14
|
+
|
|
15
|
+
interface CommitAnimatedPropertyDeps {
|
|
16
|
+
selectedGsapAnimations: GsapAnimation[];
|
|
17
|
+
gsapCommitMutation:
|
|
18
|
+
| ((
|
|
19
|
+
selection: DomEditSelection,
|
|
20
|
+
mutation: Record<string, unknown>,
|
|
21
|
+
options: {
|
|
22
|
+
label: string;
|
|
23
|
+
coalesceKey?: string;
|
|
24
|
+
softReload?: boolean;
|
|
25
|
+
skipReload?: boolean;
|
|
26
|
+
},
|
|
27
|
+
) => Promise<void>)
|
|
28
|
+
| null;
|
|
29
|
+
addGsapAnimation: (
|
|
30
|
+
selection: DomEditSelection,
|
|
31
|
+
method: "to" | "from" | "set" | "fromTo",
|
|
32
|
+
currentTime?: number,
|
|
33
|
+
) => void;
|
|
34
|
+
convertToKeyframes: (selection: DomEditSelection, animId: string) => void;
|
|
35
|
+
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
36
|
+
bumpGsapCache: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function computePercentage(selection: DomEditSelection): number {
|
|
40
|
+
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
|
|
41
|
+
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
|
|
42
|
+
const currentTime = usePlayerStore.getState().currentTime;
|
|
43
|
+
return elDuration > 0
|
|
44
|
+
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
|
45
|
+
: 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function selectorFor(selection: DomEditSelection): string | null {
|
|
49
|
+
if (selection.id) return `#${selection.id}`;
|
|
50
|
+
if (selection.selector) return selection.selector;
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
|
55
|
+
const {
|
|
56
|
+
selectedGsapAnimations,
|
|
57
|
+
gsapCommitMutation,
|
|
58
|
+
addGsapAnimation,
|
|
59
|
+
previewIframeRef,
|
|
60
|
+
bumpGsapCache,
|
|
61
|
+
} = deps;
|
|
62
|
+
|
|
63
|
+
const commitAnimatedProperty = useCallback(
|
|
64
|
+
async (
|
|
65
|
+
selection: DomEditSelection,
|
|
66
|
+
property: string,
|
|
67
|
+
value: number | string,
|
|
68
|
+
): Promise<void> => {
|
|
69
|
+
if (!gsapCommitMutation) return;
|
|
70
|
+
|
|
71
|
+
const iframe = previewIframeRef.current;
|
|
72
|
+
const selector = selectorFor(selection);
|
|
73
|
+
const pct = computePercentage(selection);
|
|
74
|
+
|
|
75
|
+
let anim: GsapAnimation | undefined =
|
|
76
|
+
selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0];
|
|
77
|
+
|
|
78
|
+
// Case 3: No animation — create one first
|
|
79
|
+
if (!anim) {
|
|
80
|
+
addGsapAnimation(selection, "to");
|
|
81
|
+
// The addGsapAnimation triggers a reload. We need to wait for the cache
|
|
82
|
+
// to update. Use a small delay then bump cache to re-fetch.
|
|
83
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
84
|
+
bumpGsapCache();
|
|
85
|
+
// After creation, we can't proceed in this call — the animation isn't
|
|
86
|
+
// in our local state yet. The user's next edit will find it.
|
|
87
|
+
// For immediate feedback, trigger a convert-to-keyframes on the new animation.
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Case 2: Flat animation — convert to keyframes first
|
|
92
|
+
if (!anim.keyframes) {
|
|
93
|
+
await gsapCommitMutation(
|
|
94
|
+
selection,
|
|
95
|
+
{ type: "convert-to-keyframes", animationId: anim.id },
|
|
96
|
+
{ label: "Convert to keyframes", skipReload: true },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Read all currently animated properties from runtime for backfill
|
|
101
|
+
const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {};
|
|
102
|
+
|
|
103
|
+
// Build the properties object: all runtime props + the new value
|
|
104
|
+
const properties: Record<string, number | string> = { ...runtimeProps };
|
|
105
|
+
properties[property] = value;
|
|
106
|
+
|
|
107
|
+
// Compute backfill defaults for properties not in existing keyframes
|
|
108
|
+
const backfillDefaults: Record<string, number | string> = { ...runtimeProps };
|
|
109
|
+
if (!(property in runtimeProps) && selector) {
|
|
110
|
+
const cssVal = readGsapProperty(iframe, selector, property);
|
|
111
|
+
if (cssVal != null) backfillDefaults[property] = cssVal;
|
|
112
|
+
}
|
|
113
|
+
backfillDefaults[property] = typeof value === "number" ? value : value;
|
|
114
|
+
|
|
115
|
+
await gsapCommitMutation(
|
|
116
|
+
selection,
|
|
117
|
+
{
|
|
118
|
+
type: "add-keyframe",
|
|
119
|
+
animationId: anim.id,
|
|
120
|
+
percentage: pct,
|
|
121
|
+
properties,
|
|
122
|
+
backfillDefaults,
|
|
123
|
+
},
|
|
124
|
+
{ label: `Edit ${property} (keyframe ${pct}%)`, softReload: true },
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
[selectedGsapAnimations, gsapCommitMutation, addGsapAnimation, previewIframeRef, bumpGsapCache],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return commitAnimatedProperty;
|
|
131
|
+
}
|
|
@@ -62,6 +62,7 @@ interface EditHistoryHandle {
|
|
|
62
62
|
interface UseAppHotkeysParams {
|
|
63
63
|
toggleTimelineVisibility: () => void;
|
|
64
64
|
handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
|
|
65
|
+
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
|
|
65
66
|
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
|
|
66
67
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
67
68
|
clearDomSelectionRef: React.MutableRefObject<() => void>;
|
|
@@ -77,6 +78,9 @@ interface UseAppHotkeysParams {
|
|
|
77
78
|
handleCopy: () => boolean;
|
|
78
79
|
handlePaste: () => Promise<void>;
|
|
79
80
|
handleCut: () => Promise<boolean>;
|
|
81
|
+
onResetKeyframes: () => boolean;
|
|
82
|
+
onDeleteSelectedKeyframes: () => void;
|
|
83
|
+
onAfterUndoRedo?: () => void;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
// ── Hook ──
|
|
@@ -84,6 +88,7 @@ interface UseAppHotkeysParams {
|
|
|
84
88
|
export function useAppHotkeys({
|
|
85
89
|
toggleTimelineVisibility,
|
|
86
90
|
handleTimelineElementDelete,
|
|
91
|
+
handleTimelineElementSplit,
|
|
87
92
|
handleDomEditElementDelete,
|
|
88
93
|
domEditSelectionRef,
|
|
89
94
|
editHistory,
|
|
@@ -98,6 +103,9 @@ export function useAppHotkeys({
|
|
|
98
103
|
handleCopy,
|
|
99
104
|
handlePaste,
|
|
100
105
|
handleCut,
|
|
106
|
+
onResetKeyframes,
|
|
107
|
+
onDeleteSelectedKeyframes,
|
|
108
|
+
onAfterUndoRedo,
|
|
101
109
|
}: UseAppHotkeysParams) {
|
|
102
110
|
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
|
103
111
|
const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined);
|
|
@@ -144,6 +152,7 @@ export function useAppHotkeys({
|
|
|
144
152
|
return;
|
|
145
153
|
}
|
|
146
154
|
if (result.ok && result.label) {
|
|
155
|
+
onAfterUndoRedo?.();
|
|
147
156
|
await syncHistoryPreviewAfterApply(result.paths);
|
|
148
157
|
showToast(`Undid ${result.label}`, "info");
|
|
149
158
|
}
|
|
@@ -154,6 +163,7 @@ export function useAppHotkeys({
|
|
|
154
163
|
syncHistoryPreviewAfterApply,
|
|
155
164
|
waitForPendingDomEditSaves,
|
|
156
165
|
writeHistoryProjectFile,
|
|
166
|
+
onAfterUndoRedo,
|
|
157
167
|
]);
|
|
158
168
|
|
|
159
169
|
const handleRedo = useCallback(async () => {
|
|
@@ -167,6 +177,7 @@ export function useAppHotkeys({
|
|
|
167
177
|
return;
|
|
168
178
|
}
|
|
169
179
|
if (result.ok && result.label) {
|
|
180
|
+
onAfterUndoRedo?.();
|
|
170
181
|
await syncHistoryPreviewAfterApply(result.paths);
|
|
171
182
|
showToast(`Redid ${result.label}`, "info");
|
|
172
183
|
}
|
|
@@ -177,6 +188,7 @@ export function useAppHotkeys({
|
|
|
177
188
|
syncHistoryPreviewAfterApply,
|
|
178
189
|
waitForPendingDomEditSaves,
|
|
179
190
|
writeHistoryProjectFile,
|
|
191
|
+
onAfterUndoRedo,
|
|
180
192
|
]);
|
|
181
193
|
|
|
182
194
|
// ── Stable refs for the consolidated keydown handler ──
|
|
@@ -185,6 +197,8 @@ export function useAppHotkeys({
|
|
|
185
197
|
handleToggleRef.current = handleTimelineToggleHotkey;
|
|
186
198
|
const handleDeleteRef = useRef(handleTimelineElementDelete);
|
|
187
199
|
handleDeleteRef.current = handleTimelineElementDelete;
|
|
200
|
+
const handleSplitRef = useRef(handleTimelineElementSplit);
|
|
201
|
+
handleSplitRef.current = handleTimelineElementSplit;
|
|
188
202
|
const handleDomEditDeleteRef = useRef(handleDomEditElementDelete);
|
|
189
203
|
handleDomEditDeleteRef.current = handleDomEditElementDelete;
|
|
190
204
|
const handleUndoRef = useRef(handleUndo);
|
|
@@ -197,6 +211,10 @@ export function useAppHotkeys({
|
|
|
197
211
|
handlePasteRef.current = handlePaste;
|
|
198
212
|
const handleCutRef = useRef(handleCut);
|
|
199
213
|
handleCutRef.current = handleCut;
|
|
214
|
+
const onResetKeyframesRef = useRef(onResetKeyframes);
|
|
215
|
+
onResetKeyframesRef.current = onResetKeyframes;
|
|
216
|
+
const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes);
|
|
217
|
+
onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes;
|
|
200
218
|
|
|
201
219
|
// ── Consolidated keydown handler ──
|
|
202
220
|
|
|
@@ -292,7 +310,31 @@ export function useAppHotkeys({
|
|
|
292
310
|
return;
|
|
293
311
|
}
|
|
294
312
|
|
|
295
|
-
//
|
|
313
|
+
// S — split selected clip at playhead
|
|
314
|
+
if (
|
|
315
|
+
event.key === "s" &&
|
|
316
|
+
!event.metaKey &&
|
|
317
|
+
!event.ctrlKey &&
|
|
318
|
+
!event.altKey &&
|
|
319
|
+
!isEditableTarget(event.target)
|
|
320
|
+
) {
|
|
321
|
+
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
|
322
|
+
if (selectedElementId) {
|
|
323
|
+
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
|
324
|
+
if (
|
|
325
|
+
element &&
|
|
326
|
+
["video", "audio", "img"].includes(element.tag) &&
|
|
327
|
+
currentTime > element.start &&
|
|
328
|
+
currentTime < element.start + element.duration
|
|
329
|
+
) {
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
void handleSplitRef.current(element, currentTime);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Delete / Backspace — remove selected keyframes > reset keyframes > remove element
|
|
296
338
|
if (
|
|
297
339
|
(event.key === "Delete" || event.key === "Backspace") &&
|
|
298
340
|
!event.metaKey &&
|
|
@@ -300,6 +342,26 @@ export function useAppHotkeys({
|
|
|
300
342
|
!event.altKey &&
|
|
301
343
|
!isEditableTarget(event.target)
|
|
302
344
|
) {
|
|
345
|
+
// Priority: selected keyframes take precedence over clip deletion
|
|
346
|
+
const { selectedKeyframes } = usePlayerStore.getState();
|
|
347
|
+
if (selectedKeyframes.size > 0) {
|
|
348
|
+
onDeleteSelectedKeyframesRef.current();
|
|
349
|
+
usePlayerStore.getState().clearSelectedKeyframes();
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Backspace: try resetting keyframes first; fall through to delete if none found
|
|
355
|
+
if (event.key === "Backspace") {
|
|
356
|
+
const { selectedElementId, keyframeCache } = usePlayerStore.getState();
|
|
357
|
+
if (selectedElementId && keyframeCache.has(selectedElementId)) {
|
|
358
|
+
if (onResetKeyframesRef.current()) {
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
303
365
|
const { selectedElementId, elements } = usePlayerStore.getState();
|
|
304
366
|
if (selectedElementId) {
|
|
305
367
|
const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
|
|
@@ -35,6 +35,37 @@ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditO
|
|
|
35
35
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
36
36
|
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
|
37
37
|
|
|
38
|
+
// ── Helpers ──
|
|
39
|
+
|
|
40
|
+
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
|
41
|
+
|
|
42
|
+
function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
|
|
43
|
+
if (!iframe?.contentWindow) return false;
|
|
44
|
+
let timelines: Record<string, TimelineLike> | undefined;
|
|
45
|
+
try {
|
|
46
|
+
timelines = (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
|
|
47
|
+
.__timelines;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (!timelines) return false;
|
|
52
|
+
const id = element.id;
|
|
53
|
+
for (const tl of Object.values(timelines)) {
|
|
54
|
+
if (!tl?.getChildren) continue;
|
|
55
|
+
try {
|
|
56
|
+
for (const child of tl.getChildren(true)) {
|
|
57
|
+
if (!child.targets) continue;
|
|
58
|
+
for (const t of child.targets()) {
|
|
59
|
+
if (t === element || (id && t.id === id)) return true;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
// ── Types ──
|
|
39
70
|
|
|
40
71
|
interface RecordEditInput {
|
|
@@ -290,12 +321,13 @@ export function useDomEditCommits({
|
|
|
290
321
|
const handleDomPathOffsetCommit = useCallback(
|
|
291
322
|
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
292
323
|
applyStudioPathOffset(selection.element, next);
|
|
324
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
|
|
293
325
|
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
294
326
|
label: "Move layer",
|
|
295
327
|
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
|
296
328
|
});
|
|
297
329
|
},
|
|
298
|
-
[commitPositionPatchToHtml],
|
|
330
|
+
[commitPositionPatchToHtml, previewIframeRef],
|
|
299
331
|
);
|
|
300
332
|
|
|
301
333
|
const handleDomGroupPathOffsetCommit = useCallback(
|
|
@@ -307,35 +339,38 @@ export function useDomEditCommits({
|
|
|
307
339
|
.join(":");
|
|
308
340
|
for (const { selection, next } of updates) {
|
|
309
341
|
applyStudioPathOffset(selection.element, next);
|
|
342
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue;
|
|
310
343
|
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
311
344
|
label: `Move ${updates.length} layers`,
|
|
312
345
|
coalesceKey: `group-path-offset:${coalesceKey}`,
|
|
313
346
|
});
|
|
314
347
|
}
|
|
315
348
|
},
|
|
316
|
-
[commitPositionPatchToHtml],
|
|
349
|
+
[commitPositionPatchToHtml, previewIframeRef],
|
|
317
350
|
);
|
|
318
351
|
|
|
319
352
|
const handleDomBoxSizeCommit = useCallback(
|
|
320
353
|
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
321
354
|
applyStudioBoxSize(selection.element, next);
|
|
355
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
|
|
322
356
|
commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
|
|
323
357
|
label: "Resize layer box",
|
|
324
358
|
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
|
325
359
|
});
|
|
326
360
|
},
|
|
327
|
-
[commitPositionPatchToHtml],
|
|
361
|
+
[commitPositionPatchToHtml, previewIframeRef],
|
|
328
362
|
);
|
|
329
363
|
|
|
330
364
|
const handleDomRotationCommit = useCallback(
|
|
331
365
|
(selection: DomEditSelection, next: { angle: number }) => {
|
|
332
366
|
applyStudioRotation(selection.element, next);
|
|
367
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return;
|
|
333
368
|
commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
|
|
334
369
|
label: "Rotate layer",
|
|
335
370
|
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
|
336
371
|
});
|
|
337
372
|
},
|
|
338
|
-
[commitPositionPatchToHtml],
|
|
373
|
+
[commitPositionPatchToHtml, previewIframeRef],
|
|
339
374
|
);
|
|
340
375
|
|
|
341
376
|
const handleDomManualEditsReset = useCallback(
|