@hyperframes/studio 0.7.46 → 0.7.47

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 (110) hide show
  1. package/dist/assets/index-Dq7FEg0K.css +1 -0
  2. package/dist/assets/{index-BGVoOXdg.js → index-DsckwbdW.js} +1 -1
  3. package/dist/assets/index-VsAbY3rl.js +423 -0
  4. package/dist/assets/{index-DO2MAFSG.js → index-uvB30_yI.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-BsLQ2qGn.js +0 -420
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -209,6 +209,67 @@ describe("edit history", () => {
209
209
  expect(state.undo[0].files["index.html"].after).toBe("c");
210
210
  });
211
211
 
212
+ it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
213
+ const timing = buildEditHistoryEntry({
214
+ projectId: "project-1",
215
+ label: "Resize timeline clip",
216
+ kind: "timeline",
217
+ coalesceKey: "timeline-resize:clip",
218
+ files: { "index.html": { before: "orig", after: "timing" } },
219
+ now: 0,
220
+ id: "timing",
221
+ });
222
+ // The server GSAP rewrite lands ~2s later, past the 300ms default window, but the
223
+ // follow-up carries a large coalesceMs so undo still collapses to a single step.
224
+ const gsap = buildEditHistoryEntry({
225
+ projectId: "project-1",
226
+ label: "Resize timeline clip",
227
+ kind: "timeline",
228
+ coalesceKey: "timeline-resize:clip",
229
+ coalesceMs: 10_000,
230
+ files: { "index.html": { before: "timing", after: "timing+gsap" } },
231
+ now: 2000,
232
+ id: "gsap",
233
+ });
234
+
235
+ const state = pushEditHistoryEntry(
236
+ pushEditHistoryEntry(createEmptyEditHistory(), timing),
237
+ gsap,
238
+ );
239
+
240
+ expect(state.undo).toHaveLength(1);
241
+ expect(state.undo[0].files["index.html"].before).toBe("orig");
242
+ expect(state.undo[0].files["index.html"].after).toBe("timing+gsap");
243
+ });
244
+
245
+ it("does not merge a slow follow-up without the coalesceMs override", () => {
246
+ const timing = buildEditHistoryEntry({
247
+ projectId: "project-1",
248
+ label: "Resize timeline clip",
249
+ kind: "timeline",
250
+ coalesceKey: "timeline-resize:clip",
251
+ files: { "index.html": { before: "orig", after: "timing" } },
252
+ now: 0,
253
+ id: "timing",
254
+ });
255
+ const late = buildEditHistoryEntry({
256
+ projectId: "project-1",
257
+ label: "Resize timeline clip",
258
+ kind: "timeline",
259
+ coalesceKey: "timeline-resize:clip",
260
+ files: { "index.html": { before: "timing", after: "timing+gsap" } },
261
+ now: 2000,
262
+ id: "late",
263
+ });
264
+
265
+ const state = pushEditHistoryEntry(
266
+ pushEditHistoryEntry(createEmptyEditHistory(), timing),
267
+ late,
268
+ );
269
+
270
+ expect(state.undo).toHaveLength(2);
271
+ });
272
+
212
273
  it("coalesces entries with the same coalesceKey within the window (prop: format)", () => {
213
274
  const first = buildEditHistoryEntry({
214
275
  projectId: "project-1",
@@ -13,6 +13,8 @@ export interface EditHistoryEntry {
13
13
  label: string;
14
14
  kind: EditHistoryKind;
15
15
  coalesceKey?: string;
16
+ /** Per-entry coalesce window override (ms). Falls back to the reducer default. */
17
+ coalesceMs?: number;
16
18
  createdAt: number;
17
19
  files: Record<string, EditHistoryFileSnapshot>;
18
20
  }
@@ -35,6 +37,7 @@ export interface BuildEditHistoryEntryInput {
35
37
  label: string;
36
38
  kind?: EditHistoryKind;
37
39
  coalesceKey?: string;
40
+ coalesceMs?: number;
38
41
  now: number;
39
42
  files: Record<string, { before: string; after: string }>;
40
43
  }
@@ -99,6 +102,7 @@ export function buildEditHistoryEntry(input: BuildEditHistoryEntryInput): EditHi
99
102
  label: input.label,
100
103
  kind: input.kind ?? "manual",
101
104
  coalesceKey: input.coalesceKey,
105
+ coalesceMs: input.coalesceMs,
102
106
  createdAt: input.now,
103
107
  files,
104
108
  };
@@ -111,7 +115,9 @@ export function pushEditHistoryEntry(
111
115
  ): EditHistoryState {
112
116
  if (Object.keys(entry.files).length === 0) return state;
113
117
 
114
- const coalesceMs = options?.coalesceMs ?? DEFAULT_COALESCE_MS;
118
+ // The incoming entry's own window wins so a caller can guarantee a merge even when a
119
+ // slow async step (e.g. a server GSAP rewrite) sits between the two records.
120
+ const coalesceMs = entry.coalesceMs ?? options?.coalesceMs ?? DEFAULT_COALESCE_MS;
115
121
  const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
116
122
  const previous = state.undo[state.undo.length - 1];
117
123
  let undo = state.undo;
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { extendRootDurationInSource } from "./rootDuration";
3
+
4
+ describe("extendRootDurationInSource", () => {
5
+ it("extends data-duration when the new end is bigger than the root duration", () => {
6
+ const source = [
7
+ `<div data-composition-id="main" data-duration="4">`,
8
+ ` <section id="clip" data-start="2" data-duration="3"></section>`,
9
+ `</div>`,
10
+ ].join("\n");
11
+
12
+ expect(extendRootDurationInSource(source, 5.25)).toContain(
13
+ `data-composition-id="main" data-duration="5.25"`,
14
+ );
15
+ });
16
+
17
+ it("does nothing when the new end is smaller than or equal to the root duration", () => {
18
+ const source = `<div data-composition-id="main" data-duration="6"></div>`;
19
+
20
+ expect(extendRootDurationInSource(source, 5)).toBe(source);
21
+ expect(extendRootDurationInSource(source, 6)).toBe(source);
22
+ });
23
+
24
+ it("leaves non-root data-duration attributes untouched by the extension", () => {
25
+ const source = [
26
+ `<div data-duration="3"></div>`,
27
+ `<div data-composition-id="main" data-duration="4"></div>`,
28
+ ].join("\n");
29
+ const patched = extendRootDurationInSource(source, 7);
30
+
31
+ expect(patched).toContain(`<div data-duration="3"></div>`);
32
+ expect(patched).toContain(`<div data-composition-id="main" data-duration="7"></div>`);
33
+ });
34
+ });
@@ -0,0 +1,17 @@
1
+ import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
2
+
3
+ export function extendRootDurationInSource(source: string, newEnd: number): string {
4
+ const rootDurMatch = source.match(
5
+ /(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/,
6
+ );
7
+ if (rootDurMatch) {
8
+ const rootDur = parseFloat(rootDurMatch[2]!);
9
+ if (newEnd > rootDur) {
10
+ return source.replace(
11
+ rootDurMatch[0],
12
+ `${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`,
13
+ );
14
+ }
15
+ }
16
+ return source;
17
+ }
@@ -196,6 +196,51 @@ export async function sdkTimingPersist(
196
196
  }
197
197
  }
198
198
 
199
+ export async function sdkTimingBatchPersist(
200
+ changes: Array<{
201
+ hfId: string;
202
+ timingUpdate: { start?: number; duration?: number; trackIndex?: number };
203
+ }>,
204
+ targetPath: string,
205
+ sdkSession: Composition | null | undefined,
206
+ deps: CutoverDeps,
207
+ options?: CutoverOptions,
208
+ ): Promise<boolean> {
209
+ const timingSrc = deps.readProjectFile;
210
+ for (const change of changes) {
211
+ void recordResolverParity(
212
+ sdkSession,
213
+ change.hfId,
214
+ "setTiming",
215
+ timingSrc ? () => timingSrc(targetPath) : undefined,
216
+ );
217
+ }
218
+ if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
219
+ if (!sdkSession || wrongCompositionFile(deps, targetPath)) return false;
220
+ if (changes.some((change) => !sdkSession.getElement(change.hfId))) return false;
221
+ try {
222
+ const serializedBefore = sdkSession.serialize();
223
+ sdkSession.batch(() => {
224
+ for (const change of changes) sdkSession.setTiming(change.hfId, change.timingUpdate);
225
+ });
226
+ const after = sdkSession.serialize();
227
+ if (after === serializedBefore) return false;
228
+ const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
229
+ await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
230
+ trackStudioEvent("sdk_cutover_success", {
231
+ hfId: changes[0]?.hfId ?? null,
232
+ opCount: changes.length,
233
+ });
234
+ return true;
235
+ } catch (err) {
236
+ trackStudioEvent("sdk_cutover_fallback", {
237
+ hfId: changes[0]?.hfId ?? null,
238
+ error: String(err),
239
+ });
240
+ return false;
241
+ }
242
+ }
243
+
199
244
  type SdkGsapTweenOp =
200
245
  | { kind: "add"; target: string; spec: GsapTweenSpec }
201
246
  | { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
4
4
  import {
5
5
  findMatchingTimelineElementId,
6
6
  findTimelineIdByAncestor,
7
+ resolveTimelineIdForSelection,
7
8
  resolveTimelineSelectionSeekTime,
8
9
  } from "./studioHelpers";
9
10
 
@@ -72,3 +73,33 @@ describe("findTimelineIdByAncestor", () => {
72
73
  expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null);
73
74
  });
74
75
  });
76
+
77
+ describe("resolveTimelineIdForSelection", () => {
78
+ const el = (over: Record<string, unknown>) =>
79
+ ({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
80
+
81
+ it("resolves an ancestor clip against activeCompPath when the selection has no sourceFile", () => {
82
+ // #card (a clip in a sub-composition) > .leaf (selected, not itself a clip)
83
+ const card = document.createElement("div");
84
+ card.id = "card";
85
+ const leaf = document.createElement("span");
86
+ leaf.className = "leaf";
87
+ card.appendChild(leaf);
88
+
89
+ const els = [
90
+ el({
91
+ id: "card",
92
+ domId: "card",
93
+ key: "comps/panel.html#card",
94
+ sourceFile: "comps/panel.html",
95
+ }),
96
+ ];
97
+ const selection = { element: leaf } as never;
98
+
99
+ // Falling back to the active comp matches; the old index.html-only fallback would miss.
100
+ expect(resolveTimelineIdForSelection(selection, els, "comps/panel.html")).toBe(
101
+ "comps/panel.html#card",
102
+ );
103
+ expect(resolveTimelineIdForSelection(selection, els, null)).toBe(null);
104
+ });
105
+ });
@@ -13,7 +13,13 @@ export interface AppToast {
13
13
  tone: "error" | "info";
14
14
  }
15
15
 
16
- export type RightPanelTab = "layers" | "design" | "renders" | "block-params" | "slideshow";
16
+ export type RightPanelTab =
17
+ | "layers"
18
+ | "design"
19
+ | "renders"
20
+ | "block-params"
21
+ | "slideshow"
22
+ | "variables";
17
23
  export type RightInspectorPane = "layers" | "design";
18
24
 
19
25
  export interface RightInspectorPanes {
@@ -209,6 +215,28 @@ export function findTimelineIdByAncestor(
209
215
  return null;
210
216
  }
211
217
 
218
+ /**
219
+ * Resolve the timeline element id for a DOM selection: direct match first, then
220
+ * nearest clip ancestor. The ancestor lookup resolves against the selection's own
221
+ * source file, falling back to the active composition path, then index.html — so a
222
+ * sub-composition selection with no explicit sourceFile resolves against the comp
223
+ * currently open, not always the root file.
224
+ */
225
+ export function resolveTimelineIdForSelection(
226
+ selection: DomEditSelection,
227
+ elements: TimelineElement[],
228
+ activeCompPath: string | null,
229
+ ): string | null {
230
+ return (
231
+ findMatchingTimelineElementId(selection, elements) ??
232
+ findTimelineIdByAncestor(
233
+ selection.element,
234
+ elements,
235
+ selection.sourceFile || activeCompPath || "index.html",
236
+ )
237
+ );
238
+ }
239
+
212
240
  export function resolveTimelineSelectionSeekTime(
213
241
  currentTime: number,
214
242
  element: Pick<TimelineElement, "start" | "duration"> | null | undefined,
@@ -8,12 +8,41 @@ import {
8
8
  normalizeStudioCompositionPath,
9
9
  normalizeStudioUrlPanelTab,
10
10
  parseStudioUrlStateFromHash,
11
+ resolveMasterCompositionPath,
11
12
  } from "./studioUrlState";
12
13
  import { useStudioUrlState } from "../hooks/useStudioUrlState";
13
14
  import { usePlayerStore } from "../player";
14
15
 
15
16
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
16
17
 
18
+ describe("resolveMasterCompositionPath", () => {
19
+ it("prefers index.html when present", () => {
20
+ expect(resolveMasterCompositionPath(["frames/a.html", "index.html", "b.html"])).toBe(
21
+ "index.html",
22
+ );
23
+ });
24
+
25
+ it("falls back to the first .html when there is no index.html", () => {
26
+ expect(resolveMasterCompositionPath(["notes.md", "card.html", "hero.html"])).toBe("card.html");
27
+ });
28
+
29
+ it("returns null when the project carries no composition", () => {
30
+ expect(resolveMasterCompositionPath(["notes.md", "styles.css"])).toBeNull();
31
+ expect(resolveMasterCompositionPath([])).toBeNull();
32
+ });
33
+ });
34
+
35
+ describe("normalizeStudioUrlPanelTab", () => {
36
+ it("accepts slideshow and variables as valid tabs", () => {
37
+ expect(normalizeStudioUrlPanelTab("slideshow", { inspectorPanelsEnabled: true })).toBe(
38
+ "slideshow",
39
+ );
40
+ expect(normalizeStudioUrlPanelTab("variables", { inspectorPanelsEnabled: true })).toBe(
41
+ "variables",
42
+ );
43
+ });
44
+ });
45
+
17
46
  function resetPlayerStore() {
18
47
  usePlayerStore.setState({
19
48
  isPlaying: false,
@@ -19,7 +19,20 @@ export interface StudioUrlState {
19
19
  selection: StudioUrlSelectionState | null;
20
20
  }
21
21
 
22
- const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders"];
22
+ const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"];
23
+
24
+ /**
25
+ * The composition a schema-level panel (Variables / Slideshow) targets on the
26
+ * master view, where there is no explicit `activeCompPath`. Prefer the
27
+ * `index.html` convention, but fall back to the first `.html` in the file tree
28
+ * (composition-browser order) so projects whose entry file is `card.html`,
29
+ * `hero.html`, etc. don't silently mis-target a non-existent `index.html`.
30
+ * Returns null when the project carries no composition file at all.
31
+ */
32
+ export function resolveMasterCompositionPath(fileTree: string[]): string | null {
33
+ if (fileTree.includes("index.html")) return "index.html";
34
+ return fileTree.find((p) => p.endsWith(".html")) ?? null;
35
+ }
23
36
 
24
37
  export function normalizeStudioUrlPanelTab(
25
38
  tab: RightPanelTab | null,
@@ -106,6 +119,8 @@ export function readStudioUrlStateFromWindow(): StudioUrlState {
106
119
  return parseStudioUrlStateFromHash(window.location.hash);
107
120
  }
108
121
 
122
+ // Pre-existing param-assembly complexity — surfaced by this PR's line shifts.
123
+ // fallow-ignore-next-line complexity
109
124
  export function buildStudioHash(projectId: string, state: StudioUrlState): string {
110
125
  const params = new URLSearchParams();
111
126