@hyperframes/studio 0.7.46 → 0.7.48
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.
- package/dist/assets/{index-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
- package/dist/assets/index-DX02Q1Yl.js +423 -0
- package/dist/assets/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.js} +1 -1
- package/dist/index.d.ts +96 -29
- package/dist/index.html +2 -2
- package/dist/index.js +8443 -4305
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -32
- package/src/components/DesignPanelPromoteProvider.tsx +45 -0
- package/src/components/PanelTabButton.tsx +31 -0
- package/src/components/StudioPreviewArea.tsx +75 -3
- package/src/components/StudioRightPanel.tsx +111 -113
- package/src/components/editor/LayersPanel.tsx +2 -22
- package/src/components/editor/PromotableControl.tsx +105 -0
- package/src/components/editor/propertyPanelSections.tsx +42 -21
- package/src/components/panels/VariablesBindElement.tsx +266 -0
- package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
- package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
- package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
- package/src/components/panels/VariablesPanel.tsx +554 -0
- package/src/components/panels/VariablesRowAction.tsx +25 -0
- package/src/components/panels/VariablesValueControls.tsx +215 -0
- package/src/components/renders/useRenderQueue.ts +12 -0
- package/src/contexts/TimelineEditContext.tsx +7 -2
- package/src/contexts/VariablePromoteContext.tsx +138 -0
- package/src/contexts/variablePromoteHelpers.test.ts +124 -0
- package/src/contexts/variablePromoteHelpers.ts +53 -0
- package/src/contexts/variablePromoteIntegration.test.ts +80 -0
- package/src/hooks/previewVariablesStore.ts +34 -0
- package/src/hooks/timelineEditingHelpers.test.ts +104 -0
- package/src/hooks/timelineEditingHelpers.ts +386 -10
- package/src/hooks/useDomEditWiring.ts +9 -7
- package/src/hooks/useDomSelection.test.ts +36 -2
- package/src/hooks/useDomSelection.ts +56 -75
- package/src/hooks/useElementLifecycleOps.ts +57 -17
- package/src/hooks/usePreviewDocumentVersion.ts +27 -0
- package/src/hooks/useProjectCompositionVariables.ts +131 -0
- package/src/hooks/useStudioContextValue.ts +6 -1
- package/src/hooks/useStudioSdkSessions.ts +37 -0
- package/src/hooks/useTimelineEditing.test.tsx +963 -0
- package/src/hooks/useTimelineEditing.ts +122 -100
- package/src/hooks/useTimelineEditingTypes.ts +15 -0
- package/src/hooks/useTimelineGroupEditing.ts +370 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
- package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
- package/src/hooks/useVariablesPersist.ts +61 -0
- package/src/player/components/BeatStrip.tsx +19 -3
- package/src/player/components/Player.tsx +7 -1
- package/src/player/components/Timeline.test.ts +38 -0
- package/src/player/components/Timeline.tsx +102 -80
- package/src/player/components/TimelineCanvas.tsx +340 -296
- package/src/player/components/TimelineClip.test.tsx +11 -0
- package/src/player/components/TimelineClipDiamonds.tsx +40 -9
- package/src/player/components/TimelineDragGhost.tsx +59 -0
- package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
- package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
- package/src/player/components/TimelineLayerGutter.tsx +61 -0
- package/src/player/components/TimelineRuler.tsx +4 -2
- package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
- package/src/player/components/timelineCallbacks.ts +20 -2
- package/src/player/components/timelineClipDragPreview.ts +52 -0
- package/src/player/components/timelineDragDrop.ts +19 -3
- package/src/player/components/timelineDropIndicator.test.ts +48 -0
- package/src/player/components/timelineDropIndicator.ts +33 -0
- package/src/player/components/timelineEditing.test.ts +251 -1
- package/src/player/components/timelineEditing.ts +178 -19
- package/src/player/components/timelineGroupEditing.ts +159 -0
- package/src/player/components/timelineLayerDrag.test.ts +200 -0
- package/src/player/components/timelineLayerDrag.ts +383 -0
- package/src/player/components/timelineLayout.test.ts +34 -0
- package/src/player/components/timelineLayout.ts +47 -0
- package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
- package/src/player/components/timelineSnapTargets.test.ts +144 -0
- package/src/player/components/timelineSnapTargets.ts +164 -0
- package/src/player/components/timelineStacking.ts +62 -0
- package/src/player/components/timelineTrackOrder.test.ts +144 -0
- package/src/player/components/timelineTrackOrder.ts +173 -0
- package/src/player/components/useTimelineActiveClips.test.ts +20 -0
- package/src/player/components/useTimelineActiveClips.ts +15 -4
- package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
- package/src/player/components/useTimelineClipDrag.ts +172 -126
- package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
- package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
- package/src/player/hooks/useTimelinePlayer.ts +7 -0
- package/src/player/lib/layerOrdering.test.ts +77 -0
- package/src/player/lib/layerOrdering.ts +98 -0
- package/src/player/lib/playbackTypes.ts +3 -0
- package/src/player/lib/timelineDOM.test.ts +103 -0
- package/src/player/lib/timelineDOM.ts +80 -0
- package/src/player/lib/timelineElementHelpers.ts +1 -1
- package/src/player/store/playerStore.test.ts +92 -0
- package/src/player/store/playerStore.ts +69 -9
- package/src/utils/blockInstaller.ts +2 -14
- package/src/utils/editHistory.test.ts +61 -0
- package/src/utils/editHistory.ts +7 -1
- package/src/utils/rootDuration.test.ts +34 -0
- package/src/utils/rootDuration.ts +17 -0
- package/src/utils/sdkCutover.ts +45 -0
- package/src/utils/studioHelpers.test.ts +31 -0
- package/src/utils/studioHelpers.ts +29 -1
- package/src/utils/studioUrlState.test.ts +29 -0
- package/src/utils/studioUrlState.ts +16 -1
- package/dist/assets/index-BsLQ2qGn.js +0 -420
- 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:
|
|
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
|
-
|
|
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<
|
|
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
|
|
301
|
+
return resolveElementSelection(next, s.selectedElementId);
|
|
261
302
|
}),
|
|
262
|
-
|
|
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
|
-
? {
|
|
381
|
-
|
|
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,
|