@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.
- package/dist/assets/index-Dq7FEg0K.css +1 -0
- package/dist/assets/{index-BGVoOXdg.js → index-DsckwbdW.js} +1 -1
- package/dist/assets/index-VsAbY3rl.js +423 -0
- package/dist/assets/{index-DO2MAFSG.js → index-uvB30_yI.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
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { TimelineElement } from "../player";
|
|
7
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
8
|
import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
|
|
7
9
|
import { useDomSelection } from "./useDomSelection";
|
|
8
10
|
|
|
@@ -12,6 +14,7 @@ interface HarnessProps {
|
|
|
12
14
|
activeCompPath: string | null;
|
|
13
15
|
projectId: string | null;
|
|
14
16
|
refreshKey: number;
|
|
17
|
+
timelineElements?: TimelineElement[];
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
function renderHarness(initialProps: HarnessProps): {
|
|
@@ -32,7 +35,7 @@ function renderHarness(initialProps: HarnessProps): {
|
|
|
32
35
|
compIdToSrc: new Map(),
|
|
33
36
|
captionEditMode: false,
|
|
34
37
|
previewIframeRef: { current: null },
|
|
35
|
-
timelineElements: [],
|
|
38
|
+
timelineElements: props.timelineElements ?? [],
|
|
36
39
|
setSelectedTimelineElementId: vi.fn(),
|
|
37
40
|
setRightCollapsed: vi.fn(),
|
|
38
41
|
setRightPanelTab: vi.fn(),
|
|
@@ -64,6 +67,10 @@ function renderHarness(initialProps: HarnessProps): {
|
|
|
64
67
|
};
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
usePlayerStore.getState().reset();
|
|
72
|
+
});
|
|
73
|
+
|
|
67
74
|
function setupSelectedHarness() {
|
|
68
75
|
const element = document.createElement("div");
|
|
69
76
|
element.id = "headline";
|
|
@@ -131,4 +138,31 @@ describe("useDomSelection", () => {
|
|
|
131
138
|
expect(harness.current().domEditSelection).toBe(selection);
|
|
132
139
|
harness.cleanup();
|
|
133
140
|
});
|
|
141
|
+
|
|
142
|
+
it("keeps preview marquee selections mirrored to the full timeline selection set", () => {
|
|
143
|
+
const first = document.createElement("div");
|
|
144
|
+
first.id = "clip-1";
|
|
145
|
+
const second = document.createElement("div");
|
|
146
|
+
second.id = "clip-2";
|
|
147
|
+
const firstSelection = makeSelection("First", first);
|
|
148
|
+
const secondSelection = makeSelection("Second", second);
|
|
149
|
+
const harness = renderHarness({
|
|
150
|
+
activeCompPath: "intro.html",
|
|
151
|
+
projectId: "project-1",
|
|
152
|
+
refreshKey: 0,
|
|
153
|
+
timelineElements: [
|
|
154
|
+
{ id: "clip-1", domId: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
|
155
|
+
{ id: "clip-2", domId: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
act(() => harness.current().applyMarqueeSelection([secondSelection, firstSelection], false));
|
|
160
|
+
|
|
161
|
+
const state = usePlayerStore.getState();
|
|
162
|
+
expect([...state.selectedElementIds]).toEqual(["clip-2", "clip-1"]);
|
|
163
|
+
expect(state.selectedElementId).toBe("clip-2");
|
|
164
|
+
expect(harness.current().domEditGroupSelections).toHaveLength(2);
|
|
165
|
+
expect(harness.current().domEditSelection).toBe(secondSelection);
|
|
166
|
+
harness.cleanup();
|
|
167
|
+
});
|
|
134
168
|
});
|
|
@@ -4,11 +4,7 @@ import {
|
|
|
4
4
|
getAllPreviewTargetsFromPointer,
|
|
5
5
|
getPreviewTargetFromPointer,
|
|
6
6
|
} from "../utils/studioPreviewHelpers";
|
|
7
|
-
import {
|
|
8
|
-
findMatchingTimelineElementId,
|
|
9
|
-
findTimelineIdByAncestor,
|
|
10
|
-
type RightPanelTab,
|
|
11
|
-
} from "../utils/studioHelpers";
|
|
7
|
+
import { resolveTimelineIdForSelection, type RightPanelTab } from "../utils/studioHelpers";
|
|
12
8
|
import {
|
|
13
9
|
domEditSelectionsTargetSame,
|
|
14
10
|
domEditSelectionInGroup,
|
|
@@ -24,6 +20,7 @@ import {
|
|
|
24
20
|
type DomEditSelection,
|
|
25
21
|
} from "../components/editor/domEditing";
|
|
26
22
|
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
|
|
23
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
27
24
|
|
|
28
25
|
// ── Types ──
|
|
29
26
|
|
|
@@ -95,7 +92,6 @@ export interface UseDomSelectionReturn {
|
|
|
95
92
|
) => Promise<DomEditSelection | null>;
|
|
96
93
|
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
|
|
97
94
|
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
|
98
|
-
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
|
|
99
95
|
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
100
96
|
}
|
|
101
97
|
|
|
@@ -126,11 +122,16 @@ export function useDomSelection({
|
|
|
126
122
|
|
|
127
123
|
// ── Refs ──
|
|
128
124
|
|
|
125
|
+
const rightPanelTabRef = useRef(rightPanelTab);
|
|
126
|
+
rightPanelTabRef.current = rightPanelTab;
|
|
129
127
|
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
|
130
128
|
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
|
|
131
129
|
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
|
|
132
130
|
const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
|
|
133
131
|
const compositionIdentityRef = useRef({ activeCompPath, projectId });
|
|
132
|
+
// Monotonic token so a rapid A->B timeline-clip select can't let A's slower async
|
|
133
|
+
// resolution land after B and restore the wrong selection.
|
|
134
|
+
const timelineSelectSeqRef = useRef(0);
|
|
134
135
|
|
|
135
136
|
// Keep refs in sync with state
|
|
136
137
|
domEditSelectionRef.current = domEditSelection;
|
|
@@ -205,22 +206,42 @@ export function useDomSelection({
|
|
|
205
206
|
if (nextSelection) {
|
|
206
207
|
if (options?.revealPanel !== false) {
|
|
207
208
|
setRightCollapsed(false);
|
|
208
|
-
|
|
209
|
+
// Keep the Variables tab in place — selecting elements is part of
|
|
210
|
+
// the bind flow there; yanking to Design would lose the context.
|
|
211
|
+
if (rightPanelTabRef.current !== "variables") {
|
|
212
|
+
setRightPanelTab("design");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Mirror the whole DOM group to the store so it stays the single source of
|
|
216
|
+
// truth: a single selection collapses to one id; a preserved group (echo
|
|
217
|
+
// during a gesture) keeps every member instead of shrinking to the anchor.
|
|
218
|
+
const anchorId = resolveTimelineIdForSelection(
|
|
219
|
+
nextSelection,
|
|
220
|
+
timelineElements,
|
|
221
|
+
activeCompPath,
|
|
222
|
+
);
|
|
223
|
+
const groupIds = nextGroup
|
|
224
|
+
.map((selection) =>
|
|
225
|
+
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
|
|
226
|
+
)
|
|
227
|
+
.filter((id): id is string => Boolean(id));
|
|
228
|
+
if (groupIds.length > 0) {
|
|
229
|
+
usePlayerStore.getState().setSelection(groupIds, anchorId);
|
|
230
|
+
} else {
|
|
231
|
+
setSelectedTimelineElementId(anchorId);
|
|
209
232
|
}
|
|
210
|
-
const nextSelectedTimelineId =
|
|
211
|
-
findMatchingTimelineElementId(nextSelection, timelineElements) ??
|
|
212
|
-
findTimelineIdByAncestor(
|
|
213
|
-
nextSelection.element,
|
|
214
|
-
timelineElements,
|
|
215
|
-
nextSelection.sourceFile || "index.html",
|
|
216
|
-
);
|
|
217
|
-
setSelectedTimelineElementId(nextSelectedTimelineId);
|
|
218
233
|
return;
|
|
219
234
|
}
|
|
220
235
|
|
|
221
236
|
setSelectedTimelineElementId(null);
|
|
222
237
|
},
|
|
223
|
-
[
|
|
238
|
+
[
|
|
239
|
+
setSelectedTimelineElementId,
|
|
240
|
+
timelineElements,
|
|
241
|
+
setRightCollapsed,
|
|
242
|
+
setRightPanelTab,
|
|
243
|
+
activeCompPath,
|
|
244
|
+
],
|
|
224
245
|
);
|
|
225
246
|
|
|
226
247
|
const clearDomSelection = useCallback(() => {
|
|
@@ -360,12 +381,15 @@ export function useDomSelection({
|
|
|
360
381
|
const handleTimelineElementSelect = useCallback(
|
|
361
382
|
async (element: TimelineElement | null) => {
|
|
362
383
|
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
|
384
|
+
const seq = ++timelineSelectSeqRef.current;
|
|
363
385
|
if (!element) {
|
|
364
386
|
applyDomSelection(null, { revealPanel: false });
|
|
365
387
|
return;
|
|
366
388
|
}
|
|
367
389
|
|
|
368
390
|
const selection = await buildDomSelectionForTimelineElement(element);
|
|
391
|
+
// A newer selection superseded this one while we were resolving — drop the stale result.
|
|
392
|
+
if (seq !== timelineSelectSeqRef.current) return;
|
|
369
393
|
if (selection) applyDomSelection(selection);
|
|
370
394
|
},
|
|
371
395
|
[applyDomSelection, buildDomSelectionForTimelineElement],
|
|
@@ -400,55 +424,6 @@ export function useDomSelection({
|
|
|
400
424
|
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
|
|
401
425
|
);
|
|
402
426
|
|
|
403
|
-
const refreshDomEditGroupSelectionsFromPreview = useCallback(
|
|
404
|
-
// fallow-ignore-next-line complexity
|
|
405
|
-
async (selections: DomEditSelection[]) => {
|
|
406
|
-
const iframe = previewIframeRef.current;
|
|
407
|
-
let doc: Document | null = null;
|
|
408
|
-
try {
|
|
409
|
-
doc = iframe?.contentDocument ?? null;
|
|
410
|
-
} catch {
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
if (!doc) return;
|
|
414
|
-
|
|
415
|
-
const nextGroup: DomEditSelection[] = [];
|
|
416
|
-
for (const selection of selections) {
|
|
417
|
-
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
418
|
-
if (!element) continue;
|
|
419
|
-
const nextSelection = await buildDomSelectionFromTarget(element);
|
|
420
|
-
if (nextSelection) nextGroup.push(nextSelection);
|
|
421
|
-
}
|
|
422
|
-
if (nextGroup.length === 0) return;
|
|
423
|
-
|
|
424
|
-
const currentSelection = domEditSelectionRef.current;
|
|
425
|
-
const nextSelection =
|
|
426
|
-
nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
|
|
427
|
-
nextGroup[0] ??
|
|
428
|
-
null;
|
|
429
|
-
|
|
430
|
-
domEditSelectionRef.current = nextSelection;
|
|
431
|
-
domEditGroupSelectionsRef.current = nextGroup;
|
|
432
|
-
setDomEditSelection(nextSelection);
|
|
433
|
-
setDomEditGroupSelections(nextGroup);
|
|
434
|
-
|
|
435
|
-
if (nextSelection) {
|
|
436
|
-
setSelectedTimelineElementId(
|
|
437
|
-
findMatchingTimelineElementId(nextSelection, timelineElements),
|
|
438
|
-
);
|
|
439
|
-
} else {
|
|
440
|
-
setSelectedTimelineElementId(null);
|
|
441
|
-
}
|
|
442
|
-
},
|
|
443
|
-
[
|
|
444
|
-
activeCompPath,
|
|
445
|
-
buildDomSelectionFromTarget,
|
|
446
|
-
setSelectedTimelineElementId,
|
|
447
|
-
timelineElements,
|
|
448
|
-
previewIframeRef,
|
|
449
|
-
],
|
|
450
|
-
);
|
|
451
|
-
|
|
452
427
|
// ── Effects ──
|
|
453
428
|
|
|
454
429
|
// Clear hover unconditionally on composition/project/preview change
|
|
@@ -530,16 +505,23 @@ export function useDomSelection({
|
|
|
530
505
|
domEditGroupSelectionsRef.current = nextGroup;
|
|
531
506
|
setDomEditSelection(nextSelection);
|
|
532
507
|
setDomEditGroupSelections(nextGroup);
|
|
533
|
-
const nextTimelineId =
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
)
|
|
540
|
-
|
|
508
|
+
const nextTimelineId = resolveTimelineIdForSelection(
|
|
509
|
+
nextSelection,
|
|
510
|
+
timelineElements,
|
|
511
|
+
activeCompPath,
|
|
512
|
+
);
|
|
513
|
+
const nextTimelineIds = nextGroup
|
|
514
|
+
.map((selection) =>
|
|
515
|
+
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
|
|
516
|
+
)
|
|
517
|
+
.filter((id): id is string => Boolean(id));
|
|
518
|
+
if (nextTimelineIds.length > 0) {
|
|
519
|
+
usePlayerStore.getState().setSelection(nextTimelineIds, nextTimelineId);
|
|
520
|
+
} else {
|
|
521
|
+
setSelectedTimelineElementId(null);
|
|
522
|
+
}
|
|
541
523
|
},
|
|
542
|
-
[applyDomSelection, timelineElements, setSelectedTimelineElementId],
|
|
524
|
+
[applyDomSelection, timelineElements, setSelectedTimelineElementId, activeCompPath],
|
|
543
525
|
);
|
|
544
526
|
|
|
545
527
|
// Disabled inspector effect
|
|
@@ -576,7 +558,6 @@ export function useDomSelection({
|
|
|
576
558
|
buildDomSelectionForTimelineElement,
|
|
577
559
|
handleTimelineElementSelect,
|
|
578
560
|
refreshDomEditSelectionFromPreview,
|
|
579
|
-
refreshDomEditGroupSelectionsFromPreview,
|
|
580
561
|
applyMarqueeSelection,
|
|
581
562
|
};
|
|
582
563
|
}
|
|
@@ -160,6 +160,7 @@ export function useElementLifecycleOps({
|
|
|
160
160
|
// persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle.
|
|
161
161
|
// No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
|
|
162
162
|
const handleDomZIndexReorderCommit = useCallback(
|
|
163
|
+
// fallow-ignore-next-line complexity
|
|
163
164
|
(
|
|
164
165
|
entries: Array<{
|
|
165
166
|
element: HTMLElement;
|
|
@@ -168,17 +169,26 @@ export function useElementLifecycleOps({
|
|
|
168
169
|
selector?: string;
|
|
169
170
|
selectorIndex?: number;
|
|
170
171
|
sourceFile: string;
|
|
172
|
+
key?: string;
|
|
171
173
|
}>,
|
|
172
174
|
) => {
|
|
173
|
-
if (entries.length === 0) return;
|
|
175
|
+
if (entries.length === 0) return Promise.resolve();
|
|
174
176
|
// Resolver shadow (telemetry-only, decoupled from cutover): record whether
|
|
175
177
|
// the SDK resolves each reordered element — the reorderElements op's targets.
|
|
176
178
|
onReorderShadow?.(
|
|
177
179
|
entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
|
|
178
180
|
);
|
|
179
181
|
const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
|
|
182
|
+
const saves: Array<Promise<void>> = [];
|
|
183
|
+
const rollbacks: Array<() => void> = [];
|
|
180
184
|
for (let i = 0; i < entries.length; i++) {
|
|
181
185
|
const entry = entries[i];
|
|
186
|
+
const priorZIndex = entry.element.style.zIndex;
|
|
187
|
+
const priorPosition = entry.element.style.position;
|
|
188
|
+
const priorStoreEntry = entry.key
|
|
189
|
+
? usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === entry.key)
|
|
190
|
+
: undefined;
|
|
191
|
+
let positionChanged = false;
|
|
182
192
|
entry.element.style.zIndex = String(entry.zIndex);
|
|
183
193
|
const patches: Array<{ type: "inline-style"; property: string; value: string }> = [
|
|
184
194
|
{ type: "inline-style", property: "z-index", value: String(entry.zIndex) },
|
|
@@ -187,28 +197,58 @@ export function useElementLifecycleOps({
|
|
|
187
197
|
const win = entry.element.ownerDocument?.defaultView;
|
|
188
198
|
if (win && win.getComputedStyle(entry.element).position === "static") {
|
|
189
199
|
entry.element.style.position = "relative";
|
|
200
|
+
positionChanged = true;
|
|
190
201
|
patches.push({ type: "inline-style", property: "position", value: "relative" });
|
|
191
202
|
}
|
|
192
203
|
} catch {
|
|
193
204
|
/* cross-origin or detached — skip */
|
|
194
205
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
206
|
+
if (entry.key) {
|
|
207
|
+
usePlayerStore
|
|
208
|
+
.getState()
|
|
209
|
+
.updateElement(entry.key, { zIndex: entry.zIndex, hasExplicitZIndex: true });
|
|
210
|
+
}
|
|
211
|
+
rollbacks.push(() => {
|
|
212
|
+
entry.element.style.zIndex = priorZIndex;
|
|
213
|
+
if (positionChanged) entry.element.style.position = priorPosition;
|
|
214
|
+
if (entry.key && priorStoreEntry) {
|
|
215
|
+
usePlayerStore.getState().updateElement(entry.key, {
|
|
216
|
+
zIndex: priorStoreEntry.zIndex,
|
|
217
|
+
hasExplicitZIndex: priorStoreEntry.hasExplicitZIndex,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
saves.push(
|
|
222
|
+
commitPositionPatchToHtml(
|
|
223
|
+
{
|
|
224
|
+
element: entry.element,
|
|
225
|
+
id: entry.id ?? null,
|
|
226
|
+
hfId: readHfId(entry.element),
|
|
227
|
+
selector: entry.selector,
|
|
228
|
+
selectorIndex: entry.selectorIndex,
|
|
229
|
+
sourceFile: entry.sourceFile,
|
|
230
|
+
} as unknown as DomEditSelection,
|
|
231
|
+
patches,
|
|
232
|
+
{
|
|
233
|
+
label: "Reorder layers",
|
|
234
|
+
coalesceKey,
|
|
235
|
+
skipRefresh: i < entries.length - 1,
|
|
236
|
+
},
|
|
237
|
+
),
|
|
238
|
+
);
|
|
211
239
|
}
|
|
240
|
+
// Resolves once every z-index patch is persisted so a same-file timing write
|
|
241
|
+
// can be ordered after it (see applyTimelineStackingReorder callers).
|
|
242
|
+
return Promise.allSettled(saves).then((settled) => {
|
|
243
|
+
const rejected = settled.find(
|
|
244
|
+
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
245
|
+
);
|
|
246
|
+
if (rejected) {
|
|
247
|
+
for (const rollback of rollbacks) rollback();
|
|
248
|
+
return Promise.reject(rejected.reason);
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
});
|
|
212
252
|
},
|
|
213
253
|
[commitPositionPatchToHtml, onReorderShadow],
|
|
214
254
|
);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Version counter for the preview DOM. `refresh` bumps immediately and again
|
|
5
|
+
* at 80ms / 300ms so consumers re-scan after the iframe settles; pending
|
|
6
|
+
* timers are collapsed by each new refresh and cleared on unmount.
|
|
7
|
+
*/
|
|
8
|
+
export function usePreviewDocumentVersion(): [number, () => void] {
|
|
9
|
+
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
|
|
10
|
+
const refreshTimersRef = useRef<number[]>([]);
|
|
11
|
+
const refresh = useCallback(() => {
|
|
12
|
+
for (const id of refreshTimersRef.current) clearTimeout(id);
|
|
13
|
+
refreshTimersRef.current = [];
|
|
14
|
+
setPreviewDocumentVersion((v) => v + 1);
|
|
15
|
+
refreshTimersRef.current.push(
|
|
16
|
+
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80),
|
|
17
|
+
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300),
|
|
18
|
+
);
|
|
19
|
+
}, []);
|
|
20
|
+
useEffect(
|
|
21
|
+
() => () => {
|
|
22
|
+
for (const id of refreshTimersRef.current) clearTimeout(id);
|
|
23
|
+
},
|
|
24
|
+
[],
|
|
25
|
+
);
|
|
26
|
+
return [previewDocumentVersion, refresh];
|
|
27
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, type MutableRefObject } from "react";
|
|
2
|
+
import { openComposition, type Composition, type CompositionVariable } from "@hyperframes/sdk";
|
|
3
|
+
import { persistSdkSerialize } from "../utils/sdkCutover";
|
|
4
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
|
+
|
|
6
|
+
/** Records an edit into the studio's undo history (label + kind + per-file before/after). */
|
|
7
|
+
export type RecordEditFn = (entry: {
|
|
8
|
+
label: string;
|
|
9
|
+
kind: EditHistoryKind;
|
|
10
|
+
files: Record<string, { before: string; after: string }>;
|
|
11
|
+
}) => Promise<void>;
|
|
12
|
+
|
|
13
|
+
export interface CompositionVariableGroup {
|
|
14
|
+
/** Project-relative file path, e.g. "compositions/frames/02-problem.html". */
|
|
15
|
+
path: string;
|
|
16
|
+
/** The composition's variable declarations (empty groups are dropped by the hook). */
|
|
17
|
+
variables: CompositionVariable[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read one composition file's declarations, or null to skip (unreadable / none / unparseable). */
|
|
21
|
+
// fallow-ignore-next-line complexity
|
|
22
|
+
async function readGroup(
|
|
23
|
+
path: string,
|
|
24
|
+
readProjectFile: (path: string) => Promise<string>,
|
|
25
|
+
): Promise<CompositionVariableGroup | null> {
|
|
26
|
+
let content: string;
|
|
27
|
+
try {
|
|
28
|
+
content = await readProjectFile(path);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (!content.includes("data-composition-variables")) return null;
|
|
33
|
+
try {
|
|
34
|
+
const comp = await openComposition(content, { history: false });
|
|
35
|
+
try {
|
|
36
|
+
const variables = comp.getVariableDeclarations();
|
|
37
|
+
return variables.length > 0 ? { path, variables } : null;
|
|
38
|
+
} finally {
|
|
39
|
+
comp.dispose();
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
return null; // Unparseable composition — skip rather than break the whole panel.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read variable declarations from every composition file in the project except
|
|
48
|
+
* `excludePath` (the active composition, which the panel renders with its full
|
|
49
|
+
* preview/add controls). Powers the Variables tab's "other compositions"
|
|
50
|
+
* sections so a variable promoted into a sub-comp file is visible alongside the
|
|
51
|
+
* host's own. Re-reads whenever `refreshKey` changes (after an edit or preview
|
|
52
|
+
* reload). A cheap substring guard skips files with no declarations before the
|
|
53
|
+
* full parse, so large projects don't pay N openComposition calls.
|
|
54
|
+
*/
|
|
55
|
+
export function useProjectCompositionVariables(
|
|
56
|
+
fileTree: string[],
|
|
57
|
+
excludePath: string | null,
|
|
58
|
+
readProjectFile: (path: string) => Promise<string>,
|
|
59
|
+
refreshKey: unknown,
|
|
60
|
+
): CompositionVariableGroup[] {
|
|
61
|
+
const [groups, setGroups] = useState<CompositionVariableGroup[]>([]);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
let cancelled = false;
|
|
65
|
+
const htmlFiles = fileTree.filter((p) => p.endsWith(".html") && p !== excludePath);
|
|
66
|
+
|
|
67
|
+
void (async () => {
|
|
68
|
+
const out: CompositionVariableGroup[] = [];
|
|
69
|
+
for (const path of htmlFiles) {
|
|
70
|
+
const group = await readGroup(path, readProjectFile);
|
|
71
|
+
if (group) out.push(group);
|
|
72
|
+
}
|
|
73
|
+
if (!cancelled) setGroups(out);
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
cancelled = true;
|
|
78
|
+
};
|
|
79
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [fileTree, excludePath, readProjectFile, refreshKey]);
|
|
81
|
+
|
|
82
|
+
return groups;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface EditVariablesDeps {
|
|
86
|
+
readProjectFile: (path: string) => Promise<string>;
|
|
87
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
88
|
+
recordEdit: RecordEditFn;
|
|
89
|
+
reloadPreview: () => void;
|
|
90
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Apply a variable-schema mutation to an arbitrary composition file (a sub-comp
|
|
95
|
+
* that isn't the active SDK session) and persist it through the standard
|
|
96
|
+
* single-writer path. Opens a throwaway session on the file, runs `mutate`,
|
|
97
|
+
* and writes the serialized result — the same contract as useVariablesPersist,
|
|
98
|
+
* but keyed on `path` rather than a live session.
|
|
99
|
+
*/
|
|
100
|
+
export function useEditVariablesInFile(deps: EditVariablesDeps) {
|
|
101
|
+
const { readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef } =
|
|
102
|
+
deps;
|
|
103
|
+
return useCallback(
|
|
104
|
+
async (path: string, label: string, mutate: (session: Composition) => void): Promise<void> => {
|
|
105
|
+
const originalContent = await readProjectFile(path);
|
|
106
|
+
const comp = await openComposition(originalContent, { history: false });
|
|
107
|
+
let after: string;
|
|
108
|
+
try {
|
|
109
|
+
mutate(comp);
|
|
110
|
+
after = comp.serialize();
|
|
111
|
+
} finally {
|
|
112
|
+
comp.dispose();
|
|
113
|
+
}
|
|
114
|
+
if (after === originalContent) return;
|
|
115
|
+
await persistSdkSerialize(
|
|
116
|
+
after,
|
|
117
|
+
path,
|
|
118
|
+
originalContent,
|
|
119
|
+
{
|
|
120
|
+
editHistory: { recordEdit },
|
|
121
|
+
writeProjectFile,
|
|
122
|
+
reloadPreview,
|
|
123
|
+
domEditSaveTimestampRef,
|
|
124
|
+
compositionPath: path,
|
|
125
|
+
},
|
|
126
|
+
{ label },
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
[readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef],
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -97,7 +97,12 @@ export function useInspectorState(
|
|
|
97
97
|
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
|
|
98
98
|
// Keep the selection box + motion path drawn even when the Inspector is
|
|
99
99
|
// collapsed — closing the panel shouldn't visually deselect the element.
|
|
100
|
-
|
|
100
|
+
// The Variables tab also works against the canvas selection (bind card),
|
|
101
|
+
// so the selection outline stays visible there too.
|
|
102
|
+
shouldShowSelectedDomBounds:
|
|
103
|
+
(inspectorPanelActive || rightPanelTab === "variables") &&
|
|
104
|
+
!isPlaying &&
|
|
105
|
+
!isGestureRecording,
|
|
101
106
|
};
|
|
102
107
|
}, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
|
|
103
108
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useEffect, type MutableRefObject } from "react";
|
|
2
|
+
import { useSdkSession } from "./useSdkSession";
|
|
3
|
+
import { usePreviewVariablesStore } from "./previewVariablesStore";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Open the studio's SDK session with master-view semantics.
|
|
7
|
+
*
|
|
8
|
+
* The master view has no explicit comp path, but the session must still model
|
|
9
|
+
* the project's main composition so schema-level panels (Variables, Slideshow)
|
|
10
|
+
* work there. Edit-flow consumers keep the legacy "no session on master view"
|
|
11
|
+
* gating via `editFlowSdkSession` so cutover behavior is unchanged.
|
|
12
|
+
*
|
|
13
|
+
* Also clears preview variable overrides whenever the composition or project
|
|
14
|
+
* changes — overrides are per-composition and must never leak into another
|
|
15
|
+
* composition's preview or render.
|
|
16
|
+
*/
|
|
17
|
+
export function useStudioSdkSessions(
|
|
18
|
+
projectId: string | null,
|
|
19
|
+
activeCompPath: string | null,
|
|
20
|
+
domEditSaveTimestampRef: MutableRefObject<number>,
|
|
21
|
+
masterCompPath: string | null,
|
|
22
|
+
) {
|
|
23
|
+
// On the master view (no explicit comp) the schema panels target the project's
|
|
24
|
+
// resolved main composition — the first `.html` in the tree, not a hardcoded
|
|
25
|
+
// "index.html" that may not exist. `null` when the project has no composition
|
|
26
|
+
// yet, which correctly leaves the session (and the panels) empty.
|
|
27
|
+
const sdkHandle = useSdkSession(
|
|
28
|
+
projectId,
|
|
29
|
+
activeCompPath ?? masterCompPath,
|
|
30
|
+
domEditSaveTimestampRef,
|
|
31
|
+
);
|
|
32
|
+
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
usePreviewVariablesStore.getState().setValues(null);
|
|
35
|
+
}, [projectId, activeCompPath]);
|
|
36
|
+
return { sdkHandle, editFlowSdkSession };
|
|
37
|
+
}
|