@hyperframes/studio 0.6.87 → 0.6.89

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.
Files changed (49) hide show
  1. package/dist/assets/index-2SbRRd33.js +146 -0
  2. package/dist/assets/index-D2NkPomd.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +33 -193
  6. package/src/components/StudioLeftSidebar.tsx +6 -0
  7. package/src/components/StudioRightPanel.tsx +8 -0
  8. package/src/components/TimelineToolbar.tsx +54 -31
  9. package/src/components/editor/AnimationCard.tsx +15 -3
  10. package/src/components/editor/DomEditOverlay.test.ts +34 -1
  11. package/src/components/editor/FileTree.tsx +5 -1
  12. package/src/components/editor/FileTreeNodes.tsx +17 -3
  13. package/src/components/editor/LayersPanel.tsx +19 -4
  14. package/src/components/editor/PropertyPanel.tsx +82 -170
  15. package/src/components/editor/domEditOverlayStartGesture.ts +1 -0
  16. package/src/components/editor/gsapAnimatesProperty.ts +52 -0
  17. package/src/components/editor/manualEditsDom.ts +11 -57
  18. package/src/components/editor/manualOffsetDrag.test.ts +18 -1
  19. package/src/components/editor/manualOffsetDrag.ts +16 -10
  20. package/src/components/editor/propertyPanel3dTransform.tsx +133 -0
  21. package/src/components/editor/propertyPanelHelpers.ts +76 -0
  22. package/src/components/editor/propertyPanelStyleSections.tsx +1 -9
  23. package/src/components/editor/useDomEditOverlayGestures.ts +3 -0
  24. package/src/components/editor/useLayerDrag.ts +6 -3
  25. package/src/components/renders/RenderQueueItem.tsx +47 -46
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -2
  27. package/src/components/sidebar/LeftSidebar.tsx +11 -0
  28. package/src/hooks/gsapDragCommit.ts +294 -0
  29. package/src/hooks/gsapKeyframeCacheHelpers.ts +88 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +49 -402
  31. package/src/hooks/gsapRuntimeReaders.ts +201 -0
  32. package/src/hooks/timelineEditingHelpers.ts +148 -0
  33. package/src/hooks/useAnimatedPropertyCommit.ts +54 -12
  34. package/src/hooks/useBlockHandlers.ts +150 -0
  35. package/src/hooks/useClipboard.ts +1 -10
  36. package/src/hooks/useDomEditPreviewSync.ts +126 -0
  37. package/src/hooks/useDomEditSession.ts +11 -79
  38. package/src/hooks/useGestureCommit.ts +166 -0
  39. package/src/hooks/useGestureRecording.ts +271 -169
  40. package/src/hooks/useGsapScriptCommits.ts +7 -80
  41. package/src/hooks/useLintModal.ts +97 -25
  42. package/src/hooks/useTimelineEditing.ts +10 -132
  43. package/src/player/components/TimelineCanvas.tsx +24 -7
  44. package/src/player/components/useTimelinePlayhead.ts +2 -1
  45. package/src/player/store/playerStore.ts +12 -0
  46. package/src/utils/gsapSoftReload.ts +18 -1
  47. package/src/utils/studioUrlState.test.ts +9 -0
  48. package/dist/assets/index-BA19FAPN.js +0 -143
  49. package/dist/assets/index-CGlIm_-E.css +0 -1
@@ -54,6 +54,8 @@ interface LeftSidebarProps {
54
54
  isRendering?: boolean;
55
55
  onLint?: () => void;
56
56
  linting?: boolean;
57
+ lintFindingCount?: number;
58
+ lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
57
59
  onToggleCollapse?: () => void;
58
60
  onAddBlock?: (blockName: string) => void;
59
61
  onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
@@ -84,6 +86,8 @@ export const LeftSidebar = memo(
84
86
  isRendering,
85
87
  onLint,
86
88
  linting,
89
+ lintFindingCount,
90
+ lintFindingsByFile,
87
91
  onToggleCollapse,
88
92
  onAddBlock,
89
93
  onPreviewBlock,
@@ -216,6 +220,7 @@ export const LeftSidebar = memo(
216
220
  onSelect={onSelectComposition}
217
221
  onRenderComposition={onRenderComposition}
218
222
  isRendering={isRendering}
223
+ lintFindingsByFile={lintFindingsByFile}
219
224
  />
220
225
  )}
221
226
  {tab === "assets" && (
@@ -242,6 +247,7 @@ export const LeftSidebar = memo(
242
247
  onDuplicateFile={onDuplicateFile}
243
248
  onMoveFile={onMoveFile}
244
249
  onImportFiles={onImportFiles}
250
+ lintFindingsByFile={lintFindingsByFile}
245
251
  />
246
252
  </div>
247
253
  )}
@@ -279,6 +285,11 @@ export const LeftSidebar = memo(
279
285
  <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
280
286
  </svg>
281
287
  {linting ? "Linting…" : "Lint"}
288
+ {!linting && lintFindingCount != null && lintFindingCount > 0 && (
289
+ <span className="ml-1 min-w-[16px] rounded-full bg-amber-500/20 px-1 text-[9px] font-bold text-amber-400">
290
+ {lintFindingCount}
291
+ </span>
292
+ )}
282
293
  </button>
283
294
  </div>
284
295
  )}
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Low-level drag commit helpers for GSAP position mutations.
3
+ * Extracted from gsapRuntimeBridge.ts to keep file sizes under the 600-line limit.
4
+ */
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import { usePlayerStore } from "../player/store/playerStore";
8
+ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes";
9
+ import {
10
+ absoluteToPercentage,
11
+ resolveTweenStart,
12
+ resolveTweenDuration,
13
+ } from "../utils/globalTimeCompiler";
14
+ import { readAllAnimatedProperties } from "./gsapRuntimeReaders";
15
+
16
+ export interface GsapDragCommitCallbacks {
17
+ commitMutation: (
18
+ selection: DomEditSelection,
19
+ mutation: Record<string, unknown>,
20
+ options: {
21
+ label: string;
22
+ coalesceKey?: string;
23
+ softReload?: boolean;
24
+ skipReload?: boolean;
25
+ beforeReload?: () => void;
26
+ },
27
+ ) => Promise<void>;
28
+ }
29
+
30
+ // ── Percentage computation ─────────────────────────────────────────────────
31
+
32
+ export function computeCurrentPercentage(
33
+ selection: DomEditSelection,
34
+ animation?: GsapAnimation,
35
+ ): number {
36
+ const currentTime = usePlayerStore.getState().currentTime;
37
+ if (animation) {
38
+ const start = resolveTweenStart(animation);
39
+ const duration = resolveTweenDuration(animation);
40
+ if (start !== null) {
41
+ return absoluteToPercentage(currentTime, start, duration);
42
+ }
43
+ }
44
+ const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
45
+ const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
46
+ return elDuration > 0
47
+ ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
48
+ : 0;
49
+ }
50
+
51
+ // ── Dynamic keyframe materialization ──────────────────────────────────────
52
+
53
+ export async function materializeIfDynamic(
54
+ anim: GsapAnimation,
55
+ iframe: HTMLIFrameElement | null,
56
+ commitMutation: GsapDragCommitCallbacks["commitMutation"],
57
+ selection: DomEditSelection,
58
+ ): Promise<string | void> {
59
+ if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
60
+
61
+ if (anim.hasUnresolvedSelector) {
62
+ const allScanned = scanAllRuntimeKeyframes(iframe);
63
+ if (allScanned.size === 0) return;
64
+ const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
65
+ selector: `#${id}`,
66
+ keyframes: data.keyframes,
67
+ easeEach: data.easeEach,
68
+ }));
69
+ await commitMutation(
70
+ selection,
71
+ {
72
+ type: "materialize-keyframes",
73
+ animationId: anim.id,
74
+ keyframes: allScanned.get(selection.id ?? "")?.keyframes ?? [],
75
+ allElements,
76
+ },
77
+ { label: "Unroll dynamic animations", skipReload: true },
78
+ );
79
+ return `${anim.targetSelector}-to-0`;
80
+ }
81
+
82
+ const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
83
+ if (!runtime || runtime.keyframes.length === 0) return;
84
+ await commitMutation(
85
+ selection,
86
+ {
87
+ type: "materialize-keyframes",
88
+ animationId: anim.id,
89
+ keyframes: runtime.keyframes,
90
+ easeEach: runtime.easeEach,
91
+ },
92
+ { label: "Materialize dynamic keyframes", skipReload: true },
93
+ );
94
+ }
95
+
96
+ // ── Extend tween ──────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Extend a tween's time range to cover `targetTime`, remap all existing
100
+ * keyframe percentages to preserve their absolute positions, then add
101
+ * a new keyframe at the target time.
102
+ */
103
+ async function extendTweenAndAddKeyframe(
104
+ selection: DomEditSelection,
105
+ anim: GsapAnimation,
106
+ properties: Record<string, number>,
107
+ targetTime: number,
108
+ tweenStart: number,
109
+ tweenDuration: number,
110
+ callbacks: GsapDragCommitCallbacks,
111
+ beforeReload?: () => void,
112
+ ): Promise<void> {
113
+ const tweenEnd = tweenStart + tweenDuration;
114
+ const newStart = Math.min(targetTime, tweenStart);
115
+ const newEnd = Math.max(targetTime, tweenEnd);
116
+ const newDuration = Math.max(0.01, newEnd - newStart);
117
+
118
+ const existingKfs = anim.keyframes?.keyframes ?? [];
119
+ const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
120
+ [];
121
+ for (const kf of existingKfs) {
122
+ const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
123
+ const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
124
+ remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } });
125
+ }
126
+
127
+ const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
128
+ remappedKfs.push({ percentage: targetPct, properties });
129
+ remappedKfs.sort((a, b) => a.percentage - b.percentage);
130
+
131
+ await callbacks.commitMutation(
132
+ selection,
133
+ { type: "delete", animationId: anim.id },
134
+ { label: "Extend tween range", skipReload: true },
135
+ );
136
+
137
+ const selector = anim.targetSelector;
138
+ await callbacks.commitMutation(
139
+ selection,
140
+ {
141
+ type: "add-with-keyframes",
142
+ targetSelector: selector,
143
+ position: Math.round(newStart * 1000) / 1000,
144
+ duration: Math.round(newDuration * 1000) / 1000,
145
+ keyframes: remappedKfs,
146
+ },
147
+ { label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
148
+ );
149
+ }
150
+
151
+ // fallow-ignore-next-line complexity
152
+ async function commitKeyframedPosition(
153
+ selection: DomEditSelection,
154
+ anim: GsapAnimation,
155
+ properties: Record<string, number>,
156
+ callbacks: GsapDragCommitCallbacks,
157
+ beforeReload?: () => void,
158
+ ): Promise<void> {
159
+ const pct = computeCurrentPercentage(selection, anim);
160
+
161
+ await callbacks.commitMutation(
162
+ selection,
163
+ {
164
+ type: "add-keyframe",
165
+ animationId: anim.id,
166
+ percentage: pct,
167
+ properties,
168
+ },
169
+ { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
170
+ );
171
+ }
172
+
173
+ /**
174
+ * For flat to()/set() tweens, convert to keyframes first so we can place the
175
+ * drag position at the current percentage.
176
+ */
177
+ // fallow-ignore-next-line complexity
178
+ async function commitFlatViaKeyframes(
179
+ selection: DomEditSelection,
180
+ anim: GsapAnimation,
181
+ properties: Record<string, number>,
182
+ callbacks: GsapDragCommitCallbacks,
183
+ beforeReload?: () => void,
184
+ ): Promise<void> {
185
+ await callbacks.commitMutation(
186
+ selection,
187
+ { type: "convert-to-keyframes", animationId: anim.id },
188
+ { label: "Convert to keyframes for drag", skipReload: true },
189
+ );
190
+
191
+ const pct = computeCurrentPercentage(selection, anim);
192
+
193
+ await callbacks.commitMutation(
194
+ selection,
195
+ {
196
+ type: "add-keyframe",
197
+ animationId: anim.id,
198
+ percentage: pct,
199
+ properties,
200
+ },
201
+ { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
202
+ );
203
+ }
204
+
205
+ // ── Main drag commit ──────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Compute the new GSAP position values from runtime-read positions + drag
209
+ * offset, then commit the mutation to the GSAP script.
210
+ */
211
+ // fallow-ignore-next-line complexity
212
+ export async function commitGsapPositionFromDrag(
213
+ selection: DomEditSelection,
214
+ anim: GsapAnimation,
215
+ studioOffset: { x: number; y: number },
216
+ gsapPos: { x: number; y: number },
217
+ iframe: HTMLIFrameElement | null,
218
+ selector: string,
219
+ callbacks: GsapDragCommitCallbacks,
220
+ ): Promise<void> {
221
+ const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation");
222
+ const rotDeg = Number.parseFloat(rotStyle) || 0;
223
+ const rad = (-rotDeg * Math.PI) / 180;
224
+ const cos = Math.cos(rad);
225
+ const sin = Math.sin(rad);
226
+ const el = selection.element;
227
+ const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
228
+ const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
229
+ const deltaX = studioOffset.x - origX;
230
+ const deltaY = studioOffset.y - origY;
231
+ const adjX = deltaX * cos - deltaY * sin;
232
+ const adjY = deltaX * sin + deltaY * cos;
233
+ const parsedBaseX = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "");
234
+ const parsedBaseY = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "");
235
+ const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : gsapPos.x;
236
+ const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : gsapPos.y;
237
+ const newX = Math.round(baseGsapX + adjX);
238
+ const newY = Math.round(baseGsapY + adjY);
239
+ const restoreOffset = () => {
240
+ el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
241
+ el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
242
+ el.removeAttribute("data-hf-drag-initial-offset-x");
243
+ el.removeAttribute("data-hf-drag-initial-offset-y");
244
+ };
245
+
246
+ if (anim.keyframes) {
247
+ const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
248
+ const effectiveAnim = newId ? { ...anim, id: newId } : anim;
249
+ const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
250
+
251
+ const ct = usePlayerStore.getState().currentTime;
252
+ const ts = resolveTweenStart(effectiveAnim);
253
+ const td = resolveTweenDuration(effectiveAnim);
254
+ if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) {
255
+ await extendTweenAndAddKeyframe(
256
+ selection,
257
+ effectiveAnim,
258
+ { ...runtimeProps, x: newX, y: newY },
259
+ ct,
260
+ ts,
261
+ td,
262
+ callbacks,
263
+ restoreOffset,
264
+ );
265
+ } else {
266
+ await commitKeyframedPosition(
267
+ selection,
268
+ effectiveAnim,
269
+ { ...runtimeProps, x: newX, y: newY },
270
+ callbacks,
271
+ restoreOffset,
272
+ );
273
+ }
274
+ } else if (anim.method === "from" || anim.method === "fromTo") {
275
+ await callbacks.commitMutation(
276
+ selection,
277
+ {
278
+ type: "convert-to-keyframes",
279
+ animationId: anim.id,
280
+ resolvedFromValues: { x: newX, y: newY },
281
+ },
282
+ { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset },
283
+ );
284
+ } else {
285
+ const runtimeProps = readAllAnimatedProperties(iframe, selector, anim);
286
+ await commitFlatViaKeyframes(
287
+ selection,
288
+ anim,
289
+ { ...runtimeProps, x: newX, y: newY },
290
+ callbacks,
291
+ restoreOffset,
292
+ );
293
+ }
294
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Helpers for reading/writing the GSAP keyframe cache in the player store.
3
+ * Extracted from useGsapScriptCommits to keep file sizes under the 600-line limit.
4
+ */
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
7
+
8
+ export function updateKeyframeCacheFromParsed(
9
+ animations: GsapAnimation[],
10
+ targetPath: string,
11
+ selectionId: string | undefined,
12
+ mutation: Record<string, unknown>,
13
+ ): void {
14
+ const { setKeyframeCache, elements } = usePlayerStore.getState();
15
+ const idsWithKeyframes = new Set<string>();
16
+ const merged = new Map<string, KeyframeCacheEntry>();
17
+ for (const anim of animations) {
18
+ const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
19
+ if (!id || !anim.keyframes) continue;
20
+ idsWithKeyframes.add(id);
21
+
22
+ // Convert tween-relative percentages to clip-relative so diamonds
23
+ // render at the correct position within the timeline clip.
24
+ const tweenPos = typeof anim.position === "number" ? anim.position : 0;
25
+ const tweenDur = anim.duration ?? 1;
26
+ const timelineEl = elements.find(
27
+ (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
28
+ );
29
+ const elStart = timelineEl?.start ?? 0;
30
+ const elDuration = timelineEl?.duration ?? 4;
31
+ const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
32
+ const absTime = tweenPos + (kf.percentage / 100) * tweenDur;
33
+ const clipPct =
34
+ elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
35
+ return { ...kf, percentage: clipPct };
36
+ });
37
+
38
+ const existing = merged.get(id);
39
+ if (existing) {
40
+ const byPct = new Map<number, (typeof existing.keyframes)[0]>();
41
+ for (const kf of [...existing.keyframes, ...clipKeyframes]) {
42
+ const prev = byPct.get(kf.percentage);
43
+ if (prev) {
44
+ prev.properties = { ...prev.properties, ...kf.properties };
45
+ if (kf.ease) prev.ease = kf.ease;
46
+ } else {
47
+ byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
48
+ }
49
+ }
50
+ existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
51
+ } else {
52
+ merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
53
+ }
54
+ }
55
+ for (const [id, entry] of merged) {
56
+ setKeyframeCache(`${targetPath}#${id}`, entry);
57
+ setKeyframeCache(id, entry);
58
+ if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
59
+ }
60
+ const targetId =
61
+ (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
62
+ selectionId;
63
+ if (targetId && !idsWithKeyframes.has(targetId)) {
64
+ setKeyframeCache(`${targetPath}#${targetId}`, undefined);
65
+ if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined);
66
+ }
67
+ }
68
+
69
+ export function buildCacheKey(sourceFile: string, elementId: string): string {
70
+ return `${sourceFile}#${elementId}`;
71
+ }
72
+
73
+ export function readKeyframeSnapshot(
74
+ sourceFile: string,
75
+ elementId: string | null | undefined,
76
+ ): KeyframeCacheEntry | undefined {
77
+ if (!elementId) return undefined;
78
+ return usePlayerStore.getState().keyframeCache.get(buildCacheKey(sourceFile, elementId));
79
+ }
80
+
81
+ export function writeKeyframeCache(
82
+ sourceFile: string,
83
+ elementId: string | null | undefined,
84
+ data: KeyframeCacheEntry | undefined,
85
+ ): void {
86
+ if (!elementId) return;
87
+ usePlayerStore.getState().setKeyframeCache(buildCacheKey(sourceFile, elementId), data);
88
+ }