@hyperframes/studio 0.7.5 → 0.7.6
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-BwFzbjZQ.js → index-B_NnmU6q.js} +1 -1
- package/dist/assets/{index-C5NAfiPa.js → index-DsBhGFPe.js} +1 -1
- package/dist/assets/{index-DzWIinxk.css → index-wJZf6FK3.css} +1 -1
- package/dist/assets/index-ysftPins.js +374 -0
- package/dist/chunk-KZXYQYIU.js +876 -0
- package/dist/chunk-KZXYQYIU.js.map +1 -0
- package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
- package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.html +2 -2
- package/dist/index.js +2298 -2483
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/components/StudioPreviewArea.tsx +10 -3
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/editor/AnimationCard.tsx +72 -251
- package/src/components/editor/AnimationCardParts.tsx +220 -0
- package/src/components/editor/BlockParamsPanel.tsx +4 -8
- package/src/components/editor/DomEditOverlay.tsx +132 -48
- package/src/components/editor/EaseCurveSection.tsx +6 -6
- package/src/components/editor/GsapAnimationSection.tsx +2 -0
- package/src/components/editor/KeyframeEaseList.tsx +63 -0
- package/src/components/editor/OffCanvasIndicators.tsx +111 -0
- package/src/components/editor/PropertyPanel.tsx +52 -54
- package/src/components/editor/gsapAnimationCallbacks.ts +1 -0
- package/src/components/editor/gsapAnimationConstants.ts +8 -0
- package/src/components/editor/manualOffsetDrag.ts +8 -0
- package/src/components/editor/marqueeCommit.ts +168 -0
- package/src/components/editor/propertyPanelHelpers.ts +1 -0
- package/src/components/editor/snapTargetCollection.ts +0 -5
- package/src/components/panels/SlideshowPanel.tsx +0 -1
- package/src/contexts/DomEditContext.tsx +8 -0
- package/src/hooks/gsapDragPositionCommit.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.ts +6 -2
- package/src/hooks/gsapRuntimeReaders.ts +1 -6
- package/src/hooks/useDomEditCommits.ts +0 -4
- package/src/hooks/useDomEditPreviewSync.ts +5 -0
- package/src/hooks/useDomEditSession.ts +23 -0
- package/src/hooks/useDomEditTextCommits.ts +3 -12
- package/src/hooks/useDomSelection.ts +46 -0
- package/src/hooks/useGestureCommit.ts +26 -3
- package/src/hooks/useGestureRecording.ts +2 -16
- package/src/hooks/useGsapAnimationOps.ts +2 -2
- package/src/hooks/useGsapTweenCache.ts +32 -4
- package/src/player/components/Player.tsx +0 -5
- package/src/player/components/timelineIcons.tsx +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +4 -14
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
- package/src/player/lib/timelineIframeHelpers.ts +2 -6
- package/src/telemetry/client.ts +2 -0
- package/src/utils/editDebugLog.ts +3 -10
- package/src/utils/gestureSmoother.test.ts +48 -0
- package/src/utils/gestureSmoother.ts +46 -0
- package/src/utils/marqueeGeometry.test.ts +123 -0
- package/src/utils/marqueeGeometry.ts +172 -0
- package/src/utils/optimisticUpdate.ts +1 -2
- package/src/utils/sourcePatcher.ts +0 -10
- package/src/utils/velocityEaseFitter.test.ts +58 -0
- package/src/utils/velocityEaseFitter.ts +121 -0
- package/dist/assets/index-D_JGXmfx.js +0 -374
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type DomEditSelection } from "./domEditing";
|
|
3
|
+
|
|
4
|
+
export interface OffCanvasRect {
|
|
5
|
+
key: string;
|
|
6
|
+
left: number;
|
|
7
|
+
top: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface OffCanvasIndicatorsProps {
|
|
13
|
+
rects: OffCanvasRect[];
|
|
14
|
+
elements: React.MutableRefObject<Map<string, HTMLElement>>;
|
|
15
|
+
compRect: { left: number; top: number; width: number; height: number };
|
|
16
|
+
selection: DomEditSelection | null;
|
|
17
|
+
groupSelections: DomEditSelection[];
|
|
18
|
+
activeCompositionPathRef: React.MutableRefObject<string | null>;
|
|
19
|
+
onSelectionChangeRef: React.MutableRefObject<
|
|
20
|
+
(selection: DomEditSelection, options?: { revealPanel?: boolean; additive?: boolean }) => void
|
|
21
|
+
>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Dashed teal indicators for elements whose bounds extend past the composition
|
|
26
|
+
* (the "gray zone"). The in-canvas portion is clipped away so only the
|
|
27
|
+
* protruding sliver is dashed; the inside portion gets a solid outline.
|
|
28
|
+
* Extracted from DomEditOverlay to keep that file under the 600-LOC cap.
|
|
29
|
+
*/
|
|
30
|
+
export function OffCanvasIndicators({
|
|
31
|
+
rects,
|
|
32
|
+
elements,
|
|
33
|
+
compRect,
|
|
34
|
+
selection,
|
|
35
|
+
groupSelections,
|
|
36
|
+
activeCompositionPathRef,
|
|
37
|
+
onSelectionChangeRef,
|
|
38
|
+
}: OffCanvasIndicatorsProps): React.ReactElement {
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
{rects
|
|
42
|
+
.filter((r) => {
|
|
43
|
+
// Suppress the indicator for any currently-selected element (primary
|
|
44
|
+
// OR a marquee group member) — those already render a selection box.
|
|
45
|
+
const el = elements.current.get(r.key);
|
|
46
|
+
if (!el) return true;
|
|
47
|
+
if (selection?.element === el) return false;
|
|
48
|
+
return !groupSelections.some((g) => g.element === el);
|
|
49
|
+
})
|
|
50
|
+
.map((r) => {
|
|
51
|
+
const pos = { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
52
|
+
const cL = Math.max(0, compRect.left - r.left);
|
|
53
|
+
const cT = Math.max(0, compRect.top - r.top);
|
|
54
|
+
const cR = Math.min(r.width, compRect.left + compRect.width - r.left);
|
|
55
|
+
const cB = Math.min(r.height, compRect.top + compRect.height - r.top);
|
|
56
|
+
const hasInside = cL < cR && cT < cB;
|
|
57
|
+
const clipOutside = hasInside
|
|
58
|
+
? `polygon(evenodd, 0 0, ${r.width}px 0, ${r.width}px ${r.height}px, 0 ${r.height}px, 0 0, ${cL}px ${cT}px, ${cR}px ${cT}px, ${cR}px ${cB}px, ${cL}px ${cB}px, ${cL}px ${cT}px)`
|
|
59
|
+
: undefined;
|
|
60
|
+
const clipInside = `inset(${cT}px ${Math.max(0, r.width - cR)}px ${Math.max(0, r.height - cB)}px ${cL}px round 6px)`;
|
|
61
|
+
const selectOffCanvas = async () => {
|
|
62
|
+
const el = elements.current.get(r.key);
|
|
63
|
+
if (!el) return;
|
|
64
|
+
const { resolveDomEditSelection } = await import("./domEditingLayers");
|
|
65
|
+
const acp = activeCompositionPathRef.current ?? "index.html";
|
|
66
|
+
const sel = await resolveDomEditSelection(el, {
|
|
67
|
+
activeCompositionPath: acp,
|
|
68
|
+
isMasterView: !acp || acp === "index.html",
|
|
69
|
+
skipSourceProbe: true,
|
|
70
|
+
});
|
|
71
|
+
if (sel) onSelectionChangeRef.current(sel, { revealPanel: true });
|
|
72
|
+
};
|
|
73
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
74
|
+
e.stopPropagation();
|
|
75
|
+
void selectOffCanvas();
|
|
76
|
+
};
|
|
77
|
+
return (
|
|
78
|
+
<div key={`offcanvas-${r.key}`} className="pointer-events-none absolute" style={pos}>
|
|
79
|
+
{/* Dashed layer — clipped to exclude canvas area.
|
|
80
|
+
Note: clip-path is visual only — hit-testing still covers the
|
|
81
|
+
full bounding rect, so clicking the in-canvas portion selects
|
|
82
|
+
via this handler. That's acceptable: it resolves the same
|
|
83
|
+
element the normal canvas path would, just with
|
|
84
|
+
skipSourceProbe (the element is already known here). */}
|
|
85
|
+
<div
|
|
86
|
+
role="button"
|
|
87
|
+
tabIndex={0}
|
|
88
|
+
aria-label={`Select off-canvas element ${r.key}`}
|
|
89
|
+
className="pointer-events-auto absolute inset-0 border-2 border-dashed border-studio-accent/60 rounded-md cursor-pointer hover:border-studio-accent hover:bg-studio-accent/10 transition-colors"
|
|
90
|
+
style={clipOutside ? { clipPath: clipOutside } : undefined}
|
|
91
|
+
title={`Off-canvas: ${r.key} — click to select`}
|
|
92
|
+
onClick={handleClick}
|
|
93
|
+
onKeyDown={(e) => {
|
|
94
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
e.stopPropagation();
|
|
97
|
+
void selectOffCanvas();
|
|
98
|
+
}
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
{/* Solid layer — clipped to canvas bounds, covers inside portion */}
|
|
102
|
+
<div
|
|
103
|
+
className="pointer-events-none absolute inset-0 border border-studio-accent/80 rounded-md bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
|
104
|
+
style={{ clipPath: clipInside }}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -84,6 +84,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
84
84
|
onSetArcPath,
|
|
85
85
|
onUpdateArcSegment,
|
|
86
86
|
onUnroll,
|
|
87
|
+
onUpdateKeyframeEase,
|
|
87
88
|
onAddKeyframe,
|
|
88
89
|
onRemoveKeyframe,
|
|
89
90
|
onConvertToKeyframes,
|
|
@@ -233,6 +234,55 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
233
234
|
const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
|
|
234
235
|
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
|
|
235
236
|
|
|
237
|
+
// fallow-ignore-next-line complexity
|
|
238
|
+
const handleCopyElementInfo = () => {
|
|
239
|
+
const file = element.sourceFile ?? "index.html";
|
|
240
|
+
let lineNum: number | null = null;
|
|
241
|
+
try {
|
|
242
|
+
const src = previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
|
|
243
|
+
if (src && element.id) {
|
|
244
|
+
const idx = src.indexOf(`id="${element.id}"`);
|
|
245
|
+
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
|
246
|
+
}
|
|
247
|
+
if (!lineNum && element.selector) {
|
|
248
|
+
const tag = element.tagName.toLowerCase();
|
|
249
|
+
const cls = element.selector.startsWith(".")
|
|
250
|
+
? element.selector.slice(1).split(".")[0]
|
|
251
|
+
: null;
|
|
252
|
+
const search = cls ? `class="${cls}` : `<${tag}`;
|
|
253
|
+
const idx = src.indexOf(search);
|
|
254
|
+
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
|
255
|
+
}
|
|
256
|
+
} catch {}
|
|
257
|
+
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
|
|
258
|
+
const lines = [
|
|
259
|
+
`Element: ${element.label} (${sourceLabel})`,
|
|
260
|
+
`File: ${fileLoc}`,
|
|
261
|
+
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
|
|
262
|
+
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
|
|
263
|
+
`Tag: <${element.tagName}>`,
|
|
264
|
+
];
|
|
265
|
+
if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") {
|
|
266
|
+
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
|
|
267
|
+
}
|
|
268
|
+
if (gsapAnimations.length > 0) {
|
|
269
|
+
const anim = gsapAnimations[0];
|
|
270
|
+
lines.push(
|
|
271
|
+
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
|
|
272
|
+
);
|
|
273
|
+
const props = Object.entries(anim.properties)
|
|
274
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
275
|
+
.join(", ");
|
|
276
|
+
if (props) lines.push(`Properties: ${props}`);
|
|
277
|
+
}
|
|
278
|
+
const text = lines.join("\n");
|
|
279
|
+
void navigator.clipboard.writeText(text);
|
|
280
|
+
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
|
|
281
|
+
setClipboardCopied(true);
|
|
282
|
+
clearTimeout(clipboardTimerRef.current);
|
|
283
|
+
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
|
284
|
+
};
|
|
285
|
+
|
|
236
286
|
return (
|
|
237
287
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
|
238
288
|
<div className="px-4 py-3">
|
|
@@ -246,60 +296,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
246
296
|
<div className="flex items-center gap-1">
|
|
247
297
|
<button
|
|
248
298
|
type="button"
|
|
249
|
-
onClick={
|
|
250
|
-
const file = element.sourceFile ?? "index.html";
|
|
251
|
-
let lineNum: number | null = null;
|
|
252
|
-
try {
|
|
253
|
-
const src =
|
|
254
|
-
previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
|
|
255
|
-
if (src && element.id) {
|
|
256
|
-
const idx = src.indexOf(`id="${element.id}"`);
|
|
257
|
-
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
|
258
|
-
}
|
|
259
|
-
if (!lineNum && element.selector) {
|
|
260
|
-
const tag = element.tagName.toLowerCase();
|
|
261
|
-
const cls = element.selector.startsWith(".")
|
|
262
|
-
? element.selector.slice(1).split(".")[0]
|
|
263
|
-
: null;
|
|
264
|
-
const search = cls ? `class="${cls}` : `<${tag}`;
|
|
265
|
-
const idx = src.indexOf(search);
|
|
266
|
-
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
|
267
|
-
}
|
|
268
|
-
} catch {}
|
|
269
|
-
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
|
|
270
|
-
const lines = [
|
|
271
|
-
`Element: ${element.label} (${sourceLabel})`,
|
|
272
|
-
`File: ${fileLoc}`,
|
|
273
|
-
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
|
|
274
|
-
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
|
|
275
|
-
`Tag: <${element.tagName}>`,
|
|
276
|
-
];
|
|
277
|
-
if (
|
|
278
|
-
element.computedStyles["z-index"] &&
|
|
279
|
-
element.computedStyles["z-index"] !== "auto"
|
|
280
|
-
) {
|
|
281
|
-
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
|
|
282
|
-
}
|
|
283
|
-
if (gsapAnimations.length > 0) {
|
|
284
|
-
const anim = gsapAnimations[0];
|
|
285
|
-
lines.push(
|
|
286
|
-
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
|
|
287
|
-
);
|
|
288
|
-
const props = Object.entries(anim.properties)
|
|
289
|
-
.map(([k, v]) => `${k}: ${v}`)
|
|
290
|
-
.join(", ");
|
|
291
|
-
if (props) lines.push(`Properties: ${props}`);
|
|
292
|
-
}
|
|
293
|
-
const text = lines.join("\n");
|
|
294
|
-
void navigator.clipboard.writeText(text);
|
|
295
|
-
showToast(
|
|
296
|
-
`Copied element info for ${element.label} — paste into any AI agent`,
|
|
297
|
-
"info",
|
|
298
|
-
);
|
|
299
|
-
setClipboardCopied(true);
|
|
300
|
-
clearTimeout(clipboardTimerRef.current);
|
|
301
|
-
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
|
302
|
-
}}
|
|
299
|
+
onClick={handleCopyElementInfo}
|
|
303
300
|
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
|
|
304
301
|
clipboardCopied
|
|
305
302
|
? "text-studio-accent"
|
|
@@ -558,6 +555,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
558
555
|
onSetArcPath={onSetArcPath}
|
|
559
556
|
onUpdateArcSegment={onUpdateArcSegment}
|
|
560
557
|
onUnroll={onUnroll}
|
|
558
|
+
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
|
561
559
|
/>
|
|
562
560
|
)}
|
|
563
561
|
|
|
@@ -28,6 +28,7 @@ export interface GsapAnimationEditCallbacks {
|
|
|
28
28
|
segmentIndex: number,
|
|
29
29
|
update: Partial<ArcPathSegment>,
|
|
30
30
|
) => void;
|
|
31
|
+
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
|
31
32
|
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
|
|
32
33
|
onUnroll?: (animationId: string) => void;
|
|
33
34
|
}
|
|
@@ -119,6 +119,9 @@ export const EASE_LABELS: Record<string, string> = {
|
|
|
119
119
|
"spring-stiff": "Stiff spring",
|
|
120
120
|
"spring-wobbly": "Wobbly spring",
|
|
121
121
|
"spring-heavy": "Heavy spring",
|
|
122
|
+
"ae-ease": "Easy Ease (AE)",
|
|
123
|
+
"ae-ease-in": "Easy Ease In (AE)",
|
|
124
|
+
"ae-ease-out": "Easy Ease Out (AE)",
|
|
122
125
|
};
|
|
123
126
|
|
|
124
127
|
export const EASE_CURVES: Record<string, [number, number, number, number]> = {
|
|
@@ -141,6 +144,11 @@ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
|
|
|
141
144
|
"expo.out": [0.16, 1, 0.3, 1],
|
|
142
145
|
"expo.in": [0.7, 0, 0.84, 0],
|
|
143
146
|
"expo.inOut": [0.87, 0, 0.13, 1],
|
|
147
|
+
// After Effects polarity: "in" eases into the keyframe (slow END, CP2 y=1),
|
|
148
|
+
// "out" eases out of it (slow START, CP1 y=0). Matches the "(AE)" labels.
|
|
149
|
+
"ae-ease": [0.333, 0, 0.667, 1],
|
|
150
|
+
"ae-ease-in": [0.333, 0.333, 0.667, 1],
|
|
151
|
+
"ae-ease-out": [0.333, 0, 0.667, 0.667],
|
|
144
152
|
};
|
|
145
153
|
|
|
146
154
|
export function parseCustomEaseFromString(ease: string): {
|
|
@@ -443,6 +443,14 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
|
|
|
443
443
|
member.element.removeAttribute("data-hf-drag-initial-offset-y");
|
|
444
444
|
member.element.removeAttribute("data-hf-drag-gsap-base-x");
|
|
445
445
|
member.element.removeAttribute("data-hf-drag-gsap-base-y");
|
|
446
|
+
// Clear the draft's `translate: none` so the soft reload starts clean —
|
|
447
|
+
// otherwise button-less pointermoves after the reload compute deltas
|
|
448
|
+
// from a stale base and fling the element off-screen (#1673).
|
|
449
|
+
// Do NOT clearProps:"transform" — that nukes the committed GSAP position
|
|
450
|
+
// and causes a visual snap-back before the soft reload re-applies it.
|
|
451
|
+
if (member.element.style.getPropertyValue("translate") === "none") {
|
|
452
|
+
member.element.style.removeProperty("translate");
|
|
453
|
+
}
|
|
446
454
|
resumeGsapTimelines(member.element);
|
|
447
455
|
}
|
|
448
456
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import type { DomEditSelection } from "./domEditing";
|
|
3
|
+
import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
|
|
4
|
+
import { isElementComputedVisible } from "./domEditingElement";
|
|
5
|
+
import { coversComposition } from "../../utils/studioPreviewHelpers";
|
|
6
|
+
import { elementObbCorners, marqueeIntersectsObb } from "../../utils/marqueeGeometry";
|
|
7
|
+
|
|
8
|
+
interface MarqueeState {
|
|
9
|
+
startX: number;
|
|
10
|
+
startY: number;
|
|
11
|
+
currentX: number;
|
|
12
|
+
currentY: number;
|
|
13
|
+
pointerId: number;
|
|
14
|
+
pastThreshold: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MARQUEE_THRESHOLD_PX = 4;
|
|
18
|
+
|
|
19
|
+
// fallow-ignore-next-line complexity
|
|
20
|
+
async function runMarqueeIntersection(
|
|
21
|
+
rect: { left: number; top: number; width: number; height: number },
|
|
22
|
+
iframe: HTMLIFrameElement,
|
|
23
|
+
overlayEl: HTMLDivElement,
|
|
24
|
+
activeCompositionPath: string,
|
|
25
|
+
): Promise<DomEditSelection[]> {
|
|
26
|
+
const doc = iframe.contentDocument;
|
|
27
|
+
if (!doc) return [];
|
|
28
|
+
|
|
29
|
+
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
|
|
30
|
+
const isMasterView = !activeCompositionPath || activeCompositionPath === "index.html";
|
|
31
|
+
const items = collectDomEditLayerItems(root, { activeCompositionPath, isMasterView });
|
|
32
|
+
|
|
33
|
+
const rootEl = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement;
|
|
34
|
+
const declW = Number.parseFloat(rootEl?.getAttribute("data-width") ?? "");
|
|
35
|
+
const declH = Number.parseFloat(rootEl?.getAttribute("data-height") ?? "");
|
|
36
|
+
const viewport = {
|
|
37
|
+
width: declW > 0 ? declW : rootEl.getBoundingClientRect().width || 1,
|
|
38
|
+
height: declH > 0 ? declH : rootEl.getBoundingClientRect().height || 1,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const hits: DomEditSelection[] = [];
|
|
42
|
+
for (const item of items) {
|
|
43
|
+
const el = item.element;
|
|
44
|
+
if (!isElementComputedVisible(el)) continue;
|
|
45
|
+
if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
|
|
46
|
+
const corners = elementObbCorners(el, overlayEl, iframe);
|
|
47
|
+
if (!corners) continue;
|
|
48
|
+
if (!marqueeIntersectsObb(rect, corners)) continue;
|
|
49
|
+
const sel = await resolveDomEditSelection(el, {
|
|
50
|
+
activeCompositionPath,
|
|
51
|
+
isMasterView,
|
|
52
|
+
skipSourceProbe: true,
|
|
53
|
+
});
|
|
54
|
+
if (sel) hits.push(sel);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return hits;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface MarqueeGesturesDeps {
|
|
61
|
+
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
62
|
+
overlayRef: React.RefObject<HTMLDivElement | null>;
|
|
63
|
+
activeCompositionPathRef: React.RefObject<string | null>;
|
|
64
|
+
onMarqueeSelectRef: React.RefObject<
|
|
65
|
+
((selections: DomEditSelection[], additive: boolean) => void) | undefined
|
|
66
|
+
>;
|
|
67
|
+
selectionRef: React.RefObject<DomEditSelection | null>;
|
|
68
|
+
gestures: {
|
|
69
|
+
onPointerMove: (event: React.PointerEvent<HTMLDivElement>) => void;
|
|
70
|
+
onPointerUp: (event: React.PointerEvent<HTMLDivElement>) => void;
|
|
71
|
+
clearPointerState: (ref: React.RefObject<DomEditSelection | null>) => void;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// fallow-ignore-next-line complexity
|
|
76
|
+
export function useMarqueeGestures(deps: MarqueeGesturesDeps) {
|
|
77
|
+
const marqueeRef = useRef<MarqueeState | null>(null);
|
|
78
|
+
const [marqueeRect, setMarqueeRect] = useState<{
|
|
79
|
+
left: number;
|
|
80
|
+
top: number;
|
|
81
|
+
width: number;
|
|
82
|
+
height: number;
|
|
83
|
+
} | null>(null);
|
|
84
|
+
|
|
85
|
+
const commitMarquee = useCallback(
|
|
86
|
+
async (
|
|
87
|
+
rect: { left: number; top: number; width: number; height: number },
|
|
88
|
+
additive: boolean,
|
|
89
|
+
) => {
|
|
90
|
+
const iframe = deps.iframeRef.current;
|
|
91
|
+
const overlay = deps.overlayRef.current;
|
|
92
|
+
if (!iframe || !overlay || !deps.onMarqueeSelectRef.current) return;
|
|
93
|
+
const acp = deps.activeCompositionPathRef.current ?? "index.html";
|
|
94
|
+
const hits = await runMarqueeIntersection(rect, iframe, overlay, acp);
|
|
95
|
+
deps.onMarqueeSelectRef.current(hits, additive);
|
|
96
|
+
},
|
|
97
|
+
[deps.iframeRef, deps.overlayRef, deps.onMarqueeSelectRef, deps.activeCompositionPathRef],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const onPointerMove = useCallback(
|
|
101
|
+
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
102
|
+
const m = marqueeRef.current;
|
|
103
|
+
if (m) {
|
|
104
|
+
const oRect = deps.overlayRef.current?.getBoundingClientRect();
|
|
105
|
+
if (!oRect) return;
|
|
106
|
+
m.currentX = event.clientX - oRect.left;
|
|
107
|
+
m.currentY = event.clientY - oRect.top;
|
|
108
|
+
if (!m.pastThreshold) {
|
|
109
|
+
const dx = m.currentX - m.startX;
|
|
110
|
+
const dy = m.currentY - m.startY;
|
|
111
|
+
if (Math.hypot(dx, dy) < MARQUEE_THRESHOLD_PX) return;
|
|
112
|
+
m.pastThreshold = true;
|
|
113
|
+
}
|
|
114
|
+
setMarqueeRect({
|
|
115
|
+
left: Math.min(m.startX, m.currentX),
|
|
116
|
+
top: Math.min(m.startY, m.currentY),
|
|
117
|
+
width: Math.abs(m.currentX - m.startX),
|
|
118
|
+
height: Math.abs(m.currentY - m.startY),
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
deps.gestures.onPointerMove(event);
|
|
123
|
+
},
|
|
124
|
+
[deps.gestures, deps.overlayRef],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const onPointerUp = useCallback(
|
|
128
|
+
(event: React.PointerEvent<HTMLDivElement>) => {
|
|
129
|
+
const m = marqueeRef.current;
|
|
130
|
+
if (m) {
|
|
131
|
+
marqueeRef.current = null;
|
|
132
|
+
try {
|
|
133
|
+
(event.currentTarget as HTMLElement).releasePointerCapture(m.pointerId);
|
|
134
|
+
} catch {
|
|
135
|
+
/* already released */
|
|
136
|
+
}
|
|
137
|
+
if (m.pastThreshold) {
|
|
138
|
+
commitMarquee(
|
|
139
|
+
{
|
|
140
|
+
left: Math.min(m.startX, m.currentX),
|
|
141
|
+
top: Math.min(m.startY, m.currentY),
|
|
142
|
+
width: Math.abs(m.currentX - m.startX),
|
|
143
|
+
height: Math.abs(m.currentY - m.startY),
|
|
144
|
+
},
|
|
145
|
+
event.shiftKey,
|
|
146
|
+
);
|
|
147
|
+
} else {
|
|
148
|
+
deps.onMarqueeSelectRef.current?.([], false);
|
|
149
|
+
}
|
|
150
|
+
setMarqueeRect(null);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
deps.gestures.onPointerUp(event);
|
|
154
|
+
},
|
|
155
|
+
[deps.gestures, commitMarquee, deps.onMarqueeSelectRef],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const onPointerCancel = useCallback(() => {
|
|
159
|
+
if (marqueeRef.current) {
|
|
160
|
+
marqueeRef.current = null;
|
|
161
|
+
setMarqueeRect(null);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
deps.gestures.clearPointerState(deps.selectionRef);
|
|
165
|
+
}, [deps.gestures, deps.selectionRef]);
|
|
166
|
+
|
|
167
|
+
return { marqueeRef, marqueeRect, onPointerMove, onPointerUp, onPointerCancel };
|
|
168
|
+
}
|
|
@@ -66,6 +66,7 @@ export interface PropertyPanelProps {
|
|
|
66
66
|
value: number | string,
|
|
67
67
|
) => void;
|
|
68
68
|
onRemoveKeyframe?: (animationId: string, percentage: number) => void;
|
|
69
|
+
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
|
69
70
|
onConvertToKeyframes?: (animationId: string) => void;
|
|
70
71
|
onCommitAnimatedProperty?: (
|
|
71
72
|
selection: DomEditSelection,
|
|
@@ -100,11 +100,6 @@ export function collectSnapContext(input: {
|
|
|
100
100
|
|
|
101
101
|
const MAX_SNAP_TARGETS = 80;
|
|
102
102
|
const elements = collectVisibleElements(root, input.excludeElements, MAX_SNAP_TARGETS);
|
|
103
|
-
if (elements.length >= MAX_SNAP_TARGETS) {
|
|
104
|
-
console.warn(
|
|
105
|
-
`[snap] Target cap reached (${MAX_SNAP_TARGETS}). Elements beyond this limit are excluded from snap alignment.`,
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
103
|
|
|
109
104
|
const entries: Array<{
|
|
110
105
|
rect: { left: number; top: number; width: number; height: number };
|
|
@@ -50,7 +50,6 @@ export function safeParseManifest(html: string): SlideshowManifest {
|
|
|
50
50
|
try {
|
|
51
51
|
return parseSlideshowManifest(html) ?? { slides: [] };
|
|
52
52
|
} catch {
|
|
53
|
-
console.warn("[SlideshowPanel] Failed to parse slideshow manifest; using empty manifest");
|
|
54
53
|
return { slides: [] };
|
|
55
54
|
}
|
|
56
55
|
}
|
|
@@ -61,6 +61,8 @@ export interface DomEditActionsValue extends Pick<
|
|
|
61
61
|
| "invalidateGsapCache"
|
|
62
62
|
| "previewIframeRef"
|
|
63
63
|
| "commitMutation"
|
|
64
|
+
| "applyMarqueeSelection"
|
|
65
|
+
| "handleUpdateKeyframeEase"
|
|
64
66
|
> {}
|
|
65
67
|
|
|
66
68
|
export interface DomEditSelectionValue extends Pick<
|
|
@@ -167,6 +169,8 @@ export function DomEditProvider({
|
|
|
167
169
|
invalidateGsapCache,
|
|
168
170
|
previewIframeRef,
|
|
169
171
|
commitMutation,
|
|
172
|
+
applyMarqueeSelection,
|
|
173
|
+
handleUpdateKeyframeEase,
|
|
170
174
|
},
|
|
171
175
|
children,
|
|
172
176
|
}: {
|
|
@@ -238,6 +242,8 @@ export function DomEditProvider({
|
|
|
238
242
|
invalidateGsapCache,
|
|
239
243
|
previewIframeRef,
|
|
240
244
|
commitMutation: stableCommitMutation,
|
|
245
|
+
applyMarqueeSelection,
|
|
246
|
+
handleUpdateKeyframeEase,
|
|
241
247
|
}),
|
|
242
248
|
[
|
|
243
249
|
handleTimelineElementSelect,
|
|
@@ -295,6 +301,8 @@ export function DomEditProvider({
|
|
|
295
301
|
invalidateGsapCache,
|
|
296
302
|
previewIframeRef,
|
|
297
303
|
stableCommitMutation,
|
|
304
|
+
applyMarqueeSelection,
|
|
305
|
+
handleUpdateKeyframeEase,
|
|
298
306
|
],
|
|
299
307
|
);
|
|
300
308
|
|
|
@@ -165,8 +165,7 @@ async function commitFlatViaKeyframes(
|
|
|
165
165
|
if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
|
|
166
166
|
}
|
|
167
167
|
mainTl.seek(ct);
|
|
168
|
-
} catch
|
|
169
|
-
console.warn("[gsap-drag] start-value read failed; using identity from values", err);
|
|
168
|
+
} catch {
|
|
170
169
|
for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key];
|
|
171
170
|
} finally {
|
|
172
171
|
if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues);
|
|
@@ -239,7 +239,9 @@ export async function tryGsapDragIntercept(
|
|
|
239
239
|
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
|
|
240
240
|
// place (idempotent), else add a new one. This also covers the stale-cache
|
|
241
241
|
// phantom — committing a set is correct because the element genuinely has no live motion.
|
|
242
|
-
|
|
242
|
+
const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
|
|
243
|
+
|
|
244
|
+
if (!hasNonHold) {
|
|
243
245
|
const existingSet =
|
|
244
246
|
posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
|
|
245
247
|
? posAnim
|
|
@@ -251,7 +253,9 @@ export async function tryGsapDragIntercept(
|
|
|
251
253
|
return true;
|
|
252
254
|
}
|
|
253
255
|
|
|
254
|
-
if (!posAnim)
|
|
256
|
+
if (!posAnim) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
255
259
|
|
|
256
260
|
// Verify the anim ID is still valid in the current file. The React-state
|
|
257
261
|
// `animations` list can lag behind the file after a prior mutation changed
|
|
@@ -115,12 +115,7 @@ export function readAllAnimatedProperties(
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
-
} catch
|
|
119
|
-
console.warn(
|
|
120
|
-
"Cross-tween guard failed — baseline capture may include values from other tweens",
|
|
121
|
-
e,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
118
|
+
} catch {}
|
|
124
119
|
for (const p of propKeys) otherTweenProps.delete(p);
|
|
125
120
|
|
|
126
121
|
// Tier 1: Transform + visual properties with universal CSS defaults.
|
|
@@ -224,10 +224,6 @@ export function useDomEditCommits({
|
|
|
224
224
|
target_source_file: selection.sourceFile ?? undefined,
|
|
225
225
|
composition: activeCompPath ?? undefined,
|
|
226
226
|
});
|
|
227
|
-
console.warn(
|
|
228
|
-
`[studio] Element not found in source: ${targetKey}. ` +
|
|
229
|
-
"This element may be generated at runtime and cannot be persisted.",
|
|
230
|
-
);
|
|
231
227
|
}
|
|
232
228
|
}
|
|
233
229
|
return;
|
|
@@ -68,6 +68,11 @@ export function useDomEditPreviewSync({
|
|
|
68
68
|
|
|
69
69
|
const nextElement = findElementForSelection(doc, currentSelection, activeCompPath);
|
|
70
70
|
if (!nextElement) {
|
|
71
|
+
// The selected element no longer resolves in the (re-synced) document
|
|
72
|
+
// — comp/hot reload, activeCompPath swap, or post-save replacement.
|
|
73
|
+
// Clear so overlay geometry isn't computed on a stale, detached node.
|
|
74
|
+
// (Drag-release-in-gray-zone is handled separately by
|
|
75
|
+
// suppressNextBoxClickRef; the dragged element still resolves here.)
|
|
71
76
|
applyDomSelection(null, { revealPanel: false });
|
|
72
77
|
return;
|
|
73
78
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
1
2
|
import type { TimelineElement } from "../player";
|
|
2
3
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
3
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
@@ -123,6 +124,7 @@ export function useDomEditSession({
|
|
|
123
124
|
buildDomSelectionForTimelineElement,
|
|
124
125
|
handleTimelineElementSelect,
|
|
125
126
|
refreshDomEditSelectionFromPreview,
|
|
127
|
+
applyMarqueeSelection,
|
|
126
128
|
} = useDomSelection({
|
|
127
129
|
projectId,
|
|
128
130
|
activeCompPath,
|
|
@@ -389,6 +391,25 @@ export function useDomEditSession({
|
|
|
389
391
|
updateArcSegment,
|
|
390
392
|
});
|
|
391
393
|
|
|
394
|
+
const handleUpdateKeyframeEase = useCallback(
|
|
395
|
+
(animationId: string, percentage: number, ease: string) => {
|
|
396
|
+
const sel = domEditSelectionRef.current;
|
|
397
|
+
if (!sel) return;
|
|
398
|
+
gsapCommitMutation(
|
|
399
|
+
sel,
|
|
400
|
+
{
|
|
401
|
+
type: "update-keyframe",
|
|
402
|
+
animationId,
|
|
403
|
+
percentage,
|
|
404
|
+
properties: {},
|
|
405
|
+
ease,
|
|
406
|
+
},
|
|
407
|
+
{ label: "Update keyframe ease", softReload: true },
|
|
408
|
+
);
|
|
409
|
+
},
|
|
410
|
+
[gsapCommitMutation, domEditSelectionRef],
|
|
411
|
+
);
|
|
412
|
+
|
|
392
413
|
return {
|
|
393
414
|
// State
|
|
394
415
|
domEditSelection,
|
|
@@ -429,6 +450,7 @@ export function useDomEditSession({
|
|
|
429
450
|
buildDomSelectionFromTarget,
|
|
430
451
|
buildDomSelectionForTimelineElement,
|
|
431
452
|
updateDomEditHoverSelection,
|
|
453
|
+
applyMarqueeSelection,
|
|
432
454
|
resolveImportedFontAsset,
|
|
433
455
|
setAgentModalOpen,
|
|
434
456
|
setAgentPromptSelectionContext,
|
|
@@ -454,6 +476,7 @@ export function useDomEditSession({
|
|
|
454
476
|
handleGsapConvertToKeyframes,
|
|
455
477
|
handleGsapRemoveAllKeyframes,
|
|
456
478
|
handleResetSelectedElementKeyframes,
|
|
479
|
+
handleUpdateKeyframeEase,
|
|
457
480
|
commitAnimatedProperty,
|
|
458
481
|
handleSetArcPath,
|
|
459
482
|
handleUpdateArcSegment,
|