@hyperframes/studio 0.7.39 → 0.7.41

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 (76) hide show
  1. package/dist/assets/{index-CQHiecE7.js → index-BCG-TnJO.js} +1 -1
  2. package/dist/assets/index-BpM6cnfP.css +1 -0
  3. package/dist/assets/{index-B9YvRJz1.js → index-DRrNf6k_.js} +195 -195
  4. package/dist/assets/{index-312a3Ceu.js → index-rnqIbAs_.js} +1 -1
  5. package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
  7. package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +34 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +2749 -2075
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +2 -1
  14. package/src/components/StudioPreviewArea.tsx +6 -0
  15. package/src/components/StudioRightPanel.tsx +3 -0
  16. package/src/components/editor/DomEditOverlay.tsx +33 -47
  17. package/src/components/editor/PropertyPanel.test.ts +25 -0
  18. package/src/components/editor/PropertyPanel.tsx +33 -7
  19. package/src/components/editor/domEditingElement.ts +8 -1
  20. package/src/components/editor/manualEditsDom.ts +1 -18
  21. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  22. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  23. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  24. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  25. package/src/components/editor/propertyPanelHelpers.ts +11 -0
  26. package/src/components/editor/propertyPanelTypes.ts +1 -0
  27. package/src/contexts/TimelineEditContext.tsx +1 -0
  28. package/src/hooks/gsapDragCommit.test.ts +56 -0
  29. package/src/hooks/gsapDragCommit.ts +41 -109
  30. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  31. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  32. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  33. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  34. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  35. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  36. package/src/hooks/timelineEditingHelpers.ts +17 -5
  37. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  38. package/src/hooks/timelineTrackVisibility.ts +371 -0
  39. package/src/hooks/useRenderClipContent.test.ts +68 -2
  40. package/src/hooks/useRenderClipContent.ts +5 -6
  41. package/src/hooks/useTimelineEditing.ts +35 -30
  42. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  43. package/src/icons/SystemIcons.tsx +12 -4
  44. package/src/player/components/AudioWaveform.tsx +11 -8
  45. package/src/player/components/CompositionThumbnail.tsx +14 -11
  46. package/src/player/components/PlayheadIndicator.tsx +21 -11
  47. package/src/player/components/Timeline.test.ts +105 -5
  48. package/src/player/components/Timeline.tsx +37 -47
  49. package/src/player/components/TimelineCanvas.tsx +275 -256
  50. package/src/player/components/TimelineClip.test.tsx +105 -0
  51. package/src/player/components/TimelineClip.tsx +44 -57
  52. package/src/player/components/TimelineRuler.tsx +17 -23
  53. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  54. package/src/player/components/TimelineTypes.ts +18 -0
  55. package/src/player/components/VideoThumbnail.tsx +16 -13
  56. package/src/player/components/timelineCallbacks.ts +1 -0
  57. package/src/player/components/timelineIcons.tsx +2 -44
  58. package/src/player/components/timelineLayout.ts +18 -22
  59. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  60. package/src/player/components/timelineTheme.test.ts +21 -4
  61. package/src/player/components/timelineTheme.ts +15 -15
  62. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  63. package/src/player/components/useTimelineActiveClips.ts +125 -0
  64. package/src/player/components/useTimelinePlayhead.ts +6 -0
  65. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  66. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  67. package/src/player/lib/time.test.ts +1 -0
  68. package/src/player/lib/timelineDOM.test.ts +48 -1
  69. package/src/player/lib/timelineDOM.ts +7 -0
  70. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  71. package/src/player/store/playerStore.ts +5 -1
  72. package/src/styles/studio.css +108 -0
  73. package/src/utils/sourcePatcher.test.ts +11 -0
  74. package/src/utils/sourcePatcher.ts +6 -4
  75. package/dist/assets/index-0P10SwC_.css +0 -1
  76. /package/dist/{domEditingLayers-S6YOLVRG.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
@@ -176,6 +176,40 @@ describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-
176
176
  });
177
177
  });
178
178
 
179
+ describe("hasNonHoldTweenForElement — channel-scoped (position vs sibling rotation)", () => {
180
+ const el = { id: "puck-c" };
181
+ const rotationTween = {
182
+ targets: () => [el],
183
+ vars: { keyframes: { "0%": { rotation: 0 }, "100%": { rotation: 90 } }, duration: 1 },
184
+ duration: () => 1,
185
+ startTime: () => 0,
186
+ };
187
+ const positionTween = {
188
+ targets: () => [el],
189
+ vars: { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 100, y: 40 } }, duration: 1 },
190
+ duration: () => 1,
191
+ startTime: () => 0,
192
+ };
193
+
194
+ it("unfiltered: a rotation-only keyframed tween counts as non-hold", () => {
195
+ expect(hasNonHoldTweenForElement(fakeIframe(el, [rotationTween]), "#puck-c")).toBe(true);
196
+ });
197
+
198
+ it("position-scoped: a sibling rotation-only tween is NOT a live position tween", () => {
199
+ // The primary fix: a keyframed rotation must not route a static position hold
200
+ // into the keyframe branch. Scoping to position channels returns false here.
201
+ expect(
202
+ hasNonHoldTweenForElement(fakeIframe(el, [rotationTween]), "#puck-c", undefined, ["x", "y"]),
203
+ ).toBe(false);
204
+ });
205
+
206
+ it("position-scoped: a real keyframed position tween still counts", () => {
207
+ expect(
208
+ hasNonHoldTweenForElement(fakeIframe(el, [positionTween]), "#puck-c", undefined, ["x", "y"]),
209
+ ).toBe(true);
210
+ });
211
+ });
212
+
179
213
  describe("arcPathFromMotionPathValue", () => {
180
214
  it("builds arc config from object form { path, curviness }", () => {
181
215
  const arc = arcPathFromMotionPathValue({
@@ -237,6 +237,7 @@ export function resolveRuntimeTween(
237
237
  kind: "keyframe" | "set",
238
238
  compositionId?: string,
239
239
  channels?: string[],
240
+ // fallow-ignore-next-line code-duplication
240
241
  ): ResolvedRuntimeTween | null {
241
242
  const timelines = timelinesOf(iframe);
242
243
  if (!timelines) return null;
@@ -261,6 +262,7 @@ export function resolveRuntimeTween(
261
262
  const wantChannels = channels && channels.length > 0 ? channels : null;
262
263
 
263
264
  let first: ResolvedRuntimeTween | null = null;
265
+ // fallow-ignore-next-line code-duplication
264
266
  let channelMatch: ResolvedRuntimeTween | null = null;
265
267
  for (const tlId of tlIds) {
266
268
  const timeline = timelines[tlId];
@@ -316,6 +318,7 @@ export function readRuntimeKeyframes(
316
318
  selector: string,
317
319
  compositionId?: string,
318
320
  requireChannels?: string[],
321
+ // fallow-ignore-next-line code-duplication
319
322
  ): ReadTween | null {
320
323
  const timelines = timelinesOf(iframe);
321
324
  if (!timelines) return null;
@@ -344,11 +347,13 @@ export function readRuntimeKeyframes(
344
347
  // (e.g. two non-overlapping gesture recordings → two separate `to()`s). The
345
348
  // overlay must draw the segment under the PLAYHEAD, not blindly the first one
346
349
  // — otherwise recording a second gesture leaves the path stuck on the first.
350
+ // fallow-ignore-next-line code-duplication
347
351
  let firstRead: ReadTween | null = null;
348
352
  for (const tlId of tlIds) {
349
353
  const timeline = timelines[tlId];
350
354
  if (!timeline?.getChildren) continue;
351
355
  const now = typeof timeline.time === "function" ? timeline.time() : null;
356
+ // fallow-ignore-next-line code-duplication
352
357
  for (const tween of timeline.getChildren(true)) {
353
358
  if (!tween.vars || !matchesElement(tween, targetEl)) continue;
354
359
  const dur = typeof tween.duration === "function" ? tween.duration() : 0;
@@ -377,12 +382,17 @@ export function readRuntimeKeyframes(
377
382
  * The drag's stale-parse guard needs this exact distinction — after a delete-all
378
383
  * only a hold may remain, and resurrecting the deleted tween from the stale parse
379
384
  * must be avoided.
385
+ * When `channels` is provided, only tweens carrying one of those keyframe
386
+ * properties count as non-hold motion (e.g. position channels), so a sibling
387
+ * rotation/scale tween doesn't make a static position hold enter the keyframe
388
+ * branch.
380
389
  */
381
390
  // fallow-ignore-next-line complexity
382
391
  export function hasNonHoldTweenForElement(
383
392
  iframe: HTMLIFrameElement | null,
384
393
  selector: string,
385
394
  compositionId?: string,
395
+ channels?: string[],
386
396
  ): boolean {
387
397
  const timelines = timelinesOf(iframe);
388
398
  if (!timelines) return false;
@@ -401,11 +411,13 @@ export function hasNonHoldTweenForElement(
401
411
  }
402
412
  if (!targetEl) return false;
403
413
 
414
+ // fallow-ignore-next-line code-duplication
404
415
  for (const tween of timeline.getChildren(true)) {
405
416
  if (!tween.vars || !matchesElement(tween, targetEl)) continue;
406
417
  const dur = typeof tween.duration === "function" ? tween.duration() : 0;
407
418
  if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
408
- if (readTween(tween.vars)) return true;
419
+ const read = readTween(tween.vars);
420
+ if (read && (!channels || readCarriesChannel(read, channels))) return true;
409
421
  }
410
422
  return false;
411
423
  }
@@ -29,6 +29,7 @@ export async function commitWholePropertyOffset(
29
29
  callbacks: GsapDragCommitCallbacks,
30
30
  label: string,
31
31
  ): Promise<void> {
32
+ // fallow-ignore-next-line code-duplication
32
33
  let effectiveAnim = anim;
33
34
  if (anim.keyframes) {
34
35
  const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
@@ -41,19 +41,30 @@ export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
41
41
  // The runtime re-reads data-start/data-duration from the DOM on each sync tick
42
42
  // (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
43
43
  // picked up automatically on the next frame without a rebind call.
44
- export function patchIframeDomTiming(
44
+ export function findTimelineElementInIframe(
45
45
  iframe: HTMLIFrameElement | null,
46
46
  element: TimelineElement,
47
- attrs: Array<[string, string]>,
48
- ): void {
47
+ ): Element | null {
49
48
  try {
50
49
  const doc = iframe?.contentDocument;
51
- if (!doc) return;
52
- const el = element.domId
50
+ if (!doc) return null;
51
+ return element.domId
53
52
  ? doc.getElementById(element.domId)
54
53
  : element.selector
55
54
  ? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
56
55
  : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export function patchIframeDomTiming(
62
+ iframe: HTMLIFrameElement | null,
63
+ element: TimelineElement,
64
+ attrs: Array<[string, string]>,
65
+ ): void {
66
+ try {
67
+ const el = findTimelineElementInIframe(iframe, element);
57
68
  if (!el) return;
58
69
  for (const [name, value] of attrs) el.setAttribute(name, value);
59
70
  } catch {
@@ -61,6 +72,7 @@ export function patchIframeDomTiming(
61
72
  }
62
73
  }
63
74
 
75
+ // fallow-ignore-next-line complexity
64
76
  export function resolveResizePlaybackStart(
65
77
  original: string,
66
78
  target: PatchTarget,
@@ -0,0 +1,202 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { usePlayerStore, type TimelineElement } from "../player";
5
+ import { toggleTimelineElementHidden, toggleTimelineTrackHidden } from "./timelineTrackVisibility";
6
+
7
+ afterEach(() => {
8
+ document.body.innerHTML = "";
9
+ vi.unstubAllGlobals();
10
+ usePlayerStore.getState().reset();
11
+ });
12
+
13
+ function element(overrides: Partial<TimelineElement>): TimelineElement {
14
+ return {
15
+ id: "clip",
16
+ tag: "div",
17
+ start: 0,
18
+ duration: 2,
19
+ track: 0,
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ function stubProjectFiles(files: Map<string, string>) {
25
+ vi.stubGlobal(
26
+ "fetch",
27
+ vi.fn(async (input: RequestInfo | URL) => {
28
+ const url = String(input);
29
+ const encodedPath = url.slice(url.lastIndexOf("/") + 1);
30
+ const path = decodeURIComponent(encodedPath);
31
+ const content = files.get(path);
32
+ return new Response(JSON.stringify({ content }), {
33
+ status: content === undefined ? 404 : 200,
34
+ headers: { "Content-Type": "application/json" },
35
+ });
36
+ }),
37
+ );
38
+ }
39
+
40
+ describe("toggleTimelineTrackHidden", () => {
41
+ it("patches iframe DOM and persists all track elements as one edit-history entry", async () => {
42
+ const iframe = document.createElement("iframe");
43
+ document.body.append(iframe);
44
+ if (iframe.contentDocument) {
45
+ iframe.contentDocument.body.innerHTML = `
46
+ <div id="hero"></div>
47
+ <div id="subtitle"></div>
48
+ `;
49
+ }
50
+
51
+ const files = new Map([
52
+ [
53
+ "index.html",
54
+ `<div id="hero" data-start="0" data-duration="2"></div>
55
+ <div id="skip" data-start="0" data-duration="2"></div>`,
56
+ ],
57
+ ["scene.html", `<div id="subtitle" data-start="1" data-duration="2"></div>`],
58
+ ]);
59
+ stubProjectFiles(files);
60
+
61
+ const writes = new Map<string, string>();
62
+ const recordEdit = vi.fn();
63
+ const timestampRef = { current: 0 };
64
+ const pendingRef = { current: new Set<string>() };
65
+
66
+ await toggleTimelineTrackHidden({
67
+ projectId: "project-1",
68
+ activeCompPath: "index.html",
69
+ timelineElements: [
70
+ element({ id: "hero", domId: "hero", track: 0 }),
71
+ element({ id: "skip", domId: "skip", track: 1 }),
72
+ element({ id: "subtitle", domId: "subtitle", track: 0, sourceFile: "scene.html" }),
73
+ ],
74
+ track: 0,
75
+ hidden: true,
76
+ previewIframe: iframe,
77
+ writeProjectFile: async (path, content) => {
78
+ writes.set(path, content);
79
+ },
80
+ recordEdit,
81
+ domEditSaveTimestampRef: timestampRef,
82
+ pendingTimelineEditPathRef: pendingRef,
83
+ });
84
+
85
+ expect(iframe.contentDocument?.getElementById("hero")?.hasAttribute("data-hidden")).toBe(true);
86
+ expect(iframe.contentDocument?.getElementById("subtitle")?.hasAttribute("data-hidden")).toBe(
87
+ true,
88
+ );
89
+ expect(writes.get("index.html")).toContain('id="hero" data-start="0" data-duration="2"');
90
+ expect(writes.get("index.html")).toContain('data-hidden=""');
91
+ expect(writes.get("index.html")).toContain('id="skip" data-start="0" data-duration="2"');
92
+ expect(writes.get("scene.html")).toContain('data-hidden=""');
93
+ expect(pendingRef.current).toEqual(new Set(["index.html", "scene.html"]));
94
+ expect(timestampRef.current).toBeGreaterThan(0);
95
+ expect(recordEdit).toHaveBeenCalledTimes(1);
96
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide track 0");
97
+ expect(Object.keys(recordEdit.mock.calls[0]?.[0]?.files ?? {}).sort()).toEqual([
98
+ "index.html",
99
+ "scene.html",
100
+ ]);
101
+ });
102
+
103
+ it("removes data-hidden from every element on the track", async () => {
104
+ const files = new Map([
105
+ [
106
+ "index.html",
107
+ `<div id="hero" data-start="0" data-duration="2" data-hidden=""></div>
108
+ <div id="caption" data-start="2" data-duration="2" data-hidden=""></div>`,
109
+ ],
110
+ ]);
111
+ stubProjectFiles(files);
112
+
113
+ const writes = new Map<string, string>();
114
+
115
+ await toggleTimelineTrackHidden({
116
+ projectId: "project-1",
117
+ activeCompPath: "index.html",
118
+ timelineElements: [
119
+ element({ id: "hero", domId: "hero", track: 0, hidden: true }),
120
+ element({ id: "caption", domId: "caption", track: 0, hidden: true }),
121
+ ],
122
+ track: 0,
123
+ hidden: false,
124
+ previewIframe: null,
125
+ writeProjectFile: async (path, content) => {
126
+ writes.set(path, content);
127
+ },
128
+ recordEdit: vi.fn(),
129
+ domEditSaveTimestampRef: { current: 0 },
130
+ pendingTimelineEditPathRef: { current: new Set() },
131
+ });
132
+
133
+ expect(writes.get("index.html")).not.toContain("data-hidden");
134
+ });
135
+ });
136
+
137
+ describe("toggleTimelineElementHidden", () => {
138
+ it("persists data-hidden for only the selected element and updates the player store", async () => {
139
+ const iframe = document.createElement("iframe");
140
+ document.body.append(iframe);
141
+ const seek = vi.fn();
142
+ const win = iframe.contentWindow;
143
+ if (!win) throw new Error("Expected iframe contentWindow");
144
+ const playerWindow: Window & { __player?: { seek?: (time: number) => void } } = win;
145
+ playerWindow.__player = { seek };
146
+
147
+ const files = new Map([
148
+ [
149
+ "index.html",
150
+ `<div id="hero" data-start="0" data-duration="2"></div>
151
+ <div id="track-mate" data-start="1" data-duration="2"></div>`,
152
+ ],
153
+ ]);
154
+ stubProjectFiles(files);
155
+
156
+ const hero = element({ id: "hero", key: "index.html:#hero", domId: "hero", track: 0 });
157
+ const trackMate = element({
158
+ id: "track-mate",
159
+ key: "index.html:#track-mate",
160
+ domId: "track-mate",
161
+ track: 0,
162
+ });
163
+ usePlayerStore.getState().setElements([hero, trackMate]);
164
+ usePlayerStore.getState().setCurrentTime(1.25);
165
+
166
+ const writes = new Map<string, string>();
167
+ const recordEdit = vi.fn();
168
+
169
+ const changedPaths = await toggleTimelineElementHidden({
170
+ projectId: "project-1",
171
+ activeCompPath: "index.html",
172
+ timelineElements: [hero, trackMate],
173
+ elementKey: "index.html:#hero",
174
+ hidden: true,
175
+ previewIframe: iframe,
176
+ writeProjectFile: async (path, content) => {
177
+ writes.set(path, content);
178
+ },
179
+ recordEdit,
180
+ domEditSaveTimestampRef: { current: 0 },
181
+ pendingTimelineEditPathRef: { current: new Set() },
182
+ });
183
+
184
+ expect(changedPaths).toEqual(["index.html"]);
185
+ expect(writes.get("index.html")).toContain('id="hero" data-start="0" data-duration="2"');
186
+ expect(writes.get("index.html")).toContain(
187
+ 'id="hero" data-start="0" data-duration="2" data-hidden=""',
188
+ );
189
+ expect(writes.get("index.html")).toContain(
190
+ 'id="track-mate" data-start="1" data-duration="2"></div>',
191
+ );
192
+ expect(recordEdit).toHaveBeenCalledTimes(1);
193
+ expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide element");
194
+ expect(seek).toHaveBeenCalledWith(1.25);
195
+ expect(
196
+ usePlayerStore.getState().elements.find((el) => el.key === "index.html:#hero")?.hidden,
197
+ ).toBe(true);
198
+ expect(
199
+ usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
200
+ ).toBeUndefined();
201
+ });
202
+ });