@hyperframes/studio 0.7.5 → 0.7.7
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-B2YXvFxf.js} +1 -1
- package/dist/assets/index-BSkUuN8g.css +1 -0
- package/dist/assets/index-BeRh2hMe.js +375 -0
- package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
- 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 +2 -0
- package/dist/index.html +2 -2
- package/dist/index.js +3326 -2939
- 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 +4 -0
- package/src/components/editor/AnimationCard.tsx +77 -250
- 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 +177 -101
- package/src/components/editor/GsapAnimationSection.tsx +4 -0
- package/src/components/editor/KeyframeEaseList.tsx +127 -0
- package/src/components/editor/OffCanvasIndicators.tsx +111 -0
- package/src/components/editor/PropertyPanel.tsx +63 -56
- package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
- package/src/components/editor/gsapAnimationConstants.ts +11 -32
- package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
- package/src/components/editor/manualOffsetDrag.ts +8 -0
- package/src/components/editor/marqueeCommit.ts +168 -0
- package/src/components/editor/propertyPanelHelpers.ts +7 -0
- package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
- package/src/components/editor/snapTargetCollection.ts +0 -5
- package/src/components/editor/useMotionPathData.ts +2 -1
- package/src/components/panels/SlideshowPanel.tsx +0 -1
- package/src/components/sidebar/AssetContextMenu.tsx +97 -0
- package/src/components/sidebar/AssetsTab.tsx +542 -254
- package/src/components/sidebar/assetHelpers.ts +40 -0
- package/src/contexts/DomEditContext.tsx +12 -0
- package/src/hooks/gsapDragCommit.ts +101 -0
- package/src/hooks/gsapDragPositionCommit.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.ts +28 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
- 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 +43 -0
- package/src/hooks/useDomEditTextCommits.ts +3 -12
- package/src/hooks/useDomEditWiring.ts +3 -0
- package/src/hooks/useDomSelection.ts +46 -0
- package/src/hooks/useGestureCommit.ts +26 -7
- package/src/hooks/useGestureRecording.ts +2 -16
- package/src/hooks/useGsapAnimationOps.ts +2 -2
- package/src/hooks/useGsapAwareEditing.ts +0 -4
- package/src/hooks/useGsapScriptCommits.ts +0 -12
- package/src/hooks/useGsapTweenCache.test.ts +45 -1
- package/src/hooks/useGsapTweenCache.ts +151 -42
- package/src/hooks/useMusicBeatAnalysis.ts +36 -21
- package/src/hooks/useRazorSplit.ts +0 -3
- 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/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
- package/dist/assets/index-DzWIinxk.css +0 -1
- package/src/utils/editDebugLog.ts +0 -16
|
@@ -84,6 +84,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
84
84
|
onSetArcPath,
|
|
85
85
|
onUpdateArcSegment,
|
|
86
86
|
onUnroll,
|
|
87
|
+
onUpdateKeyframeEase,
|
|
88
|
+
onSetAllKeyframeEases,
|
|
87
89
|
onAddKeyframe,
|
|
88
90
|
onRemoveKeyframe,
|
|
89
91
|
onConvertToKeyframes,
|
|
@@ -233,6 +235,55 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
233
235
|
const displayH = gsapRuntimeValues?.height ?? resolvedHeight;
|
|
234
236
|
const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
|
|
235
237
|
|
|
238
|
+
// fallow-ignore-next-line complexity
|
|
239
|
+
const handleCopyElementInfo = () => {
|
|
240
|
+
const file = element.sourceFile ?? "index.html";
|
|
241
|
+
let lineNum: number | null = null;
|
|
242
|
+
try {
|
|
243
|
+
const src = previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
|
|
244
|
+
if (src && element.id) {
|
|
245
|
+
const idx = src.indexOf(`id="${element.id}"`);
|
|
246
|
+
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
|
247
|
+
}
|
|
248
|
+
if (!lineNum && element.selector) {
|
|
249
|
+
const tag = element.tagName.toLowerCase();
|
|
250
|
+
const cls = element.selector.startsWith(".")
|
|
251
|
+
? element.selector.slice(1).split(".")[0]
|
|
252
|
+
: null;
|
|
253
|
+
const search = cls ? `class="${cls}` : `<${tag}`;
|
|
254
|
+
const idx = src.indexOf(search);
|
|
255
|
+
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
|
|
256
|
+
}
|
|
257
|
+
} catch {}
|
|
258
|
+
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
|
|
259
|
+
const lines = [
|
|
260
|
+
`Element: ${element.label} (${sourceLabel})`,
|
|
261
|
+
`File: ${fileLoc}`,
|
|
262
|
+
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
|
|
263
|
+
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
|
|
264
|
+
`Tag: <${element.tagName}>`,
|
|
265
|
+
];
|
|
266
|
+
if (element.computedStyles["z-index"] && element.computedStyles["z-index"] !== "auto") {
|
|
267
|
+
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
|
|
268
|
+
}
|
|
269
|
+
if (gsapAnimations.length > 0) {
|
|
270
|
+
const anim = gsapAnimations[0];
|
|
271
|
+
lines.push(
|
|
272
|
+
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
|
|
273
|
+
);
|
|
274
|
+
const props = Object.entries(anim.properties)
|
|
275
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
276
|
+
.join(", ");
|
|
277
|
+
if (props) lines.push(`Properties: ${props}`);
|
|
278
|
+
}
|
|
279
|
+
const text = lines.join("\n");
|
|
280
|
+
void navigator.clipboard.writeText(text);
|
|
281
|
+
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
|
|
282
|
+
setClipboardCopied(true);
|
|
283
|
+
clearTimeout(clipboardTimerRef.current);
|
|
284
|
+
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
|
|
285
|
+
};
|
|
286
|
+
|
|
236
287
|
return (
|
|
237
288
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
|
|
238
289
|
<div className="px-4 py-3">
|
|
@@ -246,60 +297,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
246
297
|
<div className="flex items-center gap-1">
|
|
247
298
|
<button
|
|
248
299
|
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
|
-
}}
|
|
300
|
+
onClick={handleCopyElementInfo}
|
|
303
301
|
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
|
|
304
302
|
clipboardCopied
|
|
305
303
|
? "text-studio-accent"
|
|
@@ -350,8 +348,15 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
350
348
|
onRemoveTextField={onRemoveTextField}
|
|
351
349
|
/>
|
|
352
350
|
|
|
353
|
-
{element.dataAttributes.start != null && (
|
|
354
|
-
|
|
351
|
+
{(element.dataAttributes.start != null || gsapAnimations.length > 0) && (
|
|
352
|
+
// Render whenever there's an authored clip range OR animations to infer
|
|
353
|
+
// one from — a pure-GSAP element with no data-start still gets a Timing
|
|
354
|
+
// range (TimingSection derives it from its tweens).
|
|
355
|
+
<TimingSection
|
|
356
|
+
element={element}
|
|
357
|
+
animations={gsapAnimations}
|
|
358
|
+
onSetAttribute={onSetAttribute}
|
|
359
|
+
/>
|
|
355
360
|
)}
|
|
356
361
|
{isMediaElement(element) && (
|
|
357
362
|
<MediaSection
|
|
@@ -558,6 +563,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
558
563
|
onSetArcPath={onSetArcPath}
|
|
559
564
|
onUpdateArcSegment={onUpdateArcSegment}
|
|
560
565
|
onUnroll={onUnroll}
|
|
566
|
+
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
|
567
|
+
onSetAllKeyframeEases={onSetAllKeyframeEases}
|
|
561
568
|
/>
|
|
562
569
|
)}
|
|
563
570
|
|
|
@@ -28,6 +28,9 @@ export interface GsapAnimationEditCallbacks {
|
|
|
28
28
|
segmentIndex: number,
|
|
29
29
|
update: Partial<ArcPathSegment>,
|
|
30
30
|
) => void;
|
|
31
|
+
onUpdateKeyframeEase?: (animationId: string, percentage: number, ease: string) => void;
|
|
32
|
+
/** Apply one ease to every keyframe segment at once (clears per-segment overrides). */
|
|
33
|
+
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
|
31
34
|
/** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
|
|
32
35
|
onUnroll?: (animationId: string) => void;
|
|
33
36
|
}
|
|
@@ -88,38 +88,12 @@ export const PROP_TOOLTIPS: Record<string, string> = {
|
|
|
88
88
|
innerText: "End value for a number roll-up (the number it counts up/down to)",
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"power1.in": "Gentle speedup",
|
|
98
|
-
"power2.in": "Smooth speedup",
|
|
99
|
-
"power3.in": "Strong speedup",
|
|
100
|
-
"power4.in": "Sharp speedup",
|
|
101
|
-
"power1.inOut": "Gentle ease",
|
|
102
|
-
"power2.inOut": "Smooth ease",
|
|
103
|
-
"power3.inOut": "Strong ease",
|
|
104
|
-
"power4.inOut": "Sharp ease",
|
|
105
|
-
"back.out": "Overshoot & settle",
|
|
106
|
-
"back.in": "Pull back & go",
|
|
107
|
-
"back.inOut": "Pull & overshoot",
|
|
108
|
-
"elastic.out": "Springy bounce",
|
|
109
|
-
"elastic.in": "Wind up spring",
|
|
110
|
-
"elastic.inOut": "Full spring",
|
|
111
|
-
"bounce.out": "Drop & bounce",
|
|
112
|
-
"bounce.in": "Reverse bounce",
|
|
113
|
-
"bounce.inOut": "Double bounce",
|
|
114
|
-
"expo.out": "Very snappy stop",
|
|
115
|
-
"expo.in": "Very slow start",
|
|
116
|
-
"expo.inOut": "Dramatic ease",
|
|
117
|
-
"spring-gentle": "Gentle spring",
|
|
118
|
-
"spring-bouncy": "Bouncy spring",
|
|
119
|
-
"spring-stiff": "Stiff spring",
|
|
120
|
-
"spring-wobbly": "Wobbly spring",
|
|
121
|
-
"spring-heavy": "Heavy spring",
|
|
122
|
-
};
|
|
91
|
+
// Ease labels surface the raw GSAP token (e.g. "power2.out", "back.out") rather
|
|
92
|
+
// than friendly names — motion authors recognize the GSAP vocabulary, and the
|
|
93
|
+
// invented labels ("Smooth speedup") confused users. Every consumer reads
|
|
94
|
+
// `EASE_LABELS[token] ?? token`, so an empty map cleanly falls through to the
|
|
95
|
+
// token; re-add an entry here only to override a specific token's display.
|
|
96
|
+
export const EASE_LABELS: Record<string, string> = {};
|
|
123
97
|
|
|
124
98
|
export const EASE_CURVES: Record<string, [number, number, number, number]> = {
|
|
125
99
|
none: [0, 0, 1, 1],
|
|
@@ -141,6 +115,11 @@ export const EASE_CURVES: Record<string, [number, number, number, number]> = {
|
|
|
141
115
|
"expo.out": [0.16, 1, 0.3, 1],
|
|
142
116
|
"expo.in": [0.7, 0, 0.84, 0],
|
|
143
117
|
"expo.inOut": [0.87, 0, 0.13, 1],
|
|
118
|
+
// After Effects polarity: "in" eases into the keyframe (slow END, CP2 y=1),
|
|
119
|
+
// "out" eases out of it (slow START, CP1 y=0). Matches the "(AE)" labels.
|
|
120
|
+
"ae-ease": [0.333, 0, 0.667, 1],
|
|
121
|
+
"ae-ease-in": [0.333, 0.333, 0.667, 1],
|
|
122
|
+
"ae-ease-out": [0.333, 0, 0.667, 0.667],
|
|
144
123
|
};
|
|
145
124
|
|
|
146
125
|
export function parseCustomEaseFromString(ease: string): {
|
|
@@ -73,7 +73,7 @@ describe("buildTweenSummary", () => {
|
|
|
73
73
|
expect(s).toContain("[opacity 0%");
|
|
74
74
|
expect(s).toContain("move x -50px");
|
|
75
75
|
expect(s).toContain("opacity to 100%");
|
|
76
|
-
expect(s).toContain("
|
|
76
|
+
expect(s).toContain("expo.out");
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
it("handles fromTo with empty fromProperties", () => {
|
|
@@ -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,8 @@ 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;
|
|
70
|
+
onSetAllKeyframeEases?: (animationId: string, ease: string) => void;
|
|
69
71
|
onConvertToKeyframes?: (animationId: string) => void;
|
|
70
72
|
onCommitAnimatedProperty?: (
|
|
71
73
|
selection: DomEditSelection,
|
|
@@ -210,7 +212,9 @@ export const LABEL = "text-[11px] font-medium text-panel-text-3";
|
|
|
210
212
|
export const RESPONSIVE_GRID = "grid grid-cols-[repeat(auto-fit,minmax(118px,1fr))] gap-3";
|
|
211
213
|
export const EMPTY_STYLES: Record<string, string> = {};
|
|
212
214
|
|
|
215
|
+
// fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
|
213
216
|
export const EMPTY_FILTER_VALUE = "none";
|
|
217
|
+
// fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
|
214
218
|
export const BOX_SHADOW_PRESETS = {
|
|
215
219
|
none: "none",
|
|
216
220
|
soft: "0 12px 36px rgba(0, 0, 0, 0.28)",
|
|
@@ -271,6 +275,7 @@ export function parsePxMetricValue(value: string): number | null {
|
|
|
271
275
|
return token.value;
|
|
272
276
|
}
|
|
273
277
|
|
|
278
|
+
// fallow-ignore-next-line unused-exports -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
|
274
279
|
export function clampPanelNumber(
|
|
275
280
|
value: number,
|
|
276
281
|
min: number,
|
|
@@ -319,6 +324,7 @@ export function normalizeTextMetricValue(
|
|
|
319
324
|
function splitCssFunctions(value: string): string[] {
|
|
320
325
|
const functions: string[] = [];
|
|
321
326
|
let current = "";
|
|
327
|
+
// fallow-ignore-next-line code-duplication -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
|
322
328
|
let depth = 0;
|
|
323
329
|
|
|
324
330
|
for (const char of value.trim()) {
|
|
@@ -484,6 +490,7 @@ export function extractBackgroundImageUrl(value: string | undefined): string {
|
|
|
484
490
|
|
|
485
491
|
// ── GSAP runtime value readers (used by PropertyPanel) ────────────────────
|
|
486
492
|
|
|
493
|
+
// fallow-ignore-next-line complexity -- pre-existing; surfaced in this file's diff by an unrelated line shift
|
|
487
494
|
export function readGsapRuntimeValuesForPanel(
|
|
488
495
|
gsapAnimId: string | null,
|
|
489
496
|
gsapAnimations: GsapAnimation[],
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
1
2
|
import { Clock } from "../../icons/SystemIcons";
|
|
2
3
|
import type { DomEditSelection } from "./domEditing";
|
|
3
4
|
import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
|
|
@@ -9,18 +10,45 @@ function parseTimingValue(input: string): number | null {
|
|
|
9
10
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Derive a time range from the element's GSAP tweens (earliest start → latest
|
|
15
|
+
* end) so an element animated purely by GSAP — with no `data-start` /
|
|
16
|
+
* `data-duration` — still shows a meaningful Timing range instead of 0s.
|
|
17
|
+
*/
|
|
18
|
+
function deriveTimingFromAnimations(
|
|
19
|
+
animations: GsapAnimation[],
|
|
20
|
+
): { start: number; duration: number } | null {
|
|
21
|
+
let lo = Infinity;
|
|
22
|
+
let hi = -Infinity;
|
|
23
|
+
for (const a of animations) {
|
|
24
|
+
const s = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
|
|
25
|
+
const d = a.duration ?? 0;
|
|
26
|
+
lo = Math.min(lo, s);
|
|
27
|
+
hi = Math.max(hi, s + d);
|
|
28
|
+
}
|
|
29
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi <= lo) return null;
|
|
30
|
+
return { start: lo, duration: hi - lo };
|
|
31
|
+
}
|
|
32
|
+
|
|
12
33
|
export function TimingSection({
|
|
13
34
|
element,
|
|
35
|
+
animations = [],
|
|
14
36
|
onSetAttribute,
|
|
15
37
|
}: {
|
|
16
38
|
element: DomEditSelection;
|
|
39
|
+
animations?: GsapAnimation[];
|
|
17
40
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
18
41
|
}) {
|
|
19
|
-
const
|
|
20
|
-
const
|
|
42
|
+
const explicitStart = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
|
|
43
|
+
const explicitDuration =
|
|
21
44
|
Number.parseFloat(
|
|
22
45
|
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
|
|
23
46
|
) || 0;
|
|
47
|
+
|
|
48
|
+
// No authored clip timing → infer the range from the element's animations.
|
|
49
|
+
const derived = explicitDuration > 0 ? null : deriveTimingFromAnimations(animations);
|
|
50
|
+
const start = derived ? derived.start : explicitStart;
|
|
51
|
+
const duration = derived ? derived.duration : explicitDuration;
|
|
24
52
|
const end = start + duration;
|
|
25
53
|
|
|
26
54
|
const commitStart = (nextValue: string) => {
|
|
@@ -54,6 +82,11 @@ export function TimingSection({
|
|
|
54
82
|
onCommit={commitDuration}
|
|
55
83
|
/>
|
|
56
84
|
</div>
|
|
85
|
+
{derived && (
|
|
86
|
+
<p className="mt-2 text-[10px] leading-snug text-neutral-500">
|
|
87
|
+
Inferred from this element’s animation — edit to pin an explicit clip range.
|
|
88
|
+
</p>
|
|
89
|
+
)}
|
|
57
90
|
</Section>
|
|
58
91
|
);
|
|
59
92
|
}
|
|
@@ -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 };
|
|
@@ -119,7 +119,8 @@ export function useMotionPathData(
|
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
121
|
const recompute = () => {
|
|
122
|
-
|
|
122
|
+
// Position-only: never let a co-located size/scale tween shadow the path.
|
|
123
|
+
const read = readRuntimeKeyframes(iframeRef.current, selector, undefined, ["x", "y"]);
|
|
123
124
|
const next = buildMotionPathGeometry(read);
|
|
124
125
|
setGeometry((prev) =>
|
|
125
126
|
prev?.points === next?.points && prev?.kind === next?.kind ? prev : next,
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export function ContextMenu({
|
|
2
|
+
x,
|
|
3
|
+
y,
|
|
4
|
+
asset,
|
|
5
|
+
onClose,
|
|
6
|
+
onCopy,
|
|
7
|
+
onDelete,
|
|
8
|
+
onRename,
|
|
9
|
+
}: {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
asset: string;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
onCopy: (path: string) => void;
|
|
15
|
+
onDelete?: (path: string) => void;
|
|
16
|
+
onRename?: (oldPath: string, newPath: string) => void;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className="fixed inset-0 z-[200]"
|
|
21
|
+
onClick={onClose}
|
|
22
|
+
onContextMenu={(e) => {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
onClose();
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
<div
|
|
28
|
+
className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[140px] text-xs"
|
|
29
|
+
style={{ left: x, top: y }}
|
|
30
|
+
>
|
|
31
|
+
<button
|
|
32
|
+
onClick={(e) => {
|
|
33
|
+
e.stopPropagation();
|
|
34
|
+
onCopy(asset);
|
|
35
|
+
onClose();
|
|
36
|
+
}}
|
|
37
|
+
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
|
38
|
+
>
|
|
39
|
+
Copy path
|
|
40
|
+
</button>
|
|
41
|
+
{onRename && (
|
|
42
|
+
<button
|
|
43
|
+
onClick={(e) => {
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
onClose();
|
|
46
|
+
}}
|
|
47
|
+
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
|
48
|
+
>
|
|
49
|
+
Rename
|
|
50
|
+
</button>
|
|
51
|
+
)}
|
|
52
|
+
{onDelete && (
|
|
53
|
+
<button
|
|
54
|
+
onClick={(e) => {
|
|
55
|
+
e.stopPropagation();
|
|
56
|
+
onDelete(asset);
|
|
57
|
+
onClose();
|
|
58
|
+
}}
|
|
59
|
+
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-neutral-800 transition-colors"
|
|
60
|
+
>
|
|
61
|
+
Delete
|
|
62
|
+
</button>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function DeleteConfirm({
|
|
70
|
+
name,
|
|
71
|
+
onConfirm,
|
|
72
|
+
onCancel,
|
|
73
|
+
}: {
|
|
74
|
+
name: string;
|
|
75
|
+
onConfirm: () => void;
|
|
76
|
+
onCancel: () => void;
|
|
77
|
+
}) {
|
|
78
|
+
return (
|
|
79
|
+
<div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex items-center justify-between gap-2">
|
|
80
|
+
<span className="text-[10px] text-red-400 truncate">Delete {name}?</span>
|
|
81
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
82
|
+
<button
|
|
83
|
+
onClick={onConfirm}
|
|
84
|
+
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 transition-colors"
|
|
85
|
+
>
|
|
86
|
+
Delete
|
|
87
|
+
</button>
|
|
88
|
+
<button
|
|
89
|
+
onClick={onCancel}
|
|
90
|
+
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
|
|
91
|
+
>
|
|
92
|
+
Cancel
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|