@hyperframes/studio 0.8.47 → 0.8.49
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/hyperframes-player-CqON2Rzo.js +464 -0
- package/dist/assets/index-B9LfWNyM.css +1 -0
- package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
- package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
- package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
- package/dist/index.d.ts +70 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1452 -918
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
- package/src/hooks/useTimelineAssetDropOps.ts +24 -18
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +18 -49
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +8 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +16 -21
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.test.ts +51 -0
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
- package/dist/assets/index-DnRfAiK2.css +0 -1
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
|
7
7
|
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
|
|
8
8
|
import {
|
|
9
9
|
isResolverDisagreement,
|
|
10
|
+
sdkCutoverIneligibleReason,
|
|
10
11
|
shouldDeclineTextCutoverForTarget,
|
|
11
|
-
shouldUseSdkCutover,
|
|
12
12
|
} from "./sdkCutoverEligibility";
|
|
13
13
|
import {
|
|
14
14
|
asCutoverError,
|
|
@@ -81,11 +81,14 @@ export async function sdkCutoverPersist(
|
|
|
81
81
|
deps: CutoverDeps,
|
|
82
82
|
options?: CutoverOptions,
|
|
83
83
|
): Promise<CutoverResult> {
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// Name WHICH eligibility check failed: on v0.8.47 this one string covered 26 of
|
|
85
|
+
// 30 dom declines. The two below aren't properties of the batch, so they differ.
|
|
86
|
+
if (!sdkFamilyEnabled("dom")) return declinedCutover("feature_disabled", "dom");
|
|
86
87
|
if (!sdkSession) return declinedCutover("session_unavailable", "dom");
|
|
87
88
|
const hfId = selection.hfId;
|
|
88
|
-
|
|
89
|
+
const ineligible = sdkCutoverIneligibleReason(hfId, ops);
|
|
90
|
+
// `!hfId` is already a reason; testing it again narrows the type for the rest.
|
|
91
|
+
if (ineligible || !hfId) return declinedCutover(ineligible ?? "target_unaddressable", "dom");
|
|
89
92
|
const target = sdkSession.getElement(hfId);
|
|
90
93
|
if (!target)
|
|
91
94
|
return declinedCutover("target_not_found", "dom", isResolverDisagreement(sdkSession, hfId));
|
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { PatchOperation } from "./sourcePatcher";
|
|
3
|
-
import { shouldUseSdkCutover } from "./sdkCutoverEligibility";
|
|
3
|
+
import { shouldUseSdkCutover, sdkCutoverIneligibleReason } from "./sdkCutoverEligibility";
|
|
4
|
+
|
|
5
|
+
const styleOp = (property: string, value: string): PatchOperation => ({
|
|
6
|
+
type: "inline-style",
|
|
7
|
+
property,
|
|
8
|
+
value,
|
|
9
|
+
});
|
|
10
|
+
const textOp = (value: string): PatchOperation => ({
|
|
11
|
+
type: "text-content",
|
|
12
|
+
property: "text",
|
|
13
|
+
value,
|
|
14
|
+
});
|
|
15
|
+
const attrOp = (property: string, value: string): PatchOperation => ({
|
|
16
|
+
type: "attribute",
|
|
17
|
+
property,
|
|
18
|
+
value,
|
|
19
|
+
});
|
|
20
|
+
const htmlAttrOp = (property: string, value: string): PatchOperation => ({
|
|
21
|
+
type: "html-attribute",
|
|
22
|
+
property,
|
|
23
|
+
value,
|
|
24
|
+
});
|
|
4
25
|
|
|
5
26
|
const childStyleOp: PatchOperation = {
|
|
6
27
|
type: "inline-style",
|
|
@@ -15,3 +36,77 @@ describe("shouldUseSdkCutover child-scoped operations", () => {
|
|
|
15
36
|
expect(shouldUseSdkCutover(true, true, "hf-parent", [childStyleOp])).toBe(false);
|
|
16
37
|
});
|
|
17
38
|
});
|
|
39
|
+
|
|
40
|
+
describe("sdkCutoverIneligibleReason", () => {
|
|
41
|
+
// `ineligible_operation` was one string covering four distinct causes, which
|
|
42
|
+
// made the post-flip decline telemetry unactionable: 26 of 30 declines on
|
|
43
|
+
// v0.8.47 landed there, with no way to separate a structural edit the SDK has
|
|
44
|
+
// no vocabulary for (expected, permanent) from a reserved-attribute or
|
|
45
|
+
// unsafe-attribute decline (narrower, and possibly fixable).
|
|
46
|
+
it("names an op type the SDK cannot express", () => {
|
|
47
|
+
expect(
|
|
48
|
+
sdkCutoverIneligibleReason("hf-abc", [
|
|
49
|
+
{ type: "split", property: "x", value: "1" } as unknown as PatchOperation,
|
|
50
|
+
]),
|
|
51
|
+
).toBe("unsupported_op_type");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("names a child-scoped op", () => {
|
|
55
|
+
expect(sdkCutoverIneligibleReason("hf-parent", [childStyleOp])).toBe("child_scoped_op");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("names a reserved attribute", () => {
|
|
59
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [attrOp("end", "2")])).toBe("reserved_attribute");
|
|
60
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(
|
|
61
|
+
"reserved_attribute",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("names an unsafe html attribute", () => {
|
|
66
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [htmlAttrOp("onclick", "alert(1)")])).toBe(
|
|
67
|
+
"unsafe_html_attribute",
|
|
68
|
+
);
|
|
69
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [htmlAttrOp("href", "javascript:alert(1)")])).toBe(
|
|
70
|
+
"unsafe_html_attribute",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("names an empty batch and an unaddressable target", () => {
|
|
75
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [])).toBe("no_ops");
|
|
76
|
+
expect(sdkCutoverIneligibleReason(null, [styleOp("color", "red")])).toBe(
|
|
77
|
+
"target_unaddressable",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns null when the batch is eligible", () => {
|
|
82
|
+
expect(sdkCutoverIneligibleReason("hf-abc", [styleOp("color", "red")])).toBeNull();
|
|
83
|
+
expect(
|
|
84
|
+
sdkCutoverIneligibleReason("hf-abc", [
|
|
85
|
+
styleOp("color", "red"),
|
|
86
|
+
textOp("hello"),
|
|
87
|
+
attrOp("x", "1"),
|
|
88
|
+
htmlAttrOp("class", "foo"),
|
|
89
|
+
]),
|
|
90
|
+
).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// The reason function must not drift from the predicate it explains: a batch
|
|
94
|
+
// the gate accepts must have no reason, and one it declines must have one.
|
|
95
|
+
it("agrees with shouldUseSdkCutover on every case", () => {
|
|
96
|
+
const cases: PatchOperation[][] = [
|
|
97
|
+
[styleOp("color", "red")],
|
|
98
|
+
[textOp("hello")],
|
|
99
|
+
[attrOp("x", "1")],
|
|
100
|
+
[htmlAttrOp("class", "foo")],
|
|
101
|
+
[attrOp("end", "2")],
|
|
102
|
+
[htmlAttrOp("onclick", "alert(1)")],
|
|
103
|
+
[childStyleOp],
|
|
104
|
+
[{ type: "split", property: "x", value: "1" } as unknown as PatchOperation],
|
|
105
|
+
[],
|
|
106
|
+
];
|
|
107
|
+
for (const ops of cases) {
|
|
108
|
+
const eligible = shouldUseSdkCutover(true, true, "hf-abc", ops);
|
|
109
|
+
expect(sdkCutoverIneligibleReason("hf-abc", ops) === null).toBe(eligible);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -123,21 +123,44 @@ export function isResolverDisagreement(session: Composition, hfId: string): bool
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Why a batch cannot take the SDK cutover path, or null when it can.
|
|
128
|
+
*
|
|
129
|
+
* `ineligible_operation` used to be a single decline reason covering all of
|
|
130
|
+
* these, which made the post-flip telemetry unactionable — a structural edit the
|
|
131
|
+
* SDK has no vocabulary for (expected and permanent) was indistinguishable from
|
|
132
|
+
* a reserved-attribute decline (narrow, and possibly worth fixing). The order
|
|
133
|
+
* mirrors `shouldUseSdkCutover`'s conjunction, so the first failing check names
|
|
134
|
+
* the reason.
|
|
135
|
+
*/
|
|
136
|
+
export type SdkCutoverIneligibleReason =
|
|
137
|
+
| "target_unaddressable"
|
|
138
|
+
| "no_ops"
|
|
139
|
+
| "unsupported_op_type"
|
|
140
|
+
| "child_scoped_op"
|
|
141
|
+
| "reserved_attribute"
|
|
142
|
+
| "unsafe_html_attribute";
|
|
143
|
+
|
|
144
|
+
export function sdkCutoverIneligibleReason(
|
|
145
|
+
hfId: string | null | undefined,
|
|
146
|
+
ops: PatchOperation[],
|
|
147
|
+
): SdkCutoverIneligibleReason | null {
|
|
148
|
+
if (!hfId) return "target_unaddressable";
|
|
149
|
+
if (ops.length === 0) return "no_ops";
|
|
150
|
+
if (!ops.every((o) => CUTOVER_OP_TYPES.has(o.type))) return "unsupported_op_type";
|
|
151
|
+
if (hasChildScopedOp(ops)) return "child_scoped_op";
|
|
152
|
+
if (ops.some(mapsToReservedAttr)) return "reserved_attribute";
|
|
153
|
+
if (hasUnsafeHtmlAttributeOp(ops)) return "unsafe_html_attribute";
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
126
157
|
export function shouldUseSdkCutover(
|
|
127
158
|
flagEnabled: boolean,
|
|
128
159
|
hasSession: boolean,
|
|
129
160
|
hfId: string | null | undefined,
|
|
130
161
|
ops: PatchOperation[],
|
|
131
162
|
): boolean {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
!!hfId &&
|
|
136
|
-
ops.length > 0 &&
|
|
137
|
-
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
|
|
138
|
-
// SDK edit ops target only the element hfId; child-scoped patch ops need the server path.
|
|
139
|
-
!hasChildScopedOp(ops) &&
|
|
140
|
-
!ops.some(mapsToReservedAttr) &&
|
|
141
|
-
!hasUnsafeHtmlAttributeOp(ops)
|
|
142
|
-
);
|
|
163
|
+
// Derived from the reason function so the two can never disagree about which
|
|
164
|
+
// batches are eligible.
|
|
165
|
+
return flagEnabled && hasSession && sdkCutoverIneligibleReason(hfId, ops) === null;
|
|
143
166
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
findMatchingTimelineElementId,
|
|
6
6
|
findTimelineIdByAncestor,
|
|
7
7
|
resolveDroppedAssetDimensions,
|
|
8
|
+
resolveElementTrack,
|
|
8
9
|
resolveTimelineIdForSelection,
|
|
9
10
|
resolveTimelineSelectionSeekTime,
|
|
10
11
|
} from "./studioHelpers";
|
|
@@ -42,6 +43,13 @@ describe("findMatchingTimelineElementId", () => {
|
|
|
42
43
|
expect(findMatchingTimelineElementId({ id: "s1", sourceFile: "index.html" }, els)).toBe("s1");
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
it("matches by hfId when the selection has no domId, so an element with no authored id can still be found", () => {
|
|
47
|
+
const els = [el({ id: "hf-1", domId: undefined, hfId: "hf-1", sourceFile: "index.html" })];
|
|
48
|
+
expect(
|
|
49
|
+
findMatchingTimelineElementId({ id: undefined, hfId: "hf-1", sourceFile: "index.html" }, els),
|
|
50
|
+
).toBe("hf-1");
|
|
51
|
+
});
|
|
52
|
+
|
|
45
53
|
it("returns a qualified id for a sub-comp child with no matching timeline element", () => {
|
|
46
54
|
const els = [el({ id: "s3", domId: "s3", sourceFile: "index.html" })];
|
|
47
55
|
expect(
|
|
@@ -199,3 +207,13 @@ describe("resolveDroppedAssetDimensions", () => {
|
|
|
199
207
|
expect(load).toHaveBeenCalledOnce();
|
|
200
208
|
});
|
|
201
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("resolveElementTrack", () => {
|
|
212
|
+
it("rounds an authored track", () => {
|
|
213
|
+
expect(resolveElementTrack({ authoredTrack: 2.4, track: 0 })).toBe(2);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("falls back to the resolved track, rounded, when nothing was authored", () => {
|
|
217
|
+
expect(resolveElementTrack({ authoredTrack: undefined, track: 3.6 })).toBe(4);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -126,9 +126,15 @@ export function getHistoryShortcutLabel(action: "undo" | "redo"): string {
|
|
|
126
126
|
return action === "undo" ? `${modifier}+Z` : `${modifier}+Shift+Z`;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
type ElementMatchSelection = Pick<
|
|
129
|
+
export type ElementMatchSelection = Pick<
|
|
130
130
|
DomEditSelection,
|
|
131
|
-
|
|
131
|
+
| "id"
|
|
132
|
+
| "hfId"
|
|
133
|
+
| "selector"
|
|
134
|
+
| "selectorIndex"
|
|
135
|
+
| "sourceFile"
|
|
136
|
+
| "compositionSrc"
|
|
137
|
+
| "isCompositionHost"
|
|
132
138
|
>;
|
|
133
139
|
|
|
134
140
|
function matchesByDomId(
|
|
@@ -142,6 +148,17 @@ function matchesByDomId(
|
|
|
142
148
|
);
|
|
143
149
|
}
|
|
144
150
|
|
|
151
|
+
function matchesByHfId(
|
|
152
|
+
selection: ElementMatchSelection,
|
|
153
|
+
element: TimelineElement,
|
|
154
|
+
selectionSourceFile: string,
|
|
155
|
+
): boolean {
|
|
156
|
+
if (!selection.hfId) return false;
|
|
157
|
+
return (
|
|
158
|
+
element.hfId === selection.hfId && (element.sourceFile || "index.html") === selectionSourceFile
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
145
162
|
function matchesByCompositionHost(
|
|
146
163
|
selection: ElementMatchSelection,
|
|
147
164
|
element: TimelineElement,
|
|
@@ -172,14 +189,22 @@ export function findMatchingTimelineElementId(
|
|
|
172
189
|
// scan let `.find()` stop at an EARLIER, unrelated host that merely shares
|
|
173
190
|
// the compositionSrc, before the scan ever reached the correct id/selector
|
|
174
191
|
// match further down the list — collapsing every repeated host to the
|
|
175
|
-
// first one. Try id, then selector, across the WHOLE list
|
|
176
|
-
// back to the coarser compositionSrc-only match when
|
|
177
|
-
// specific element.
|
|
192
|
+
// first one. Try id, then hfId, then selector, across the WHOLE list
|
|
193
|
+
// first; only fall back to the coarser compositionSrc-only match when
|
|
194
|
+
// none of them identifies a specific element.
|
|
178
195
|
const byId = selection.id
|
|
179
196
|
? elements.find((el) => matchesByDomId(selection, el, selectionSourceFile))
|
|
180
197
|
: undefined;
|
|
181
198
|
if (byId) return byId.key ?? byId.id;
|
|
182
199
|
|
|
200
|
+
// hfId is the stable content-hash id every element gets regardless of
|
|
201
|
+
// whether it has a real DOM id — the only correlator for an element like
|
|
202
|
+
// an ungroup child that has neither an authored id nor a selector.
|
|
203
|
+
const byHfId = selection.hfId
|
|
204
|
+
? elements.find((el) => matchesByHfId(selection, el, selectionSourceFile))
|
|
205
|
+
: undefined;
|
|
206
|
+
if (byHfId) return byHfId.key ?? byHfId.id;
|
|
207
|
+
|
|
183
208
|
const bySelector = selection.selector
|
|
184
209
|
? elements.find((el) => matchesBySelector(selection, el))
|
|
185
210
|
: undefined;
|
|
@@ -198,6 +223,17 @@ export function findMatchingTimelineElementId(
|
|
|
198
223
|
return null;
|
|
199
224
|
}
|
|
200
225
|
|
|
226
|
+
// The element's track: authored if given, else the runtime's already-resolved
|
|
227
|
+
// fallback — always rounded to an integer index either way. Shared by the
|
|
228
|
+
// group/ungroup and razor-split flows so they can't drift out of sync again.
|
|
229
|
+
export function resolveElementTrack(
|
|
230
|
+
element: Pick<TimelineElement, "authoredTrack" | "track">,
|
|
231
|
+
): number {
|
|
232
|
+
return Math.round(
|
|
233
|
+
Number.isFinite(element.authoredTrack) ? (element.authoredTrack as number) : element.track,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
201
237
|
/**
|
|
202
238
|
* A selected DOM node may be a static descendant of a clip (e.g. the `.num` text
|
|
203
239
|
* inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest
|
|
@@ -73,6 +73,20 @@ describe("timelineSnapEnabled preference", () => {
|
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
describe("rippleEditEnabled preference", () => {
|
|
77
|
+
it("round-trips through storage", () => {
|
|
78
|
+
const storage = createStorage();
|
|
79
|
+
writeStudioUiPreferences({ rippleEditEnabled: false }, storage);
|
|
80
|
+
expect(readStudioUiPreferences(storage).rippleEditEnabled).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("ignores non-boolean values", () => {
|
|
84
|
+
const storage = createStorage();
|
|
85
|
+
storage.setItem("hf-studio-ui-preferences", JSON.stringify({ rippleEditEnabled: "yes" }));
|
|
86
|
+
expect(readStudioUiPreferences(storage).rippleEditEnabled).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
76
90
|
describe("thumbnailMode preference", () => {
|
|
77
91
|
it("round-trips the adaptive mode", () => {
|
|
78
92
|
const storage = createStorage();
|
|
@@ -24,6 +24,9 @@ export interface StudioUiPreferences {
|
|
|
24
24
|
snapToGrid?: boolean;
|
|
25
25
|
/** Timeline magnet: snap clip drags/trims/drops to playhead, clip edges, and beats. */
|
|
26
26
|
timelineSnapEnabled?: boolean;
|
|
27
|
+
/** Keeps the main track gapless: deleting a clip closes the gap. Distinct
|
|
28
|
+
* from `timelineSnapEnabled` ("Magnet", drag/trim snapping). */
|
|
29
|
+
rippleEditEnabled?: boolean;
|
|
27
30
|
/** Transport + ruler readout mode: timecode or frame number. */
|
|
28
31
|
timeDisplayMode?: TimelineTimeDisplayMode;
|
|
29
32
|
/**
|
|
@@ -136,6 +139,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
|
|
136
139
|
if (typeof parsed.timelineSnapEnabled === "boolean") {
|
|
137
140
|
preferences.timelineSnapEnabled = parsed.timelineSnapEnabled;
|
|
138
141
|
}
|
|
142
|
+
if (typeof parsed.rippleEditEnabled === "boolean") {
|
|
143
|
+
preferences.rippleEditEnabled = parsed.rippleEditEnabled;
|
|
144
|
+
}
|
|
139
145
|
if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
|
|
140
146
|
preferences.timeDisplayMode = parsed.timeDisplayMode;
|
|
141
147
|
}
|
|
@@ -31,16 +31,24 @@ export interface StudioUrlState {
|
|
|
31
31
|
const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"];
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* The composition a schema-level panel (Variables / Slideshow)
|
|
35
|
-
* master view, where there is no explicit `activeCompPath`.
|
|
36
|
-
* `index.html` convention, but fall back to the first
|
|
34
|
+
* The composition auto-open and a schema-level panel (Variables / Slideshow)
|
|
35
|
+
* target on the master view, where there is no explicit `activeCompPath`.
|
|
36
|
+
* Prefer the `index.html` convention, but fall back to the first entry
|
|
37
37
|
* (composition-browser order) so projects whose entry file is `card.html`,
|
|
38
38
|
* `hero.html`, etc. don't silently mis-target a non-existent `index.html`.
|
|
39
|
-
*
|
|
39
|
+
* Takes the server's filtered composition list, never the raw file tree —
|
|
40
|
+
* a non-composition `.html` (a vendored preset, one with no
|
|
41
|
+
* `data-composition-id`) must never become the target. Returns null when the
|
|
42
|
+
* project carries no composition file at all.
|
|
40
43
|
*/
|
|
41
|
-
export function resolveMasterCompositionPath(
|
|
42
|
-
if (
|
|
43
|
-
return
|
|
44
|
+
export function resolveMasterCompositionPath(compositions: string[]): string | null {
|
|
45
|
+
if (compositions.includes("index.html")) return "index.html";
|
|
46
|
+
return compositions.find((p) => p.endsWith(".html")) ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A URL with no `activeCompPath` needs no hydration step — there's nothing to apply. */
|
|
50
|
+
export function isHydratedFromUrlState(urlState: StudioUrlState): boolean {
|
|
51
|
+
return urlState.activeCompPath == null;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export function normalizeStudioUrlPanelTab(tab: RightPanelTab | null): RightPanelTab | null {
|
|
@@ -3,6 +3,7 @@ import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
|
|
|
3
3
|
import type { RecordEditInput } from "./studioFileHistory";
|
|
4
4
|
import { buildProjectApiPath } from "./projectRouting";
|
|
5
5
|
import { studioWriteHeaders } from "./studioFileVersion";
|
|
6
|
+
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
6
7
|
|
|
7
8
|
interface TimelineCompositionInsertionResult {
|
|
8
9
|
path: string;
|
|
@@ -76,7 +77,7 @@ export async function commitTimelineCompositionInsertion(input: {
|
|
|
76
77
|
await input.writeFile(input.targetPath, result.before, result.after);
|
|
77
78
|
throw error;
|
|
78
79
|
}
|
|
79
|
-
input.selectHost(
|
|
80
|
+
input.selectHost(deriveTimelineStoreKeyForDomId(result.hostId, input.targetPath));
|
|
80
81
|
try {
|
|
81
82
|
input.resync?.();
|
|
82
83
|
} catch (error) {
|
|
@@ -34,6 +34,16 @@ export function isAudioTimelineElement(
|
|
|
34
34
|
return Boolean(element.src && AUDIO_SOURCE_EXT_RE.test(element.src));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** The two tags the property panel lets you put a volume automation lane on.
|
|
38
|
+
* Single owner: `groupAutomationLanes`, `automationLaneCountOf` and
|
|
39
|
+
* `TimelineAutomationLaneSlot`'s clip filter all have to agree on this set. */
|
|
40
|
+
export function isAudioOrVideoTimelineElement(
|
|
41
|
+
element: Pick<TimelineElement, "tag" | "src"> | null | undefined,
|
|
42
|
+
): boolean {
|
|
43
|
+
if (!element) return false;
|
|
44
|
+
return isAudioTimelineElement(element) || element.tag.trim().toLowerCase() === "video";
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
/** True for the music track: an audio element with data-timeline-role="music",
|
|
38
48
|
* or — when no role is set — an id matching the music regex. Voiceover/other
|
|
39
49
|
* audio (explicit non-music role) is excluded. */
|