@hyperframes/studio 0.6.58 → 0.6.60
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-DG5-N9Mj.js +139 -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 +18 -10
- package/src/hooks/useDomSelection.ts +35 -1
- package/src/hooks/useFileManager.ts +4 -5
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/usePreviewInteraction.ts +67 -3
- package/src/hooks/useStudioContextValue.ts +138 -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
|
});
|
|
@@ -343,6 +346,7 @@ export function useDomEditSession({
|
|
|
343
346
|
useEffect(() => {
|
|
344
347
|
if (!previewIframe) return;
|
|
345
348
|
|
|
349
|
+
// fallow-ignore-next-line complexity
|
|
346
350
|
const syncSelectionFromDocument = async () => {
|
|
347
351
|
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
|
|
348
352
|
const currentSelection = domEditSelectionRef.current;
|
|
@@ -402,16 +406,20 @@ export function useDomEditSession({
|
|
|
402
406
|
// not when openSourceForSelection is recreated due to editingFile content updates.
|
|
403
407
|
const openSourceRef = useRef(openSourceForSelection);
|
|
404
408
|
openSourceRef.current = openSourceForSelection;
|
|
405
|
-
useEffect(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
409
|
+
useEffect(
|
|
410
|
+
// fallow-ignore-next-line complexity
|
|
411
|
+
() => {
|
|
412
|
+
if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
|
|
413
|
+
if (!domEditSelection.sourceFile) return;
|
|
414
|
+
if (getSidebarTab() !== "code") return;
|
|
415
|
+
openSourceRef.current(domEditSelection.sourceFile, {
|
|
416
|
+
id: domEditSelection.id,
|
|
417
|
+
selector: domEditSelection.selector,
|
|
418
|
+
selectorIndex: domEditSelection.selectorIndex,
|
|
419
|
+
});
|
|
420
|
+
},
|
|
421
|
+
[domEditSelection, getSidebarTab],
|
|
422
|
+
);
|
|
415
423
|
|
|
416
424
|
return {
|
|
417
425
|
// 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
|
() =>
|
|
@@ -105,6 +105,7 @@ export function useGsapScriptCommits({
|
|
|
105
105
|
|
|
106
106
|
/** Send a mutation and record the edit in undo history. */
|
|
107
107
|
const commitMutation = useCallback(
|
|
108
|
+
// fallow-ignore-next-line complexity
|
|
108
109
|
async (
|
|
109
110
|
selection: DomEditSelection,
|
|
110
111
|
mutation: Record<string, unknown>,
|
|
@@ -210,6 +211,7 @@ export function useGsapScriptCommits({
|
|
|
210
211
|
);
|
|
211
212
|
|
|
212
213
|
const addGsapAnimation = useCallback(
|
|
214
|
+
// fallow-ignore-next-line complexity
|
|
213
215
|
async (
|
|
214
216
|
selection: DomEditSelection,
|
|
215
217
|
method: "to" | "from" | "set" | "fromTo",
|
|
@@ -268,6 +270,7 @@ export function useGsapScriptCommits({
|
|
|
268
270
|
);
|
|
269
271
|
|
|
270
272
|
const addGsapProperty = useCallback(
|
|
273
|
+
// fallow-ignore-next-line complexity
|
|
271
274
|
(selection: DomEditSelection, animationId: string, property: string) => {
|
|
272
275
|
let defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
|
|
273
276
|
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);
|