@hyperframes/studio 0.6.59 → 0.6.61
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-B5EnhVCT.css +1 -0
- package/dist/assets/index-BdDNthf4.js +140 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +63 -127
- package/src/components/AskAgentModal.tsx +14 -2
- package/src/components/StudioRightPanel.tsx +1 -0
- package/src/components/editor/AnimationCard.tsx +60 -53
- package/src/components/editor/EaseCurveSection.tsx +5 -2
- package/src/components/editor/PropertyPanel.tsx +3 -1
- package/src/components/editor/domEditingAgentPrompt.ts +14 -0
- package/src/components/editor/domEditingElement.ts +34 -15
- package/src/components/editor/gsapAnimationConstants.ts +3 -1
- package/src/components/editor/gsapAnimationHelpers.test.ts +67 -0
- package/src/components/editor/gsapAnimationHelpers.ts +36 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
- package/src/components/nle/NLELayout.tsx +2 -1
- package/src/hooks/useDomEditSession.ts +19 -10
- package/src/hooks/useDomSelection.ts +35 -1
- package/src/hooks/useFileManager.ts +4 -5
- package/src/hooks/useGsapScriptCommits.ts +14 -1
- package/src/hooks/usePreviewInteraction.ts +67 -3
- package/src/hooks/useStudioContextValue.ts +138 -0
- package/src/utils/gsapSoftReload.test.ts +104 -0
- package/src/utils/gsapSoftReload.ts +89 -0
- package/src/utils/studioPreviewHelpers.ts +38 -0
- package/dist/assets/index-B1XH-ptc.js +0 -138
- package/dist/assets/index-DH9QNjuX.css +0 -1
|
@@ -153,31 +153,49 @@ export function resolveVisualDomEditSelectionTarget(
|
|
|
153
153
|
elementsFromPoint: Iterable<Element | null | undefined>,
|
|
154
154
|
options: Pick<DomEditContextOptions, "activeCompositionPath">,
|
|
155
155
|
): HTMLElement | null {
|
|
156
|
-
const candidates
|
|
156
|
+
const candidates = resolveAllVisualDomEditTargets(elementsFromPoint, options);
|
|
157
|
+
return candidates[0] ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Returns all independently-selectable elements at the given point, in paint
|
|
162
|
+
* order (topmost first). Used for click-cycling through stacked layers.
|
|
163
|
+
*
|
|
164
|
+
* Each entry in the returned array is an independent "layer" — an element
|
|
165
|
+
* that is not an ancestor of an earlier entry. This gives one result per
|
|
166
|
+
* z-stacked element rather than one per DOM node.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveAllVisualDomEditTargets(
|
|
169
|
+
elementsFromPoint: Iterable<Element | null | undefined>,
|
|
170
|
+
options: Pick<DomEditContextOptions, "activeCompositionPath">,
|
|
171
|
+
): HTMLElement[] {
|
|
172
|
+
const raw: HTMLElement[] = [];
|
|
157
173
|
|
|
158
174
|
for (const entry of elementsFromPoint) {
|
|
159
175
|
if (!isHtmlElement(entry)) continue;
|
|
160
176
|
if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
|
|
161
|
-
|
|
177
|
+
raw.push(entry);
|
|
162
178
|
}
|
|
163
179
|
}
|
|
164
180
|
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
let
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
best
|
|
181
|
+
if (raw.length === 0) return [];
|
|
182
|
+
|
|
183
|
+
// First pass: for each contiguous ancestor-descendant run, keep only the
|
|
184
|
+
// deepest (most specific) element, matching the original single-pick logic.
|
|
185
|
+
const layers: HTMLElement[] = [];
|
|
186
|
+
let best = raw[0];
|
|
187
|
+
for (let i = 1; i < raw.length; i++) {
|
|
188
|
+
const el = raw[i];
|
|
189
|
+
if (best.contains(el)) {
|
|
190
|
+
best = el; // go deeper in this subtree
|
|
191
|
+
} else {
|
|
192
|
+
layers.push(best);
|
|
193
|
+
best = el;
|
|
177
194
|
}
|
|
178
195
|
}
|
|
196
|
+
layers.push(best);
|
|
179
197
|
|
|
180
|
-
return
|
|
198
|
+
return layers;
|
|
181
199
|
}
|
|
182
200
|
|
|
183
201
|
// ─── Raster detection ────────────────────────────────────────────────────────
|
|
@@ -248,6 +266,7 @@ export function findElementForSelection(
|
|
|
248
266
|
return matches[0] ?? null;
|
|
249
267
|
}
|
|
250
268
|
|
|
269
|
+
// fallow-ignore-next-line complexity
|
|
251
270
|
export function findElementForTimelineElement(
|
|
252
271
|
doc: Document,
|
|
253
272
|
element: TimelineElementDomTarget,
|
|
@@ -4,7 +4,7 @@ export const METHOD_LABELS: Record<string, string> = {
|
|
|
4
4
|
set: "Set",
|
|
5
5
|
to: "Animate",
|
|
6
6
|
from: "Animate In",
|
|
7
|
-
fromTo: "
|
|
7
|
+
fromTo: "From → To",
|
|
8
8
|
};
|
|
9
9
|
|
|
10
10
|
export const METHOD_TOOLTIPS: Record<string, string> = {
|
|
@@ -121,6 +121,8 @@ export function parseCustomEaseFromString(ease: string): {
|
|
|
121
121
|
return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] };
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
|
|
125
|
+
|
|
124
126
|
export const ADD_METHODS = ["to", "from", "fromTo", "set"] as const;
|
|
125
127
|
|
|
126
128
|
export const ADD_METHOD_LABELS: Record<string, string> = {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildTweenSummary } from "./gsapAnimationHelpers";
|
|
3
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
4
|
+
|
|
5
|
+
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
|
|
6
|
+
return {
|
|
7
|
+
id: "a1",
|
|
8
|
+
method: "to",
|
|
9
|
+
targetSelector: "#box",
|
|
10
|
+
properties: {},
|
|
11
|
+
position: 0,
|
|
12
|
+
duration: 1,
|
|
13
|
+
ease: "power2.out",
|
|
14
|
+
...overrides,
|
|
15
|
+
} as GsapAnimation;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("buildTweenSummary", () => {
|
|
19
|
+
it("describes a to tween", () => {
|
|
20
|
+
const s = buildTweenSummary(anim({ properties: { opacity: 1, x: 100 } }));
|
|
21
|
+
expect(s).toContain("#box");
|
|
22
|
+
expect(s).toContain("opacity");
|
|
23
|
+
expect(s).toContain("move x");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("describes a from tween", () => {
|
|
27
|
+
const s = buildTweenSummary(anim({ method: "from", properties: { opacity: 0 } }));
|
|
28
|
+
expect(s).toContain("enters from");
|
|
29
|
+
expect(s).toContain("opacity");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("describes a set tween", () => {
|
|
33
|
+
const s = buildTweenSummary(anim({ method: "set", properties: { opacity: 0 } }));
|
|
34
|
+
expect(s).toMatch(/^At 0s, instantly set/);
|
|
35
|
+
expect(s).toContain("opacity");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("describes a fromTo tween with both from and to sections", () => {
|
|
39
|
+
const s = buildTweenSummary(
|
|
40
|
+
anim({
|
|
41
|
+
method: "fromTo",
|
|
42
|
+
fromProperties: { opacity: 0, x: -50 },
|
|
43
|
+
properties: { opacity: 1, x: 0 },
|
|
44
|
+
position: 0.5,
|
|
45
|
+
duration: 1.5,
|
|
46
|
+
ease: "expo.out",
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
expect(s).toContain("animates from");
|
|
50
|
+
expect(s).toContain("[opacity 0%");
|
|
51
|
+
expect(s).toContain("move x -50px");
|
|
52
|
+
expect(s).toContain("opacity to 100%");
|
|
53
|
+
expect(s).toContain("very snappy stop");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("handles fromTo with empty fromProperties", () => {
|
|
57
|
+
const s = buildTweenSummary(
|
|
58
|
+
anim({ method: "fromTo", fromProperties: {}, properties: { scale: 2 } }),
|
|
59
|
+
);
|
|
60
|
+
expect(s).toContain("from [—]");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("handles no properties", () => {
|
|
64
|
+
const s = buildTweenSummary(anim({ properties: {} }));
|
|
65
|
+
expect(s).toContain("no properties yet");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { EASE_LABELS, PERCENT_PROPS, PROP_LABELS, PROP_UNITS } from "./gsapAnimationConstants";
|
|
3
|
+
|
|
4
|
+
function formatPropValue(prop: string, v: number | string): string {
|
|
5
|
+
const unit = PROP_UNITS[prop] ?? "";
|
|
6
|
+
if (PERCENT_PROPS.has(prop)) return `${Math.round(Number(v) * 100)}${unit}`;
|
|
7
|
+
return `${v}${unit}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// fallow-ignore-next-line complexity
|
|
11
|
+
export function buildTweenSummary(animation: GsapAnimation): string {
|
|
12
|
+
const easeName = animation.ease ?? "none";
|
|
13
|
+
const ease = EASE_LABELS[easeName] ?? easeName;
|
|
14
|
+
const props = Object.entries(animation.properties);
|
|
15
|
+
const target = animation.targetSelector;
|
|
16
|
+
const dur = animation.duration ?? 0;
|
|
17
|
+
const pos = animation.position;
|
|
18
|
+
const propDescs = props.map(([p, v]) => {
|
|
19
|
+
const label = (PROP_LABELS[p] ?? p).toLowerCase();
|
|
20
|
+
return `${label} to ${formatPropValue(p, v)}`;
|
|
21
|
+
});
|
|
22
|
+
const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
|
|
23
|
+
if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
|
|
24
|
+
if (animation.method === "from")
|
|
25
|
+
return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
|
|
26
|
+
if (animation.method === "fromTo") {
|
|
27
|
+
const fromProps = Object.entries(animation.fromProperties ?? {});
|
|
28
|
+
const fromDescs = fromProps.map(([p, v]) => {
|
|
29
|
+
const label = (PROP_LABELS[p] ?? p).toLowerCase();
|
|
30
|
+
return `${label} ${formatPropValue(p, v)}`;
|
|
31
|
+
});
|
|
32
|
+
const fromText = fromDescs.length > 0 ? fromDescs.join(", ") : "—";
|
|
33
|
+
return `Starting at ${pos}s, over ${dur}s, ${target} animates from [${fromText}] to [${propText}] using a ${ease.toLowerCase()} curve.`;
|
|
34
|
+
}
|
|
35
|
+
return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
|
|
36
|
+
}
|
|
@@ -29,7 +29,7 @@ function CommitField({
|
|
|
29
29
|
const el = inputRef.current;
|
|
30
30
|
if (!el) return;
|
|
31
31
|
const handler = (e: WheelEvent) => {
|
|
32
|
-
if (disabled) return;
|
|
32
|
+
if (disabled || document.activeElement !== el) return;
|
|
33
33
|
const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
|
|
34
34
|
if (delta === 0) return;
|
|
35
35
|
const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);
|
|
@@ -97,6 +97,7 @@ export function shouldDisableTimelineWhileCompositionLoading(compositionLoading:
|
|
|
97
97
|
return compositionLoading;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// fallow-ignore-next-line complexity
|
|
100
101
|
export const NLELayout = memo(function NLELayout({
|
|
101
102
|
projectId,
|
|
102
103
|
portrait,
|
|
@@ -367,7 +368,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
367
368
|
{/* Preview + player controls */}
|
|
368
369
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
369
370
|
<div
|
|
370
|
-
className="flex-1 min-h-0 relative"
|
|
371
|
+
className="flex-1 min-h-0 relative overflow-hidden"
|
|
371
372
|
data-preview-pan-surface="true"
|
|
372
373
|
onDragOver={handlePreviewDragOver}
|
|
373
374
|
onDragLeave={handlePreviewDragLeave}
|
|
@@ -67,6 +67,7 @@ export interface UseDomEditSessionParams {
|
|
|
67
67
|
|
|
68
68
|
// ── Hook ──
|
|
69
69
|
|
|
70
|
+
// fallow-ignore-next-line complexity
|
|
70
71
|
export function useDomEditSession({
|
|
71
72
|
projectId,
|
|
72
73
|
activeCompPath,
|
|
@@ -129,6 +130,7 @@ export function useDomEditSession({
|
|
|
129
130
|
clearDomSelection,
|
|
130
131
|
buildDomSelectionFromTarget,
|
|
131
132
|
resolveDomSelectionFromPreviewPoint,
|
|
133
|
+
resolveAllDomSelectionsFromPreviewPoint,
|
|
132
134
|
updateDomEditHoverSelection,
|
|
133
135
|
buildDomSelectionForTimelineElement,
|
|
134
136
|
handleTimelineElementSelect,
|
|
@@ -187,6 +189,7 @@ export function useDomEditSession({
|
|
|
187
189
|
showToast,
|
|
188
190
|
applyDomSelection,
|
|
189
191
|
resolveDomSelectionFromPreviewPoint,
|
|
192
|
+
resolveAllDomSelectionsFromPreviewPoint,
|
|
190
193
|
updateDomEditHoverSelection,
|
|
191
194
|
onClickToSource,
|
|
192
195
|
});
|
|
@@ -221,6 +224,7 @@ export function useDomEditSession({
|
|
|
221
224
|
} = useGsapScriptCommits({
|
|
222
225
|
projectIdRef,
|
|
223
226
|
activeCompPath,
|
|
227
|
+
previewIframeRef,
|
|
224
228
|
editHistory,
|
|
225
229
|
domEditSaveTimestampRef,
|
|
226
230
|
reloadPreview,
|
|
@@ -343,6 +347,7 @@ export function useDomEditSession({
|
|
|
343
347
|
useEffect(() => {
|
|
344
348
|
if (!previewIframe) return;
|
|
345
349
|
|
|
350
|
+
// fallow-ignore-next-line complexity
|
|
346
351
|
const syncSelectionFromDocument = async () => {
|
|
347
352
|
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
|
|
348
353
|
const currentSelection = domEditSelectionRef.current;
|
|
@@ -402,16 +407,20 @@ export function useDomEditSession({
|
|
|
402
407
|
// not when openSourceForSelection is recreated due to editingFile content updates.
|
|
403
408
|
const openSourceRef = useRef(openSourceForSelection);
|
|
404
409
|
openSourceRef.current = openSourceForSelection;
|
|
405
|
-
useEffect(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
410
|
+
useEffect(
|
|
411
|
+
// fallow-ignore-next-line complexity
|
|
412
|
+
() => {
|
|
413
|
+
if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
|
|
414
|
+
if (!domEditSelection.sourceFile) return;
|
|
415
|
+
if (getSidebarTab() !== "code") return;
|
|
416
|
+
openSourceRef.current(domEditSelection.sourceFile, {
|
|
417
|
+
id: domEditSelection.id,
|
|
418
|
+
selector: domEditSelection.selector,
|
|
419
|
+
selectorIndex: domEditSelection.selectorIndex,
|
|
420
|
+
});
|
|
421
|
+
},
|
|
422
|
+
[domEditSelection, getSidebarTab],
|
|
423
|
+
);
|
|
415
424
|
|
|
416
425
|
return {
|
|
417
426
|
// State
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getAllPreviewTargetsFromPointer,
|
|
5
|
+
getPreviewTargetFromPointer,
|
|
6
|
+
} from "../utils/studioPreviewHelpers";
|
|
4
7
|
import { findMatchingTimelineElementId, type RightPanelTab } from "../utils/studioHelpers";
|
|
5
8
|
import {
|
|
6
9
|
domEditSelectionsTargetSame,
|
|
@@ -67,6 +70,10 @@ export interface UseDomSelectionReturn {
|
|
|
67
70
|
clientY: number,
|
|
68
71
|
options?: { preferClipAncestor?: boolean },
|
|
69
72
|
) => Promise<DomEditSelection | null>;
|
|
73
|
+
resolveAllDomSelectionsFromPreviewPoint: (
|
|
74
|
+
clientX: number,
|
|
75
|
+
clientY: number,
|
|
76
|
+
) => Promise<DomEditSelection[]>;
|
|
70
77
|
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
|
|
71
78
|
buildDomSelectionForTimelineElement: (
|
|
72
79
|
element: TimelineElement,
|
|
@@ -113,6 +120,7 @@ export function useDomSelection({
|
|
|
113
120
|
// ── Callbacks ──
|
|
114
121
|
|
|
115
122
|
const applyDomSelection = useCallback(
|
|
123
|
+
// fallow-ignore-next-line complexity
|
|
116
124
|
(
|
|
117
125
|
selection: DomEditSelection | null,
|
|
118
126
|
options?: {
|
|
@@ -212,6 +220,7 @@ export function useDomSelection({
|
|
|
212
220
|
);
|
|
213
221
|
|
|
214
222
|
const resolveDomSelectionFromPreviewPoint = useCallback(
|
|
223
|
+
// fallow-ignore-next-line complexity
|
|
215
224
|
async (
|
|
216
225
|
clientX: number,
|
|
217
226
|
clientY: number,
|
|
@@ -234,6 +243,27 @@ export function useDomSelection({
|
|
|
234
243
|
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
|
|
235
244
|
);
|
|
236
245
|
|
|
246
|
+
const resolveAllDomSelectionsFromPreviewPoint = useCallback(
|
|
247
|
+
// fallow-ignore-next-line complexity
|
|
248
|
+
async (clientX: number, clientY: number): Promise<DomEditSelection[]> => {
|
|
249
|
+
const iframe = previewIframeRef.current;
|
|
250
|
+
if (!iframe || captionEditMode) return [];
|
|
251
|
+
try {
|
|
252
|
+
if (iframe.contentDocument) reapplyPositionEditsAfterSeek(iframe.contentDocument);
|
|
253
|
+
} catch {
|
|
254
|
+
/* cross-origin guard */
|
|
255
|
+
}
|
|
256
|
+
const targets = getAllPreviewTargetsFromPointer(iframe, clientX, clientY, activeCompPath);
|
|
257
|
+
const results: DomEditSelection[] = [];
|
|
258
|
+
for (const target of targets) {
|
|
259
|
+
const sel = await buildDomSelectionFromTarget(target, { skipSourceProbe: true });
|
|
260
|
+
if (sel) results.push(sel);
|
|
261
|
+
}
|
|
262
|
+
return results;
|
|
263
|
+
},
|
|
264
|
+
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
|
|
265
|
+
);
|
|
266
|
+
|
|
237
267
|
const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
|
|
238
268
|
if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
|
|
239
269
|
domEditHoverSelectionRef.current = selection;
|
|
@@ -241,6 +271,7 @@ export function useDomSelection({
|
|
|
241
271
|
}, []);
|
|
242
272
|
|
|
243
273
|
const buildDomSelectionForTimelineElement = useCallback(
|
|
274
|
+
// fallow-ignore-next-line complexity
|
|
244
275
|
async (element: TimelineElement): Promise<DomEditSelection | null> => {
|
|
245
276
|
const iframe = previewIframeRef.current;
|
|
246
277
|
let doc: Document | null = null;
|
|
@@ -282,6 +313,7 @@ export function useDomSelection({
|
|
|
282
313
|
);
|
|
283
314
|
|
|
284
315
|
const refreshDomEditSelectionFromPreview = useCallback(
|
|
316
|
+
// fallow-ignore-next-line complexity
|
|
285
317
|
async (selection: DomEditSelection) => {
|
|
286
318
|
const iframe = previewIframeRef.current;
|
|
287
319
|
let doc: Document | null = null;
|
|
@@ -307,6 +339,7 @@ export function useDomSelection({
|
|
|
307
339
|
);
|
|
308
340
|
|
|
309
341
|
const refreshDomEditGroupSelectionsFromPreview = useCallback(
|
|
342
|
+
// fallow-ignore-next-line complexity
|
|
310
343
|
async (selections: DomEditSelection[]) => {
|
|
311
344
|
const iframe = previewIframeRef.current;
|
|
312
345
|
let doc: Document | null = null;
|
|
@@ -430,6 +463,7 @@ export function useDomSelection({
|
|
|
430
463
|
clearDomSelection,
|
|
431
464
|
buildDomSelectionFromTarget,
|
|
432
465
|
resolveDomSelectionFromPreviewPoint,
|
|
466
|
+
resolveAllDomSelectionsFromPreviewPoint,
|
|
433
467
|
updateDomEditHoverSelection,
|
|
434
468
|
buildDomSelectionForTimelineElement,
|
|
435
469
|
handleTimelineElementSelect,
|
|
@@ -38,6 +38,7 @@ export function useFileManager({
|
|
|
38
38
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
|
39
39
|
const [projectDir, setProjectDir] = useState<string | null>(null);
|
|
40
40
|
const [fileTree, setFileTree] = useState<string[]>([]);
|
|
41
|
+
const [compositionPaths, setCompositionPaths] = useState<string[]>([]);
|
|
41
42
|
const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
|
|
42
43
|
const [revealSourceOffset, setRevealSourceOffset] = useState<number | null>(null);
|
|
43
44
|
|
|
@@ -65,8 +66,9 @@ export function useFileManager({
|
|
|
65
66
|
setFileTreeLoaded(false);
|
|
66
67
|
fetch(`/api/projects/${projectId}`)
|
|
67
68
|
.then((r) => r.json())
|
|
68
|
-
.then((data: { files?: string[]; dir?: string }) => {
|
|
69
|
+
.then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
|
|
69
70
|
if (!cancelled && data.files) setFileTree(data.files);
|
|
71
|
+
if (!cancelled && data.compositions) setCompositionPaths(data.compositions);
|
|
70
72
|
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
|
|
71
73
|
})
|
|
72
74
|
.catch(() => {
|
|
@@ -417,10 +419,7 @@ export function useFileManager({
|
|
|
417
419
|
|
|
418
420
|
// ── Derived state ──
|
|
419
421
|
|
|
420
|
-
const compositions =
|
|
421
|
-
() => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
|
|
422
|
-
[fileTree],
|
|
423
|
-
);
|
|
422
|
+
const compositions = compositionPaths;
|
|
424
423
|
|
|
425
424
|
const assets = useMemo(
|
|
426
425
|
() =>
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
|
+
import { applySoftReload } from "../utils/gsapSoftReload";
|
|
5
6
|
|
|
6
7
|
const PROPERTY_DEFAULTS: Record<string, number> = {
|
|
7
8
|
opacity: 1,
|
|
@@ -45,6 +46,7 @@ interface MutationResult {
|
|
|
45
46
|
parsed?: ParsedGsap;
|
|
46
47
|
before?: string;
|
|
47
48
|
after?: string;
|
|
49
|
+
scriptText?: string;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
async function mutateGsapScript(
|
|
@@ -71,6 +73,7 @@ async function mutateGsapScript(
|
|
|
71
73
|
interface GsapScriptCommitsParams {
|
|
72
74
|
projectIdRef: React.MutableRefObject<string | null>;
|
|
73
75
|
activeCompPath: string | null;
|
|
76
|
+
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
74
77
|
editHistory: {
|
|
75
78
|
recordEdit: (entry: {
|
|
76
79
|
label: string;
|
|
@@ -90,6 +93,7 @@ const DEBOUNCE_MS = 150;
|
|
|
90
93
|
export function useGsapScriptCommits({
|
|
91
94
|
projectIdRef,
|
|
92
95
|
activeCompPath,
|
|
96
|
+
previewIframeRef,
|
|
93
97
|
editHistory,
|
|
94
98
|
domEditSaveTimestampRef,
|
|
95
99
|
reloadPreview,
|
|
@@ -105,6 +109,7 @@ export function useGsapScriptCommits({
|
|
|
105
109
|
|
|
106
110
|
/** Send a mutation and record the edit in undo history. */
|
|
107
111
|
const commitMutation = useCallback(
|
|
112
|
+
// fallow-ignore-next-line complexity
|
|
108
113
|
async (
|
|
109
114
|
selection: DomEditSelection,
|
|
110
115
|
mutation: Record<string, unknown>,
|
|
@@ -130,13 +135,18 @@ export function useGsapScriptCommits({
|
|
|
130
135
|
|
|
131
136
|
onCacheInvalidate();
|
|
132
137
|
|
|
133
|
-
if (
|
|
138
|
+
if (options.softReload && result.scriptText) {
|
|
139
|
+
if (!applySoftReload(previewIframeRef.current, result.scriptText)) {
|
|
140
|
+
reloadPreview();
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
134
143
|
reloadPreview();
|
|
135
144
|
}
|
|
136
145
|
},
|
|
137
146
|
[
|
|
138
147
|
projectIdRef,
|
|
139
148
|
activeCompPath,
|
|
149
|
+
previewIframeRef,
|
|
140
150
|
editHistory,
|
|
141
151
|
domEditSaveTimestampRef,
|
|
142
152
|
reloadPreview,
|
|
@@ -155,6 +165,7 @@ export function useGsapScriptCommits({
|
|
|
155
165
|
{
|
|
156
166
|
label: `Edit GSAP ${property}`,
|
|
157
167
|
coalesceKey: `gsap:${animationId}:${property}`,
|
|
168
|
+
softReload: true,
|
|
158
169
|
},
|
|
159
170
|
);
|
|
160
171
|
}, [commitMutation]);
|
|
@@ -210,6 +221,7 @@ export function useGsapScriptCommits({
|
|
|
210
221
|
);
|
|
211
222
|
|
|
212
223
|
const addGsapAnimation = useCallback(
|
|
224
|
+
// fallow-ignore-next-line complexity
|
|
213
225
|
async (
|
|
214
226
|
selection: DomEditSelection,
|
|
215
227
|
method: "to" | "from" | "set" | "fromTo",
|
|
@@ -268,6 +280,7 @@ export function useGsapScriptCommits({
|
|
|
268
280
|
);
|
|
269
281
|
|
|
270
282
|
const addGsapProperty = useCallback(
|
|
283
|
+
// fallow-ignore-next-line complexity
|
|
271
284
|
(selection: DomEditSelection, animationId: string, property: string) => {
|
|
272
285
|
let defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
|
|
273
286
|
const el = selection.element;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
2
|
import { liveTime, usePlayerStore } from "../player";
|
|
3
3
|
import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
|
|
4
4
|
import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
@@ -22,11 +22,26 @@ export interface UsePreviewInteractionParams {
|
|
|
22
22
|
clientY: number,
|
|
23
23
|
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
|
|
24
24
|
) => Promise<DomEditSelection | null>;
|
|
25
|
+
resolveAllDomSelectionsFromPreviewPoint: (
|
|
26
|
+
clientX: number,
|
|
27
|
+
clientY: number,
|
|
28
|
+
) => Promise<DomEditSelection[]>;
|
|
25
29
|
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
|
|
26
30
|
|
|
27
31
|
onClickToSource?: (selection: DomEditSelection) => void;
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
interface ClickCycleState {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
candidates: DomEditSelection[];
|
|
38
|
+
index: number;
|
|
39
|
+
at: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CYCLE_RADIUS_PX = 6;
|
|
43
|
+
const CYCLE_WINDOW_MS = 600;
|
|
44
|
+
|
|
30
45
|
// ── Hook ──
|
|
31
46
|
|
|
32
47
|
export function usePreviewInteraction({
|
|
@@ -36,36 +51,85 @@ export function usePreviewInteraction({
|
|
|
36
51
|
showToast,
|
|
37
52
|
applyDomSelection,
|
|
38
53
|
resolveDomSelectionFromPreviewPoint,
|
|
54
|
+
resolveAllDomSelectionsFromPreviewPoint,
|
|
39
55
|
updateDomEditHoverSelection,
|
|
40
56
|
onClickToSource,
|
|
41
57
|
}: UsePreviewInteractionParams) {
|
|
58
|
+
const cycleRef = useRef<ClickCycleState | null>(null);
|
|
59
|
+
|
|
42
60
|
const handlePreviewCanvasMouseDown = useCallback(
|
|
61
|
+
// fallow-ignore-next-line complexity
|
|
43
62
|
async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
44
63
|
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
|
|
64
|
+
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const prev = cycleRef.current;
|
|
67
|
+
const dx = prev ? e.clientX - prev.x : Infinity;
|
|
68
|
+
const dy = prev ? e.clientY - prev.y : Infinity;
|
|
69
|
+
const sameSpot =
|
|
70
|
+
prev !== null &&
|
|
71
|
+
Math.sqrt(dx * dx + dy * dy) < CYCLE_RADIUS_PX &&
|
|
72
|
+
now - prev.at < CYCLE_WINDOW_MS;
|
|
73
|
+
|
|
74
|
+
if (e.shiftKey) {
|
|
75
|
+
// Additive selection — no cycling
|
|
76
|
+
cycleRef.current = null;
|
|
77
|
+
const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
78
|
+
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
79
|
+
});
|
|
80
|
+
if (!nextSelection) return;
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
e.stopPropagation();
|
|
83
|
+
applyDomSelection(nextSelection, { additive: true });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (sameSpot && prev) {
|
|
88
|
+
// Cycle to next candidate in z-stack
|
|
89
|
+
const nextIndex = (prev.index + 1) % prev.candidates.length;
|
|
90
|
+
const nextSel = prev.candidates[nextIndex];
|
|
91
|
+
cycleRef.current = { ...prev, index: nextIndex, at: now };
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
e.stopPropagation();
|
|
94
|
+
applyDomSelection(nextSel);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Fresh click — resolve topmost element
|
|
45
99
|
const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
46
100
|
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
47
101
|
});
|
|
48
102
|
if (!nextSelection) {
|
|
49
|
-
|
|
103
|
+
cycleRef.current = null;
|
|
104
|
+
applyDomSelection(null, { revealPanel: false });
|
|
50
105
|
return;
|
|
51
106
|
}
|
|
52
107
|
e.preventDefault();
|
|
53
108
|
e.stopPropagation();
|
|
54
|
-
applyDomSelection(nextSelection
|
|
109
|
+
applyDomSelection(nextSelection);
|
|
110
|
+
|
|
55
111
|
if (!e.shiftKey && e.altKey && onClickToSource) {
|
|
56
112
|
onClickToSource(nextSelection);
|
|
57
113
|
}
|
|
114
|
+
|
|
115
|
+
// Resolve all stacked candidates so a subsequent click at the same
|
|
116
|
+
// position can cycle to the next layer (issues #1124, #1125).
|
|
117
|
+
const all = await resolveAllDomSelectionsFromPreviewPoint(e.clientX, e.clientY);
|
|
118
|
+
cycleRef.current =
|
|
119
|
+
all.length > 1 ? { x: e.clientX, y: e.clientY, candidates: all, index: 0, at: now } : null;
|
|
58
120
|
},
|
|
59
121
|
[
|
|
60
122
|
applyDomSelection,
|
|
61
123
|
captionEditMode,
|
|
62
124
|
compositionLoading,
|
|
63
125
|
onClickToSource,
|
|
126
|
+
resolveAllDomSelectionsFromPreviewPoint,
|
|
64
127
|
resolveDomSelectionFromPreviewPoint,
|
|
65
128
|
],
|
|
66
129
|
);
|
|
67
130
|
|
|
68
131
|
const handlePreviewCanvasPointerMove = useCallback(
|
|
132
|
+
// fallow-ignore-next-line complexity
|
|
69
133
|
async (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
70
134
|
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
|
|
71
135
|
updateDomEditHoverSelection(null);
|