@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
@@ -0,0 +1,98 @@
1
+ export interface StackingContextDescriptor {
2
+ parentCompositionId: string | null;
3
+ compositionAncestors: readonly string[];
4
+ stackingContextId?: string | null;
5
+ }
6
+
7
+ export interface ContextOrderItem extends StackingContextDescriptor {
8
+ zIndex: number;
9
+ }
10
+
11
+ // fallow-ignore-next-line complexity
12
+ export function getElementZIndex(element: HTMLElement): number {
13
+ try {
14
+ const inline = element.style?.zIndex;
15
+ if (inline && inline !== "auto") {
16
+ const parsed = parseInt(inline, 10);
17
+ if (Number.isFinite(parsed)) return parsed;
18
+ }
19
+ const win = element.ownerDocument?.defaultView;
20
+ if (!win) return 0;
21
+ const value = win.getComputedStyle(element).zIndex;
22
+ if (value === "auto" || value === "") return 0;
23
+ const parsed = parseInt(value, 10);
24
+ return Number.isFinite(parsed) ? parsed : 0;
25
+ } catch {
26
+ return 0;
27
+ }
28
+ }
29
+
30
+ // fallow-ignore-next-line complexity
31
+ export function hasExplicitZIndex(element: HTMLElement): boolean {
32
+ try {
33
+ const inline = element.style?.zIndex;
34
+ if (inline) return inline !== "auto";
35
+ const win = element.ownerDocument?.defaultView;
36
+ if (!win) return false;
37
+ const value = win.getComputedStyle(element).zIndex;
38
+ return value !== "auto" && value !== "";
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ export function computeReorderZValues(
45
+ existingValues: readonly number[],
46
+ fromIndex: number,
47
+ toIndex: number,
48
+ ): number[] {
49
+ const reordered = [...existingValues];
50
+ const [moved] = reordered.splice(fromIndex, 1);
51
+ reordered.splice(toIndex, 0, moved);
52
+
53
+ const sorted = [...existingValues].sort((a, b) => b - a);
54
+ const hasDupes = sorted.some((v, i) => i > 0 && v === sorted[i - 1]);
55
+ return hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted;
56
+ }
57
+
58
+ export function resolveStackingContextKey(item: StackingContextDescriptor): string {
59
+ return item.stackingContextId ?? item.parentCompositionId ?? item.compositionAncestors[0] ?? "";
60
+ }
61
+
62
+ function resolveStackingContextDepth(item: StackingContextDescriptor): number {
63
+ const contextKey = resolveStackingContextKey(item);
64
+ if (!contextKey) return 0;
65
+ const index = item.compositionAncestors.indexOf(contextKey);
66
+ return index >= 0 ? index : 0;
67
+ }
68
+
69
+ export function resolveContextOrder<T extends ContextOrderItem>(items: readonly T[]): T[] {
70
+ if (items.length === 0) return [];
71
+
72
+ const groups = new Map<
73
+ string,
74
+ { firstIndex: number; depth: number; entries: Array<{ item: T; index: number }> }
75
+ >();
76
+
77
+ items.forEach((item, index) => {
78
+ const key = resolveStackingContextKey(item);
79
+ const group = groups.get(key);
80
+ if (group) {
81
+ group.entries.push({ item, index });
82
+ return;
83
+ }
84
+ groups.set(key, {
85
+ firstIndex: index,
86
+ depth: resolveStackingContextDepth(item),
87
+ entries: [{ item, index }],
88
+ });
89
+ });
90
+
91
+ return [...groups.values()]
92
+ .sort((a, b) => a.depth - b.depth || a.firstIndex - b.firstIndex)
93
+ .flatMap((group) =>
94
+ group.entries
95
+ .sort((a, b) => b.item.zIndex - a.item.zIndex || a.index - b.index)
96
+ .map((entry) => entry.item),
97
+ );
98
+ }
@@ -38,9 +38,12 @@ export interface ClipManifestClip {
38
38
  start: number;
39
39
  duration: number;
40
40
  track: number;
41
+ zIndex?: number;
42
+ stackingContextId?: string | null;
41
43
  kind: "video" | "audio" | "image" | "element" | "composition";
42
44
  tagName: string | null;
43
45
  compositionId: string | null;
46
+ compositionAncestors?: string[];
44
47
  parentCompositionId: string | null;
45
48
  compositionSrc: string | null;
46
49
  assetUrl: string | null;
@@ -4,6 +4,7 @@ import {
4
4
  createTimelineElementFromManifestClip,
5
5
  parseTimelineFromDOM,
6
6
  createImplicitTimelineLayersFromDOM,
7
+ buildStandaloneRootTimelineElement,
7
8
  } from "./timelineDOM";
8
9
 
9
10
  function makeDoc(html: string): Document {
@@ -12,6 +13,29 @@ function makeDoc(html: string): Document {
12
13
  return d;
13
14
  }
14
15
 
16
+ function makeLiveDoc(html: string): Document {
17
+ document.head.innerHTML = "";
18
+ document.body.innerHTML = html;
19
+ return document;
20
+ }
21
+
22
+ function mockComputedZIndex(doc: Document, zIndexById: ReadonlyMap<string, string>): void {
23
+ const win = doc.defaultView;
24
+ if (!win) throw new Error("Expected document window");
25
+ const original = win.getComputedStyle.bind(win);
26
+ Object.defineProperty(win, "getComputedStyle", {
27
+ configurable: true,
28
+ value: (element: Element, pseudoElt?: string | null) => {
29
+ const style = original(element, pseudoElt);
30
+ const zIndex = zIndexById.get(element.id);
31
+ if (zIndex != null) {
32
+ Object.defineProperty(style, "zIndex", { configurable: true, value: zIndex });
33
+ }
34
+ return style;
35
+ },
36
+ });
37
+ }
38
+
15
39
  describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
16
40
  it("harvests hfId from a data-start element that has data-hf-id", () => {
17
41
  const doc = makeDoc(`
@@ -102,6 +126,58 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
102
126
 
103
127
  expect(element.hidden).toBe(true);
104
128
  });
129
+
130
+ it("captures the effective z-index from the live element, not the runtime inline-only value", () => {
131
+ // The runtime reports inline-only z-index (0 for CSS-rule authored z-index),
132
+ // which must NOT override the live element's effective z-index — otherwise
133
+ // the timeline collapses every CSS-styled clip to a z=0 tie and mis-orders.
134
+ const doc = makeDoc(`
135
+ <div data-composition-id="root">
136
+ <div id="hero" class="clip" data-start="0" data-duration="5" style="z-index: 30"></div>
137
+ </div>
138
+ `);
139
+ const hostEl = doc.getElementById("hero");
140
+
141
+ const element = createTimelineElementFromManifestClip({
142
+ clip: {
143
+ id: "hero",
144
+ label: "Hero",
145
+ kind: "element",
146
+ tagName: "div",
147
+ start: 0,
148
+ duration: 5,
149
+ track: 0,
150
+ zIndex: 0,
151
+ compositionId: null,
152
+ parentCompositionId: null,
153
+ compositionSrc: null,
154
+ assetUrl: null,
155
+ },
156
+ fallbackIndex: 0,
157
+ doc,
158
+ hostEl,
159
+ });
160
+
161
+ expect(element.zIndex).toBe(30);
162
+ expect(element.hasExplicitZIndex).toBe(true);
163
+ });
164
+
165
+ it("marks parsed inline, CSS-rule, and auto z-index authorship accurately", () => {
166
+ const doc = makeLiveDoc(`
167
+ <div data-composition-id="root">
168
+ <div id="inline" class="clip" data-start="0" data-duration="2" style="z-index: 3"></div>
169
+ <div id="rule" class="clip" data-start="0" data-duration="2"></div>
170
+ <div id="auto" class="clip" data-start="0" data-duration="2"></div>
171
+ </div>
172
+ `);
173
+ mockComputedZIndex(doc, new Map([["rule", "12"]]));
174
+
175
+ const elements = parseTimelineFromDOM(doc, 10);
176
+
177
+ expect(elements.find((el) => el.id === "inline")?.hasExplicitZIndex).toBe(true);
178
+ expect(elements.find((el) => el.id === "rule")?.hasExplicitZIndex).toBe(true);
179
+ expect(elements.find((el) => el.id === "auto")?.hasExplicitZIndex).toBe(false);
180
+ });
105
181
  });
106
182
 
107
183
  describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
@@ -135,4 +211,31 @@ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
135
211
 
136
212
  expect(layers).toEqual([]);
137
213
  });
214
+
215
+ it("marks implicit layer CSS z-index authorship from computed style", () => {
216
+ const doc = makeLiveDoc(`
217
+ <div data-composition-id="root">
218
+ <div id="layer" class="clip"></div>
219
+ </div>
220
+ `);
221
+ mockComputedZIndex(doc, new Map([["layer", "8"]]));
222
+
223
+ const layers = createImplicitTimelineLayersFromDOM(doc, 10);
224
+
225
+ expect(layers[0]?.zIndex).toBe(8);
226
+ expect(layers[0]?.hasExplicitZIndex).toBe(true);
227
+ });
228
+ });
229
+
230
+ describe("buildStandaloneRootTimelineElement", () => {
231
+ it("marks the standalone root as auto z-index", () => {
232
+ const root = buildStandaloneRootTimelineElement({
233
+ compositionId: "root",
234
+ tagName: "div",
235
+ rootDuration: 10,
236
+ iframeSrc: "/preview/comp/index.html",
237
+ });
238
+
239
+ expect(root?.hasExplicitZIndex).toBe(false);
240
+ });
138
241
  });
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { TimelineElement } from "../store/playerStore";
12
12
  import type { ClipManifestClip } from "./playbackTypes";
13
+ import { getElementZIndex, hasExplicitZIndex } from "./layerOrdering";
13
14
  import {
14
15
  resolveMediaElement,
15
16
  applyMediaMetadataFromElement,
@@ -65,6 +66,49 @@ function resolveClipTag(clip: ClipManifestClip): string {
65
66
  return clip.tagName || clip.kind || "div";
66
67
  }
67
68
 
69
+ function resolveDomCompositionContext(
70
+ element: Element,
71
+ root: Element | null,
72
+ ): {
73
+ parentCompositionId: string | null;
74
+ compositionAncestors: string[];
75
+ stackingContextId: string | null;
76
+ } {
77
+ const ancestors: string[] = [];
78
+ let parentCompositionId: string | null = null;
79
+ let cursor = element.parentElement;
80
+ while (cursor) {
81
+ const compositionId = cursor.getAttribute("data-composition-id");
82
+ if (compositionId) {
83
+ ancestors.push(compositionId);
84
+ if (!parentCompositionId && cursor !== root) {
85
+ parentCompositionId = compositionId;
86
+ }
87
+ }
88
+ cursor = cursor.parentElement;
89
+ }
90
+ const compositionAncestors = ancestors.reverse();
91
+ return {
92
+ parentCompositionId,
93
+ compositionAncestors,
94
+ stackingContextId: parentCompositionId ?? compositionAncestors[0] ?? null,
95
+ };
96
+ }
97
+
98
+ function isHTMLElement(element: Element | null): element is HTMLElement {
99
+ if (!element) return false;
100
+ const HtmlElementCtor = element.ownerDocument.defaultView?.HTMLElement ?? globalThis.HTMLElement;
101
+ return typeof HtmlElementCtor !== "undefined" && element instanceof HtmlElementCtor;
102
+ }
103
+
104
+ function getTimelineElementZIndex(element: Element | null): number | undefined {
105
+ return isHTMLElement(element) ? getElementZIndex(element) : undefined;
106
+ }
107
+
108
+ function getTimelineElementHasExplicitZIndex(element: Element | null): boolean {
109
+ return isHTMLElement(element) ? hasExplicitZIndex(element) : false;
110
+ }
111
+
68
112
  // fallow-ignore-next-line complexity
69
113
  export function createTimelineElementFromManifestClip(params: {
70
114
  clip: ClipManifestClip;
@@ -86,6 +130,14 @@ export function createTimelineElementFromManifestClip(params: {
86
130
  let sourceFile: string | undefined;
87
131
 
88
132
  let hfId: string | undefined;
133
+ const domContext = hostEl
134
+ ? resolveDomCompositionContext(hostEl, doc?.querySelector("[data-composition-id]") ?? null)
135
+ : null;
136
+ const compositionAncestors = clip.compositionAncestors ?? domContext?.compositionAncestors;
137
+ const parentCompositionId = clip.parentCompositionId ?? domContext?.parentCompositionId;
138
+ const stackingContextId =
139
+ clip.stackingContextId ?? parentCompositionId ?? compositionAncestors?.[0] ?? null;
140
+
89
141
  if (hostEl) {
90
142
  domId = hostEl.id || undefined;
91
143
  hfId = hostEl.getAttribute("data-hf-id") || undefined;
@@ -112,6 +164,15 @@ export function createTimelineElementFromManifestClip(params: {
112
164
  start: clip.start,
113
165
  duration: clip.duration,
114
166
  track: clip.track,
167
+ // Prefer the effective (computed) z-index read from the live element — the
168
+ // same read the reorder commit uses — so CSS-rule z-index (not just inline)
169
+ // is captured. clip.zIndex from the runtime is inline-only (0 for CSS rules),
170
+ // so it can only serve as a fallback when the element isn't live.
171
+ zIndex: getTimelineElementZIndex(hostEl) ?? clip.zIndex ?? 0,
172
+ hasExplicitZIndex: getTimelineElementHasExplicitZIndex(hostEl),
173
+ stackingContextId,
174
+ parentCompositionId,
175
+ compositionAncestors,
115
176
  domId,
116
177
  hfId,
117
178
  selector,
@@ -146,6 +207,8 @@ export function createTimelineElementFromManifestClip(params: {
146
207
  }
147
208
  }
148
209
  if (hostEl) {
210
+ entry.zIndex = getTimelineElementZIndex(hostEl) ?? entry.zIndex;
211
+ entry.hasExplicitZIndex = getTimelineElementHasExplicitZIndex(hostEl);
149
212
  entry.domId = hostEl.id || undefined;
150
213
  entry.hfId = hostEl.getAttribute("data-hf-id") || undefined;
151
214
  entry.selector = getTimelineElementSelector(hostEl);
@@ -206,6 +269,7 @@ export function createImplicitTimelineLayersFromDOM(
206
269
  });
207
270
  if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue;
208
271
 
272
+ const compositionContext = resolveDomCompositionContext(child, rootComp);
209
273
  layers.push({
210
274
  domId: child.id || undefined,
211
275
  hfId: child.getAttribute("data-hf-id") || undefined,
@@ -217,6 +281,11 @@ export function createImplicitTimelineLayersFromDOM(
217
281
  selectorIndex,
218
282
  sourceFile,
219
283
  start: 0,
284
+ zIndex: getTimelineElementZIndex(child),
285
+ hasExplicitZIndex: getTimelineElementHasExplicitZIndex(child),
286
+ stackingContextId: compositionContext.stackingContextId,
287
+ parentCompositionId: compositionContext.parentCompositionId,
288
+ compositionAncestors: compositionContext.compositionAncestors,
220
289
  tag: child.tagName.toLowerCase(),
221
290
  timingSource: "implicit",
222
291
  track: maxTrack + 1 + layers.length,
@@ -278,6 +347,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
278
347
  selectorIndex,
279
348
  sourceFile,
280
349
  });
350
+ const compositionContext = resolveDomCompositionContext(el, rootComp);
281
351
  const entry: TimelineElement = {
282
352
  id: identity.id,
283
353
  label,
@@ -286,6 +356,11 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
286
356
  start,
287
357
  duration: dur,
288
358
  track: isNaN(track) ? 0 : track,
359
+ zIndex: getTimelineElementZIndex(el),
360
+ hasExplicitZIndex: getTimelineElementHasExplicitZIndex(el),
361
+ stackingContextId: compositionContext.stackingContextId,
362
+ parentCompositionId: compositionContext.parentCompositionId,
363
+ compositionAncestors: compositionContext.compositionAncestors,
289
364
  domId: el.id || undefined,
290
365
  hfId: el.getAttribute("data-hf-id") || undefined,
291
366
  selector,
@@ -406,6 +481,11 @@ export function buildStandaloneRootTimelineElement(params: {
406
481
  start: 0,
407
482
  duration: params.rootDuration,
408
483
  track: 0,
484
+ zIndex: 0,
485
+ hasExplicitZIndex: false,
486
+ stackingContextId: params.compositionId,
487
+ parentCompositionId: null,
488
+ compositionAncestors: [params.compositionId],
409
489
  compositionSrc,
410
490
  selector: params.selector,
411
491
  selectorIndex: params.selectorIndex,
@@ -245,7 +245,7 @@ export function buildTimelineElementIdentity(params: {
245
245
  return { id, key };
246
246
  }
247
247
 
248
- export function getTimelineElementIdentity(element: TimelineElement): string {
248
+ export function getTimelineElementIdentity(element: { key?: string | null; id: string }): string {
249
249
  return element.key ?? element.id;
250
250
  }
251
251
 
@@ -222,6 +222,98 @@ describe("usePlayerStore", () => {
222
222
  });
223
223
  });
224
224
 
225
+ describe("selectedElementIds", () => {
226
+ it("sets a multi-id selection with a coherent anchor", () => {
227
+ usePlayerStore.getState().setSelection(["el-1", "el-2", "el-3"], "el-2");
228
+
229
+ const state = usePlayerStore.getState();
230
+ expect([...state.selectedElementIds]).toEqual(["el-1", "el-2", "el-3"]);
231
+ expect(state.selectedElementId).toBe("el-2");
232
+ });
233
+
234
+ it("falls back to the first selected id when the anchor is outside the set", () => {
235
+ usePlayerStore.getState().setSelection(["el-1", "el-2"], "missing");
236
+
237
+ const state = usePlayerStore.getState();
238
+ expect([...state.selectedElementIds]).toEqual(["el-1", "el-2"]);
239
+ expect(state.selectedElementId).toBe("el-1");
240
+ });
241
+
242
+ it("single-click selection replaces the set with the selected id", () => {
243
+ const store = usePlayerStore.getState();
244
+ store.setSelection(["el-1", "el-2"], "el-2");
245
+ store.setSelectedElementId("el-3");
246
+
247
+ const state = usePlayerStore.getState();
248
+ expect([...state.selectedElementIds]).toEqual(["el-3"]);
249
+ expect(state.selectedElementId).toBe("el-3");
250
+ });
251
+
252
+ it("setSelectedElementId collapses to a single element even for a current member", () => {
253
+ const store = usePlayerStore.getState();
254
+ store.setSelection(["el-1", "el-2", "el-3"], "el-1");
255
+ // A genuine single selection (click) collapses the set, even if the id was a member.
256
+ store.setSelectedElementId("el-2");
257
+
258
+ const state = usePlayerStore.getState();
259
+ expect([...state.selectedElementIds]).toEqual(["el-2"]);
260
+ expect(state.selectedElementId).toBe("el-2");
261
+ });
262
+
263
+ it("setSelectionAnchor moves the anchor within a group without collapsing it", () => {
264
+ const store = usePlayerStore.getState();
265
+ store.setSelection(["el-1", "el-2", "el-3"], "el-1");
266
+ // A DOM->store echo during a group gesture only moves the anchor.
267
+ store.setSelectionAnchor("el-2");
268
+
269
+ let state = usePlayerStore.getState();
270
+ expect([...state.selectedElementIds]).toEqual(["el-1", "el-2", "el-3"]);
271
+ expect(state.selectedElementId).toBe("el-2");
272
+
273
+ // A non-member anchor is a genuine new single selection.
274
+ store.setSelectionAnchor("outside");
275
+ state = usePlayerStore.getState();
276
+ expect([...state.selectedElementIds]).toEqual(["outside"]);
277
+ expect(state.selectedElementId).toBe("outside");
278
+ });
279
+
280
+ it("clearing single selection empties the set", () => {
281
+ const store = usePlayerStore.getState();
282
+ store.setSelection(["el-1", "el-2"], "el-2");
283
+ store.setSelectedElementId(null);
284
+
285
+ const state = usePlayerStore.getState();
286
+ expect([...state.selectedElementIds]).toEqual([]);
287
+ expect(state.selectedElementId).toBeNull();
288
+ });
289
+
290
+ it("toggle adds and removes members while keeping the anchor in the set", () => {
291
+ const store = usePlayerStore.getState();
292
+ store.setSelectedElementId("el-1");
293
+ store.toggleSelectedElementId("el-2");
294
+
295
+ let state = usePlayerStore.getState();
296
+ expect([...state.selectedElementIds]).toEqual(["el-1", "el-2"]);
297
+ expect(state.selectedElementId).toBe("el-1");
298
+
299
+ store.toggleSelectedElementId("el-1");
300
+
301
+ state = usePlayerStore.getState();
302
+ expect([...state.selectedElementIds]).toEqual(["el-2"]);
303
+ expect(state.selectedElementId).toBe("el-2");
304
+ });
305
+
306
+ it("clearSelection empties the set and the anchor", () => {
307
+ const store = usePlayerStore.getState();
308
+ store.setSelection(["el-1", "el-2"], "el-2");
309
+ store.clearSelection();
310
+
311
+ const state = usePlayerStore.getState();
312
+ expect([...state.selectedElementIds]).toEqual([]);
313
+ expect(state.selectedElementId).toBeNull();
314
+ });
315
+ });
316
+
225
317
  describe("updateElement", () => {
226
318
  it("updates the start time of a specific element", () => {
227
319
  usePlayerStore.getState().setElements([
@@ -28,6 +28,16 @@ export interface TimelineElement {
28
28
  start: number;
29
29
  duration: number;
30
30
  track: number;
31
+ /** Resolved z-index for stacking-aware timeline ordering. */
32
+ zIndex?: number;
33
+ /** True when the effective z-index was authored inline or through CSS, not auto. */
34
+ hasExplicitZIndex?: boolean;
35
+ /** Stacking context this element belongs to; root clips use the root composition id. */
36
+ stackingContextId?: string | null;
37
+ /** Nearest parent composition context, matching RuntimeTimelineClip. */
38
+ parentCompositionId?: string | null;
39
+ /** Composition ancestry from root to nearest parent, matching RuntimeTimelineClip. */
40
+ compositionAncestors?: string[];
31
41
  domId?: string;
32
42
  /** Stable `data-hf-id` attribute value — used as primary patch target when present */
33
43
  hfId?: string;
@@ -65,6 +75,23 @@ export interface TimelineElement {
65
75
  export type ZoomMode = "fit" | "manual";
66
76
  type TimelineTool = "select" | "razor";
67
77
 
78
+ function resolveElementSelection(
79
+ ids: Iterable<string>,
80
+ anchor?: string | null,
81
+ ): { selectedElementIds: Set<string>; selectedElementId: string | null } {
82
+ const selectedElementIds = new Set(ids);
83
+ if (selectedElementIds.size === 0) {
84
+ return { selectedElementIds, selectedElementId: null };
85
+ }
86
+ if (anchor && selectedElementIds.has(anchor)) {
87
+ return { selectedElementIds, selectedElementId: anchor };
88
+ }
89
+ return {
90
+ selectedElementIds,
91
+ selectedElementId: selectedElementIds.values().next().value ?? null,
92
+ };
93
+ }
94
+
68
95
  interface PlayerState {
69
96
  isPlaying: boolean;
70
97
  currentTime: number;
@@ -115,8 +142,10 @@ interface PlayerState {
115
142
 
116
143
  /** Multi-select: additional selected elements beyond selectedElementId. */
117
144
  selectedElementIds: Set<string>;
145
+ setSelection: (ids: Iterable<string>, anchor?: string | null) => void;
146
+ addSelectedElementId: (id: string) => void;
118
147
  toggleSelectedElementId: (id: string) => void;
119
- clearSelectedElementIds: () => void;
148
+ clearSelection: () => void;
120
149
 
121
150
  /** Keyframe data per element id, populated from parsed GSAP animations. */
122
151
  keyframeCache: Map<string, KeyframeCacheEntry>;
@@ -132,10 +161,15 @@ interface PlayerState {
132
161
  setBeatDragging: (dragging: boolean) => void;
133
162
  setElements: (elements: TimelineElement[]) => void;
134
163
  setSelectedElementId: (id: string | null) => void;
164
+ /** Move the selection anchor within an active multi-selection without collapsing it. */
165
+ setSelectionAnchor: (id: string | null) => void;
135
166
  updateElement: (
136
167
  elementId: string,
137
168
  updates: Partial<
138
- Pick<TimelineElement, "start" | "duration" | "track" | "playbackStart" | "hidden">
169
+ Pick<
170
+ TimelineElement,
171
+ "start" | "duration" | "track" | "zIndex" | "hasExplicitZIndex" | "playbackStart" | "hidden"
172
+ >
139
173
  >,
140
174
  ) => void;
141
175
  setZoomMode: (mode: ZoomMode) => void;
@@ -252,14 +286,21 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
252
286
  setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }),
253
287
 
254
288
  selectedElementIds: new Set<string>(),
289
+ setSelection: (ids, anchor) => set(resolveElementSelection(ids, anchor)),
290
+ addSelectedElementId: (id: string) =>
291
+ set((s) => {
292
+ const next = new Set(s.selectedElementIds);
293
+ next.add(id);
294
+ return resolveElementSelection(next, s.selectedElementId);
295
+ }),
255
296
  toggleSelectedElementId: (id: string) =>
256
297
  set((s) => {
257
298
  const next = new Set(s.selectedElementIds);
258
299
  if (next.has(id)) next.delete(id);
259
300
  else next.add(id);
260
- return { selectedElementIds: next };
301
+ return resolveElementSelection(next, s.selectedElementId);
261
302
  }),
262
- clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
303
+ clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }),
263
304
 
264
305
  keyframeCache: new Map(),
265
306
  setKeyframeCache: (elementId, data) =>
@@ -370,16 +411,35 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
370
411
  setTimelineReady: (ready) => set({ timelineReady: ready }),
371
412
  setBeatDragging: (dragging) => set({ beatDragging: dragging }),
372
413
  setElements: (elements) => set({ elements }),
414
+ // A genuine single selection: always collapse the set to just this element. User
415
+ // intent (timeline click, preview click via applyDomSelection) flows here; DOM sync
416
+ // echoes that must preserve a group go through setSelectionAnchor instead.
373
417
  setSelectedElementId: (id) =>
374
- set((s) =>
418
+ set((s) => {
419
+ const selectedElementIds = id ? new Set([id]) : new Set<string>();
375
420
  // Selecting a different element drops any active keyframe selection — otherwise
376
421
  // a stale activeKeyframePct from a prior diamond click would force the next drag
377
422
  // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER
378
423
  // calling setSelectedElementId, so this never clobbers a genuine keyframe select.
379
- id !== s.selectedElementId
380
- ? { selectedElementId: id, activeKeyframePct: null, motionPathArmed: false }
381
- : { selectedElementId: id },
382
- ),
424
+ return id !== s.selectedElementId
425
+ ? {
426
+ selectedElementId: id,
427
+ selectedElementIds,
428
+ activeKeyframePct: null,
429
+ motionPathArmed: false,
430
+ }
431
+ : { selectedElementId: id, selectedElementIds };
432
+ }),
433
+ // Move the anchor within an active multi-selection WITHOUT collapsing it — used by
434
+ // DOM->store sync echoes while a group gesture re-patches the preview. A non-member
435
+ // id is treated as a genuine new single selection.
436
+ setSelectionAnchor: (id) =>
437
+ set((s) => {
438
+ if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) {
439
+ return { selectedElementId: id };
440
+ }
441
+ return { selectedElementId: id, selectedElementIds: id ? new Set([id]) : new Set<string>() };
442
+ }),
383
443
  updateElement: (elementId, updates) =>
384
444
  set((state) => ({
385
445
  elements: state.elements.map((el) =>
@@ -8,6 +8,7 @@ import { collectHtmlIds } from "./studioHelpers";
8
8
  import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
9
9
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
10
10
  import type { EditHistoryKind } from "./editHistory";
11
+ import { extendRootDurationInSource } from "./rootDuration";
11
12
 
12
13
  function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
13
14
  try {
@@ -163,20 +164,7 @@ export async function addBlockToProject(
163
164
  ].join("\n");
164
165
 
165
166
  let patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml);
166
-
167
- const newEnd = start + duration;
168
- const rootDurMatch = patchedContent.match(
169
- /(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/,
170
- );
171
- if (rootDurMatch) {
172
- const rootDur = parseFloat(rootDurMatch[2]!);
173
- if (newEnd > rootDur) {
174
- patchedContent = patchedContent.replace(
175
- rootDurMatch[0],
176
- `${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`,
177
- );
178
- }
179
- }
167
+ patchedContent = extendRootDurationInSource(patchedContent, start + duration);
180
168
 
181
169
  await saveProjectFilesWithHistory({
182
170
  projectId,