@hyperframes/studio 0.8.87 → 0.8.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 (31) hide show
  1. package/dist/assets/{hyperframes-player-CtNVImMi.js → hyperframes-player-CVoS7kv8.js} +1 -1
  2. package/dist/assets/{index-DZKcDAtf.js → index-BM8PwYby.js} +187 -187
  3. package/dist/assets/{index-DJ-Ol7YS.js → index-D35-L_yn.js} +1 -1
  4. package/dist/assets/{index-B_MF86RE.js → index-DBub9qQY.js} +1 -1
  5. package/dist/{chunk-7PLTC6SV.js → chunk-KU4F7QZ3.js} +9 -1
  6. package/dist/chunk-KU4F7QZ3.js.map +1 -0
  7. package/dist/index.d.ts +42 -5
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +269 -94
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineHistoryButtons.dom.test.tsx +14 -3
  14. package/src/components/TimelineToolPicker.tsx +90 -0
  15. package/src/components/TimelineToolbar.tsx +2 -27
  16. package/src/components/editor/DomEditOverlay.tsx +7 -4
  17. package/src/components/editor/domEditOverlayGestures.ts +4 -4
  18. package/src/components/editor/useDomEditNudge.ts +2 -2
  19. package/src/hooks/appHotkeysDispatch.ts +8 -0
  20. package/src/hooks/useAppHotkeys.test.ts +58 -1
  21. package/src/hooks/useDomGeometryCommit.test.tsx +226 -0
  22. package/src/hooks/useDomGeometryCommit.ts +147 -0
  23. package/src/hooks/useGsapAwareEditing.ts +9 -4
  24. package/src/index.ts +3 -1
  25. package/src/player/components/Timeline.showKeyframes.test.tsx +91 -0
  26. package/src/player/components/TimelineTypes.ts +1 -0
  27. package/src/player/components/studioShortcuts.ts +2 -0
  28. package/src/player/components/useTimelineKeyframeData.ts +14 -0
  29. package/src/player/components/useTimelineProviderState.tsx +5 -3
  30. package/src/player/store/playerStore.ts +16 -0
  31. package/dist/chunk-7PLTC6SV.js.map +0 -1
@@ -0,0 +1,147 @@
1
+ import { useCallback, useMemo, useRef } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { DomEditSelection } from "../components/editor/domEditing";
4
+ import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
5
+ import { isPreviewBooted } from "../player/store/playerStore";
6
+ import type { DomEditCommitOutcome } from "./domEditCommitRunner";
7
+ import type { UseDomStyleCommitOptions } from "./useDomStyleCommit";
8
+ import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
9
+ import { useGsapAwareEditing } from "./useGsapAwareEditing";
10
+ import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
11
+ import { useGsapScriptCommits } from "./useGsapScriptCommits";
12
+ import { useGsapCacheVersion } from "./useGsapTweenCache";
13
+
14
+ /**
15
+ * Studio's `Player` must show the project, with `beginTimelineSession(projectId)` run before it
16
+ * mounts; drain `waitForPendingSaves` before switching projects.
17
+ */
18
+ export interface UseDomGeometryCommitOptions extends UseDomStyleCommitOptions {
19
+ /** Called when a save cannot patch the preview in place; defaults to reloading the iframe. */
20
+ reloadPreview?: () => void;
21
+ }
22
+
23
+ export interface DomGeometryCommits {
24
+ commitPathOffset: (
25
+ selection: DomEditSelection,
26
+ next: { x: number; y: number },
27
+ modifiers?: { altKey?: boolean },
28
+ ) => Promise<DomEditCommitOutcome>;
29
+ commitGroupPathOffset: (updates: DomEditGroupPathOffsetCommit[]) => Promise<DomEditCommitOutcome>;
30
+ commitBoxSize: (
31
+ selection: DomEditSelection,
32
+ next: { width: number; height: number },
33
+ offset?: { x: number; y: number },
34
+ restore?: () => void,
35
+ ) => Promise<DomEditCommitOutcome>;
36
+ commitRotation: (
37
+ selection: DomEditSelection,
38
+ next: { angle: number },
39
+ ) => Promise<DomEditCommitOutcome>;
40
+ waitForPendingSaves: () => Promise<void>;
41
+ }
42
+
43
+ const noop = () => {};
44
+ const NO_SELECTED_ANIMATIONS: GsapAnimation[] = [];
45
+ // ponytail: unreachable, the GSAP writer always exists so a resize never takes the DOM route.
46
+ const noDomBoxSizeRoute = () => Promise.reject(new Error("Resize has no DOM route here"));
47
+
48
+ /**
49
+ * Saves canvas moves, resizes and rotations through Studio's own GSAP-aware commits, for a host
50
+ * without the editor. A failed save rejects, so DomEditOverlay undoes the live gesture.
51
+ */
52
+ export function useDomGeometryCommit({
53
+ projectId,
54
+ iframeRef,
55
+ writeProjectFile,
56
+ recordEdit,
57
+ activeCompPath = null,
58
+ showToast = noop,
59
+ reloadPreview,
60
+ }: UseDomGeometryCommitOptions): DomGeometryCommits {
61
+ const projectIdRef = useRef(projectId);
62
+ projectIdRef.current = projectId;
63
+ const pending = useRef(new Set<Promise<void>>()).current;
64
+ const editHistory = useMemo(() => ({ recordEdit }), [recordEdit]);
65
+ const reload = useCallback(
66
+ () => (reloadPreview ? reloadPreview() : iframeRef.current?.contentWindow?.location.reload()),
67
+ [reloadPreview, iframeRef],
68
+ );
69
+ const { bump: bumpGsapCache } = useGsapCacheVersion();
70
+ const gsap = useGsapScriptCommits({
71
+ projectIdRef,
72
+ activeCompPath,
73
+ previewIframeRef: iframeRef,
74
+ editHistory,
75
+ reloadPreview: reload,
76
+ onCacheInvalidate: bumpGsapCache,
77
+ showToast,
78
+ writeProjectFile,
79
+ });
80
+ const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
81
+ const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
82
+ const {
83
+ handleGsapAwarePathOffsetCommit,
84
+ handleGsapAwareGroupPathOffsetCommit,
85
+ handleGsapAwareBoxSizeCommit,
86
+ handleGsapAwareRotationCommit,
87
+ } = useGsapAwareEditing({
88
+ // The host owns selection, so every gesture reads its element's animations from the server.
89
+ domEditSelection: null,
90
+ selectedGsapAnimations: NO_SELECTED_ANIMATIONS,
91
+ gsapCommitMutation: gsap.commitMutation,
92
+ previewIframeRef: iframeRef,
93
+ showToast,
94
+ bumpGsapCache,
95
+ makeFetchFallback,
96
+ trackGsapInteractionFailure,
97
+ handleDomBoxSizeCommit: noDomBoxSizeRoute,
98
+ addGsapAnimation: gsap.addGsapAnimation,
99
+ convertToKeyframes: gsap.convertToKeyframes,
100
+ setArcPath: gsap.setArcPath,
101
+ updateArcSegment: gsap.updateArcSegment,
102
+ });
103
+ const saved = useCallback(
104
+ async (commit: () => Promise<void>, restore = noop): Promise<DomEditCommitOutcome> => {
105
+ const refusal = !projectIdRef.current
106
+ ? "No project is open"
107
+ : !isPreviewBooted(projectIdRef.current)
108
+ ? "The preview for this project has not loaded yet"
109
+ : null;
110
+ if (refusal) {
111
+ restore();
112
+ showToast(refusal, "error");
113
+ throw new Error(refusal);
114
+ }
115
+ const run = commit();
116
+ pending.add(run);
117
+ try {
118
+ await run;
119
+ } finally {
120
+ pending.delete(run);
121
+ }
122
+ return { ok: true };
123
+ },
124
+ [pending, showToast],
125
+ );
126
+ return useMemo(
127
+ () => ({
128
+ commitPathOffset: (selection, next, modifiers) =>
129
+ saved(() => handleGsapAwarePathOffsetCommit(selection, next, modifiers)),
130
+ commitGroupPathOffset: (updates) =>
131
+ saved(() => handleGsapAwareGroupPathOffsetCommit(updates)),
132
+ commitBoxSize: (selection, next, offset, restore) =>
133
+ saved(() => handleGsapAwareBoxSizeCommit(selection, next, offset, restore), restore),
134
+ commitRotation: (selection, next) =>
135
+ saved(() => handleGsapAwareRotationCommit(selection, next)),
136
+ waitForPendingSaves: () => Promise.allSettled([...pending]).then(noop),
137
+ }),
138
+ [
139
+ saved,
140
+ pending,
141
+ handleGsapAwarePathOffsetCommit,
142
+ handleGsapAwareGroupPathOffsetCommit,
143
+ handleGsapAwareBoxSizeCommit,
144
+ handleGsapAwareRotationCommit,
145
+ ],
146
+ );
147
+ }
@@ -298,10 +298,15 @@ export function useGsapAwareEditing({
298
298
  offset?: { x: number; y: number },
299
299
  restore: () => void = () => undefined,
300
300
  ) => {
301
- const ownedAnimations = getGsapAnimationsForSelection(selection);
302
- const targetAnimations = Array.isArray(ownedAnimations)
303
- ? ownedAnimations
304
- : await ownedAnimations;
301
+ let targetAnimations: GsapAnimation[];
302
+ try {
303
+ const ownedAnimations = getGsapAnimationsForSelection(selection);
304
+ targetAnimations = Array.isArray(ownedAnimations) ? ownedAnimations : await ownedAnimations;
305
+ } catch (error) {
306
+ restore();
307
+ trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
308
+ throw error;
309
+ }
305
310
  const scaleRoute = targetAnimations.some((anim) => anim.propertyGroup === "scale");
306
311
  const selector = selectorFromSelection(selection);
307
312
  const hasLivePositionTween = selector
package/src/index.ts CHANGED
@@ -167,7 +167,7 @@ export type { AudioMeterStripProps } from "./components/nle/AudioMeterStrip";
167
167
  export { useClipboard } from "./hooks/useClipboard";
168
168
  export type { UseClipboardOptions } from "./hooks/useClipboard";
169
169
 
170
- // DOM editing for a host outside EditorShell; useDomStyleCommit saves styles without the session.
170
+ // DOM editing for a host outside EditorShell; the Commit hooks save without useDomEditSession.
171
171
  export { useDomEditSession } from "./hooks/useDomEditSession";
172
172
  export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
173
173
  export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
@@ -181,6 +181,8 @@ export { useDomStyleCommit } from "./hooks/useDomStyleCommit";
181
181
  export type { UseDomStyleCommitOptions } from "./hooks/useDomStyleCommit";
182
182
  export type { DomEditCommitDeclineReason, DomEditCommitOutcome } from "./hooks/domEditCommitRunner";
183
183
  export { resolveDomEditSelection } from "./components/editor/domEditingLayers";
184
+ export { useDomGeometryCommit } from "./hooks/useDomGeometryCommit";
185
+ export type { DomGeometryCommits, UseDomGeometryCommitOptions } from "./hooks/useDomGeometryCommit";
184
186
  export { DomEditOverlay } from "./components/editor/DomEditOverlay";
185
187
  export type {
186
188
  DomEditGroupPathOffsetCommit,
@@ -0,0 +1,91 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { Timeline } from "./Timeline";
7
+ import { installTimelineMountEnv } from "./timelineMountTestEnv";
8
+ import { usePlayerStore } from "../store/playerStore";
9
+
10
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
+
12
+ installTimelineMountEnv();
13
+
14
+ afterEach(() => {
15
+ document.body.innerHTML = "";
16
+ });
17
+
18
+ async function mountKeyframed(showKeyframes?: boolean, withLanes = true) {
19
+ usePlayerStore.setState({
20
+ duration: 10,
21
+ currentTime: 0,
22
+ timelineReady: true,
23
+ selectedElementId: "card",
24
+ elements: [{ id: "card", label: "Hero card", tag: "div", start: 0, duration: 4, track: 0 }],
25
+ gsapAnimations: withLanes
26
+ ? new Map([
27
+ [
28
+ "card",
29
+ [
30
+ {
31
+ id: "card-position",
32
+ targetSelector: "#card",
33
+ method: "to",
34
+ position: 0,
35
+ duration: 2,
36
+ properties: {},
37
+ propertyGroup: "position",
38
+ keyframes: {
39
+ format: "percentage",
40
+ keyframes: [
41
+ { percentage: 0, properties: { x: 0 } },
42
+ { percentage: 50, properties: { x: 100 } },
43
+ ],
44
+ },
45
+ },
46
+ ],
47
+ ],
48
+ ])
49
+ : new Map(),
50
+ keyframeCache: new Map([
51
+ [
52
+ "card",
53
+ {
54
+ format: "percentage",
55
+ keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
56
+ },
57
+ ],
58
+ ]),
59
+ });
60
+ const host = document.createElement("div");
61
+ document.body.append(host);
62
+ const root = createRoot(host);
63
+ await act(async () => root.render(<Timeline showKeyframes={showKeyframes} />));
64
+ const diamonds = host.querySelectorAll('button[title="50%"]').length;
65
+ const named = Array.from(host.querySelectorAll("[role=rowheader]")).some((h) =>
66
+ h.textContent?.includes("Hero card"),
67
+ );
68
+ const lanes = host.textContent?.includes("Position") ?? false;
69
+ act(() => root.unmount());
70
+ return { diamonds, named, lanes };
71
+ }
72
+
73
+ describe("Timeline showKeyframes", () => {
74
+ it("draws a keyframed clip's diamonds by default", async () => {
75
+ expect((await mountKeyframed(undefined, false)).diamonds).toBeGreaterThan(0);
76
+ });
77
+
78
+ it("draws no keyframe diamonds when the host turns keyframes off", async () => {
79
+ expect((await mountKeyframed(false, false)).diamonds).toBe(0);
80
+ });
81
+
82
+ it("draws the keyframe lanes by default and none when keyframes are off", async () => {
83
+ expect((await mountKeyframed()).lanes).toBe(true);
84
+ expect((await mountKeyframed(false)).lanes).toBe(false);
85
+ });
86
+
87
+ it("keeps each track's name in the header when keyframes are off", async () => {
88
+ expect((await mountKeyframed()).named).toBe(true);
89
+ expect((await mountKeyframed(false)).named).toBe(true);
90
+ });
91
+ });
@@ -34,4 +34,5 @@ export interface TimelineProps
34
34
  canPasteClip?: () => boolean;
35
35
  theme?: Partial<TimelineTheme>;
36
36
  showAudioEffects?: boolean;
37
+ showKeyframes?: boolean;
37
38
  }
@@ -52,6 +52,8 @@ export const DEFAULT_SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
52
52
  { key: "⌘X", label: "Cut element" },
53
53
  { key: hintKey(STUDIO_PLAIN_KEYS.split), label: "Split clip at playhead" },
54
54
  { key: "⇧Click", label: "Razor tool: split all tracks" },
55
+ { key: "[", label: "Select clips starting before the playhead" },
56
+ { key: "]", label: "Select clips starting at or after the playhead" },
55
57
  { key: "⌘G", label: "Group elements" },
56
58
  { key: "⌘⇧G", label: "Ungroup" },
57
59
  { key: "Del", label: "Delete selected element (no keyframe selected)" },
@@ -0,0 +1,14 @@
1
+ import { usePlayerStore, type KeyframeCacheEntry } from "../store/playerStore";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+
4
+ const HIDDEN = {
5
+ gsapAnimations: new Map<string, GsapAnimation[]>(),
6
+ keyframeCache: new Map<string, KeyframeCacheEntry>(),
7
+ };
8
+
9
+ /** The keyframe data the timeline draws, or none when the host hides keyframes. */
10
+ export function useTimelineKeyframeData(showKeyframes: boolean) {
11
+ const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
12
+ const keyframeCache = usePlayerStore((s) => s.keyframeCache);
13
+ return showKeyframes ? { gsapAnimations, keyframeCache } : HIDDEN;
14
+ }
@@ -21,6 +21,7 @@ import { useTimelineGeometry } from "./useTimelineGeometry";
21
21
  import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
22
22
  import { GUTTER, LABEL_COL_W } from "./timelineLayout";
23
23
  import { useTimelineLabelColumn } from "./useTimelineLabelColumn";
24
+ import { useTimelineKeyframeData } from "./useTimelineKeyframeData";
24
25
  import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
25
26
  import { ClipContentOnceShown } from "./timelineClipChildren";
26
27
  import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
@@ -68,6 +69,7 @@ export function useTimelineProviderState({
68
69
  canPasteClip,
69
70
  theme: themeOverrides,
70
71
  showAudioEffects = true,
72
+ showKeyframes = true,
71
73
  sessionEpoch = 0,
72
74
  previewIframeRef,
73
75
  onZIndexReorder,
@@ -104,8 +106,9 @@ export function useTimelineProviderState({
104
106
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
105
107
  const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
106
108
  const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
107
- const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
108
- const { labelMode, contentOrigin } = useTimelineLabelColumn(gsapAnimations, timelineElements);
109
+ const { gsapAnimations, keyframeCache } = useTimelineKeyframeData(showKeyframes);
110
+ const namedAnimations = usePlayerStore((s) => s.gsapAnimations);
111
+ const { labelMode, contentOrigin } = useTimelineLabelColumn(namedAnimations, timelineElements);
109
112
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
110
113
  const currentTime = usePlayerStore((s) => s.currentTime);
111
114
  const beatDragging = usePlayerStore((s) => s.beatDragging);
@@ -130,7 +133,6 @@ export function useTimelineProviderState({
130
133
  () => getEffectiveTimelineDuration(duration, timelineElements),
131
134
  [duration, timelineElements],
132
135
  );
133
- const keyframeCache = usePlayerStore((s) => s.keyframeCache);
134
136
  useAutoExpandKeyframedClips(gsapAnimations);
135
137
  const {
136
138
  tracks,
@@ -23,6 +23,7 @@ import { createRangeSelectionSlice, type RangeSelectionSlice } from "./rangeSele
23
23
  import { createTimelineResetState } from "./timelineResetState";
24
24
  export type { KeyframeCacheEntry } from "./keyframeSlice";
25
25
  export { liveTime } from "./liveTime";
26
+ import { liveTime } from "./liveTime";
26
27
  export { createTimelineResetState };
27
28
 
28
29
  import type {
@@ -96,6 +97,8 @@ interface PlayerState extends PlayerStoreSlices {
96
97
 
97
98
  activeTool: TimelineTool;
98
99
  setActiveTool: (tool: TimelineTool) => void;
100
+ selectLeftward: () => void;
101
+ selectRightward: () => void;
99
102
 
100
103
  /** Tween-relative percentage of the last-clicked keyframe diamond. Operations
101
104
  * (drag, resize, rotate) target this instead of recomputing from playhead. */
@@ -250,6 +253,17 @@ interface BeatHistoryEntry {
250
253
  label: string;
251
254
  }
252
255
 
256
+ /** Selects like the marquee: the primary first, so its resets run, then the whole set. */
257
+ function selectAroundPlayhead(
258
+ state: PlayerState,
259
+ keep: (start: number, playhead: number) => boolean,
260
+ ): void {
261
+ const playhead = state.isPlaying ? liveTime.latest() : state.currentTime;
262
+ const ids = state.elements.filter((el) => keep(el.start, playhead)).map((el) => el.key ?? el.id);
263
+ state.setSelectedElementId(ids[0] ?? null);
264
+ state.setSelectedElementIds(new Set(ids));
265
+ }
266
+
253
267
  export const usePlayerStore = create<PlayerState>((set, get) => ({
254
268
  isPlaying: false,
255
269
  currentTime: 0,
@@ -273,6 +287,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
273
287
 
274
288
  activeTool: "select",
275
289
  setActiveTool: (tool) => set({ activeTool: tool }),
290
+ selectLeftward: () => selectAroundPlayhead(get(), (start, playhead) => start < playhead),
291
+ selectRightward: () => selectAroundPlayhead(get(), (start, playhead) => start >= playhead),
276
292
 
277
293
  ...createKeyframeSlice(set, () => ({
278
294
  timelineProjectId: get().timelineProjectId,