@hyperframes/studio 0.8.54 → 0.8.56
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-Cy_AwUa8.js → hyperframes-player-k8LzMtXX.js} +1 -1
- package/dist/assets/{index-DPUJoO8n.js → index-BlkJ_Axe.js} +192 -192
- package/dist/assets/{index-CTa-6Kso.js → index-CxR7F2YQ.js} +1 -1
- package/dist/assets/{index-DkAzKuPZ.js → index-DFlILHJe.js} +1 -1
- package/dist/assets/{index-DrHQAuGO.css → index-ZmBtVrEm.css} +1 -1
- package/dist/index.d.ts +16 -8
- package/dist/index.html +2 -2
- package/dist/index.js +3152 -3010
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +14 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +59 -50
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -40
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
- package/src/components/editor/PropertyPanel.tsx +4 -3
- package/src/components/editor/SnapToolbar.test.tsx +1 -0
- package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
- package/src/components/editor/previewReadOnlyContext.tsx +59 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
- package/src/components/editor/useDomEditNudge.ts +9 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
- package/src/components/editor/useOffCanvasIndicators.ts +50 -0
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +15 -8
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +128 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
- package/src/components/nle/usePreviewBlockDrop.ts +3 -1
- package/src/hooks/appHotkeysDispatch.ts +16 -1
- package/src/hooks/gsapDragCommit.ts +1 -1
- package/src/hooks/gsapEditOutcome.test.ts +115 -0
- package/src/hooks/gsapEditOutcome.ts +39 -10
- package/src/hooks/gsapResizeIntercept.test.ts +42 -3
- package/src/hooks/gsapResizeIntercept.ts +12 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
- package/src/hooks/gsapRuntimeBridge.ts +14 -3
- package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
- package/src/hooks/timelineAudioGroupCreate.ts +3 -4
- package/src/hooks/timelineTrackVisibility.ts +7 -8
- package/src/hooks/useAppHotkeys.test.ts +71 -3
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -0
- package/src/hooks/useDomEditCommits.test.tsx +1 -0
- package/src/hooks/useDomEditCommits.ts +3 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useDomEditWiring.ts +40 -40
- package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
- package/src/hooks/useDomGeometryCommits.ts +26 -20
- package/src/hooks/useGestureCommit.test.tsx +29 -0
- package/src/hooks/useGestureCommit.ts +23 -0
- package/src/hooks/useInlineTextEdit.test.tsx +23 -2
- package/src/hooks/useInlineTextEdit.ts +11 -2
- package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
- package/src/hooks/useTimelineAssetDropOps.ts +1 -6
- package/src/hooks/useTimelineEditing.test.tsx +10 -7
- package/src/hooks/useTimelineEditing.ts +2 -7
- package/src/player/components/Timeline.tsx +22 -23
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +34 -30
- package/src/player/components/timelineTrackDisplay.ts +1 -4
- package/src/player/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
- package/src/player/store/playerStore.ts +0 -4
- package/src/player/store/timelineElement.ts +2 -4
- package/src/player/store/timelineResetState.ts +0 -1
- package/src/styles/hex-baseline.json +3 -4
- package/src/styles/studio.css +7 -7
- package/src/styles/theme.css +29 -0
- package/src/utils/studioSaveDiagnostics.ts +12 -0
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- package/src/player/hooks/useTimelineRowElements.ts +0 -17
|
@@ -2,6 +2,27 @@ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/
|
|
|
2
2
|
|
|
3
3
|
export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Which of the nine situations produced a block. The user-facing `reason` stays
|
|
7
|
+
* coarse — three messages — but "source-uneditable" alone covers nine distinct
|
|
8
|
+
* causes, and `edit_blocked` telemetry could not tell them apart. That matters
|
|
9
|
+
* because the copy ("This animation is computed at runtime") is only literally
|
|
10
|
+
* true for `provenance-runtime-dynamic`; the others are parser or source-match
|
|
11
|
+
* limits, where the animation may well be plain authored source.
|
|
12
|
+
*
|
|
13
|
+
* Telemetry only. Nothing branches on it.
|
|
14
|
+
*/
|
|
15
|
+
export type GsapEditBlockDetail =
|
|
16
|
+
| "provenance-runtime-dynamic"
|
|
17
|
+
| "unresolved-keyframes"
|
|
18
|
+
| "unresolved-selector"
|
|
19
|
+
| "geometry-unresolved-source"
|
|
20
|
+
| "live-position-no-source-tween"
|
|
21
|
+
| "no-position-tween"
|
|
22
|
+
| "live-rotation-no-source-tween"
|
|
23
|
+
| "live-resize-no-source-tween"
|
|
24
|
+
| "zero-duration-tween";
|
|
25
|
+
|
|
5
26
|
export type GsapEditOutcome =
|
|
6
27
|
| {
|
|
7
28
|
status: "persisted";
|
|
@@ -22,7 +43,7 @@ export type GsapEditOutcome =
|
|
|
22
43
|
*/
|
|
23
44
|
ownsDragOffset?: boolean;
|
|
24
45
|
}
|
|
25
|
-
| { status: "blocked"; reason: GsapEditBlockReason };
|
|
46
|
+
| { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail };
|
|
26
47
|
|
|
27
48
|
const COPY: Record<GsapEditBlockReason, string> = {
|
|
28
49
|
"no-selector": "This layer needs a stable selector before Studio can save the edit.",
|
|
@@ -32,25 +53,32 @@ const COPY: Record<GsapEditBlockReason, string> = {
|
|
|
32
53
|
};
|
|
33
54
|
|
|
34
55
|
export class GsapEditBlockedError extends Error {
|
|
35
|
-
constructor(
|
|
56
|
+
constructor(
|
|
57
|
+
readonly reason: GsapEditBlockReason,
|
|
58
|
+
readonly detail?: GsapEditBlockDetail,
|
|
59
|
+
) {
|
|
36
60
|
super(COPY[reason]);
|
|
37
61
|
this.name = "GsapEditBlockedError";
|
|
38
62
|
}
|
|
39
63
|
}
|
|
40
64
|
|
|
41
65
|
export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
|
|
42
|
-
if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason);
|
|
66
|
+
if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason, outcome.detail);
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
|
|
46
70
|
const editability = editabilityForProvenance(animation.provenance);
|
|
47
71
|
if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
// Same message for all three, but they are different problems: only the first
|
|
73
|
+
// is genuinely a runtime-computed value.
|
|
74
|
+
if (editability === "source") {
|
|
75
|
+
throw new GsapEditBlockedError("source-uneditable", "provenance-runtime-dynamic");
|
|
76
|
+
}
|
|
77
|
+
if (animation.hasUnresolvedKeyframes) {
|
|
78
|
+
throw new GsapEditBlockedError("source-uneditable", "unresolved-keyframes");
|
|
79
|
+
}
|
|
80
|
+
if (animation.hasUnresolvedSelector) {
|
|
81
|
+
throw new GsapEditBlockedError("source-uneditable", "unresolved-selector");
|
|
54
82
|
}
|
|
55
83
|
}
|
|
56
84
|
|
|
@@ -84,7 +112,8 @@ export function directEditOutcomeForProperties(
|
|
|
84
112
|
}
|
|
85
113
|
return { status: "persisted" };
|
|
86
114
|
} catch (error) {
|
|
87
|
-
if (isGsapEditBlockedError(error))
|
|
115
|
+
if (isGsapEditBlockedError(error))
|
|
116
|
+
return { status: "blocked", reason: error.reason, detail: error.detail };
|
|
88
117
|
throw error;
|
|
89
118
|
}
|
|
90
119
|
}
|
|
@@ -180,11 +180,15 @@ it("reuses the ownership parse instead of fetching a resolved size group twice",
|
|
|
180
180
|
);
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
function makeBoxSelection(): { el: HTMLElement; selection: DomEditSelection } {
|
|
184
184
|
const el = document.createElement("div");
|
|
185
185
|
el.id = "box";
|
|
186
186
|
document.body.append(el);
|
|
187
|
-
|
|
187
|
+
return { el, selection: { id: "box", selector: "#box", element: el } as DomEditSelection };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it("blocks when runtime size motion exists but the authored tween cannot be resolved", async () => {
|
|
191
|
+
const { el, selection } = makeBoxSelection();
|
|
188
192
|
const liveSizeTween = {
|
|
189
193
|
targets: () => [el],
|
|
190
194
|
vars: { width: 300, duration: 1 },
|
|
@@ -199,7 +203,42 @@ it("blocks when runtime size motion exists but the authored tween cannot be reso
|
|
|
199
203
|
|
|
200
204
|
await expect(
|
|
201
205
|
tryGsapResizeIntercept(selection, { width: 344, height: 344 }, [], iframe, commitMutation),
|
|
202
|
-
).resolves.toEqual({
|
|
206
|
+
).resolves.toEqual({
|
|
207
|
+
status: "blocked",
|
|
208
|
+
reason: "source-uneditable",
|
|
209
|
+
detail: "live-resize-no-source-tween",
|
|
210
|
+
});
|
|
211
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("blocks a tween with no positive duration instead of committing into it", async () => {
|
|
215
|
+
const { selection } = makeBoxSelection();
|
|
216
|
+
// A zero-duration `from` is not an instant hold (those are `set`/`to`), so it
|
|
217
|
+
// reaches the duration check: a resize has no timeline position to land on.
|
|
218
|
+
const zeroDurationTween = {
|
|
219
|
+
id: "#box-size",
|
|
220
|
+
targetSelector: "#box",
|
|
221
|
+
propertyGroup: "size",
|
|
222
|
+
method: "from",
|
|
223
|
+
position: 0,
|
|
224
|
+
duration: 0,
|
|
225
|
+
properties: { width: 150, height: 150 },
|
|
226
|
+
} as unknown as GsapAnimation;
|
|
227
|
+
const commitMutation = vi.fn();
|
|
228
|
+
|
|
229
|
+
await expect(
|
|
230
|
+
tryGsapResizeIntercept(
|
|
231
|
+
selection,
|
|
232
|
+
{ width: 344, height: 344 },
|
|
233
|
+
[zeroDurationTween],
|
|
234
|
+
null,
|
|
235
|
+
commitMutation,
|
|
236
|
+
),
|
|
237
|
+
).resolves.toEqual({
|
|
238
|
+
status: "blocked",
|
|
239
|
+
reason: "source-uneditable",
|
|
240
|
+
detail: "zero-duration-tween",
|
|
241
|
+
});
|
|
203
242
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
204
243
|
});
|
|
205
244
|
|
|
@@ -137,7 +137,12 @@ export async function tryGsapResizeIntercept(
|
|
|
137
137
|
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, [...resizeProperties])
|
|
138
138
|
: false;
|
|
139
139
|
if (!anim && hasLiveResizeTween) {
|
|
140
|
-
|
|
140
|
+
// Third twin of the position/rotation cases: a live tween with no source match.
|
|
141
|
+
return {
|
|
142
|
+
status: "blocked",
|
|
143
|
+
reason: "source-uneditable",
|
|
144
|
+
detail: "live-resize-no-source-tween",
|
|
145
|
+
};
|
|
141
146
|
}
|
|
142
147
|
logResize("intercept-enter", {
|
|
143
148
|
hasScaleGroup,
|
|
@@ -194,7 +199,12 @@ export async function tryGsapResizeIntercept(
|
|
|
194
199
|
}
|
|
195
200
|
|
|
196
201
|
const tweenDuration = resolveTweenDuration(anim);
|
|
197
|
-
if (tweenDuration <= 0)
|
|
202
|
+
if (tweenDuration <= 0) {
|
|
203
|
+
// The tween exists in source but has no positive duration, so there is no
|
|
204
|
+
// timeline position at which a resize could land — a different cause from
|
|
205
|
+
// the live-tween-without-a-source-match cases above.
|
|
206
|
+
return { status: "blocked", reason: "source-uneditable", detail: "zero-duration-tween" };
|
|
207
|
+
}
|
|
198
208
|
|
|
199
209
|
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
|
200
210
|
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
@@ -94,7 +94,11 @@ describe("tryGsapDragIntercept — stale-parse guard (no resurrection after dele
|
|
|
94
94
|
vi.fn().mockResolvedValue([]),
|
|
95
95
|
);
|
|
96
96
|
|
|
97
|
-
expect(result).toEqual({
|
|
97
|
+
expect(result).toEqual({
|
|
98
|
+
status: "blocked",
|
|
99
|
+
reason: "source-uneditable",
|
|
100
|
+
detail: "live-position-no-source-tween",
|
|
101
|
+
});
|
|
98
102
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
99
103
|
});
|
|
100
104
|
|
|
@@ -354,7 +358,11 @@ describe("tryGsapRotationIntercept — instant holds", () => {
|
|
|
354
358
|
fakeIframe("puck-b", [liveRotation]),
|
|
355
359
|
commitMutation,
|
|
356
360
|
),
|
|
357
|
-
).resolves.toEqual({
|
|
361
|
+
).resolves.toEqual({
|
|
362
|
+
status: "blocked",
|
|
363
|
+
reason: "source-uneditable",
|
|
364
|
+
detail: "live-rotation-no-source-tween",
|
|
365
|
+
});
|
|
358
366
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
359
367
|
});
|
|
360
368
|
|
|
@@ -156,7 +156,13 @@ async function preflightGsapDragIntercept(
|
|
|
156
156
|
const hasLivePosition = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
|
|
157
157
|
|
|
158
158
|
if (hasLivePosition && !posAnim) {
|
|
159
|
-
|
|
159
|
+
// GSAP is visibly moving this element but the parser found no position
|
|
160
|
+
// tween for it — a source-match gap, not necessarily computed source.
|
|
161
|
+
return {
|
|
162
|
+
status: "blocked",
|
|
163
|
+
reason: "source-uneditable",
|
|
164
|
+
detail: "live-position-no-source-tween",
|
|
165
|
+
};
|
|
160
166
|
}
|
|
161
167
|
if (!posAnim && !writeTargetSelector(selection)) {
|
|
162
168
|
return { status: "blocked", reason: "no-selector" };
|
|
@@ -268,7 +274,7 @@ export async function tryGsapDragIntercept(
|
|
|
268
274
|
}
|
|
269
275
|
|
|
270
276
|
if (!posAnim) {
|
|
271
|
-
return { status: "blocked", reason: "source-uneditable" };
|
|
277
|
+
return { status: "blocked", reason: "source-uneditable", detail: "no-position-tween" };
|
|
272
278
|
}
|
|
273
279
|
|
|
274
280
|
// Verify the anim ID is still valid in the current file. The React-state
|
|
@@ -353,7 +359,12 @@ export async function tryGsapRotationIntercept(
|
|
|
353
359
|
? hasNonHoldTweenForElement(iframe, liveSelector, undefined, ROTATION_CHANNELS)
|
|
354
360
|
: false;
|
|
355
361
|
if (!anim && hasLiveRotationTween) {
|
|
356
|
-
|
|
362
|
+
// Rotation twin of the position case above: live tween, no source match.
|
|
363
|
+
return {
|
|
364
|
+
status: "blocked",
|
|
365
|
+
reason: "source-uneditable",
|
|
366
|
+
detail: "live-rotation-no-source-tween",
|
|
367
|
+
};
|
|
357
368
|
}
|
|
358
369
|
|
|
359
370
|
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
|
@@ -196,6 +196,7 @@ function renderGestureCommit(
|
|
|
196
196
|
previewIframeRef: { current: document.createElement("iframe") },
|
|
197
197
|
showToast: vi.fn(),
|
|
198
198
|
isGestureRecordingRef: { current: false },
|
|
199
|
+
readOnlyPreview: false,
|
|
199
200
|
});
|
|
200
201
|
toggle = handleToggleRecording;
|
|
201
202
|
return null;
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
import { useCallback } from "react";
|
|
10
10
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
11
|
-
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
12
11
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
13
12
|
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
14
13
|
import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
|
|
@@ -286,7 +285,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
286
285
|
groupId: string,
|
|
287
286
|
groupLabel?: string,
|
|
288
287
|
) => Promise<void> {
|
|
289
|
-
const
|
|
288
|
+
const timelineElements = usePlayerStore((state) => state.elements);
|
|
290
289
|
return useCallback(
|
|
291
290
|
async (clipIds: readonly string[], groupId: string, groupLabel?: string) => {
|
|
292
291
|
if (isRecordingRef?.current) {
|
|
@@ -299,7 +298,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
299
298
|
// timeline's group-pointer button) name clips the way the document does,
|
|
300
299
|
// because that is the only space `resolveAudioGroups` reads back.
|
|
301
300
|
const wanted = new Set(clipIds);
|
|
302
|
-
const elements =
|
|
301
|
+
const elements = timelineElements.filter((item) => {
|
|
303
302
|
const domId = runtimeAudioId(item);
|
|
304
303
|
return domId !== null && wanted.has(domId);
|
|
305
304
|
});
|
|
@@ -340,7 +339,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
340
339
|
},
|
|
341
340
|
[
|
|
342
341
|
activeCompPath,
|
|
343
|
-
|
|
342
|
+
timelineElements,
|
|
344
343
|
previewIframeRef,
|
|
345
344
|
writeProjectFile,
|
|
346
345
|
recordEdit,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
3
3
|
import { reseekPreviewAtTime } from "../player/hooks/timelineSyncHydration";
|
|
4
|
-
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
5
4
|
import { applySoftReloadFinalization } from "../utils/gsapSoftReload";
|
|
6
5
|
import {
|
|
7
6
|
timelineTrackOrder,
|
|
@@ -295,7 +294,7 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
295
294
|
// virtual sub-comp children carry their own (display.track + idx) track numbers,
|
|
296
295
|
// so filtering the raw store list by a virtual track number would hide the wrong
|
|
297
296
|
// outer-scene sibling sharing that index.
|
|
298
|
-
const
|
|
297
|
+
const timelineElements = usePlayerStore((state) => state.elements);
|
|
299
298
|
return useCallback(
|
|
300
299
|
async (track: number, hidden: boolean, displayNumber?: number | null) => {
|
|
301
300
|
if (isRecordingRef?.current) {
|
|
@@ -308,7 +307,7 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
308
307
|
await toggleTimelineTrackHidden({
|
|
309
308
|
projectId: pid,
|
|
310
309
|
activeCompPath,
|
|
311
|
-
timelineElements
|
|
310
|
+
timelineElements,
|
|
312
311
|
track,
|
|
313
312
|
hidden,
|
|
314
313
|
displayNumber,
|
|
@@ -327,7 +326,7 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
327
326
|
},
|
|
328
327
|
[
|
|
329
328
|
activeCompPath,
|
|
330
|
-
|
|
329
|
+
timelineElements,
|
|
331
330
|
previewIframeRef,
|
|
332
331
|
writeProjectFile,
|
|
333
332
|
recordEdit,
|
|
@@ -354,7 +353,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
354
353
|
elementKey: string | readonly string[],
|
|
355
354
|
hidden: boolean,
|
|
356
355
|
) => Promise<void> {
|
|
357
|
-
const
|
|
356
|
+
const timelineElements = usePlayerStore((state) => state.elements);
|
|
358
357
|
return useCallback(
|
|
359
358
|
async (elementKey: string | readonly string[], hidden: boolean) => {
|
|
360
359
|
if (isRecordingRef?.current) {
|
|
@@ -364,7 +363,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
364
363
|
const pid = projectIdRef.current;
|
|
365
364
|
if (!pid) return;
|
|
366
365
|
const keys = typeof elementKey === "string" ? [elementKey] : elementKey;
|
|
367
|
-
if (!
|
|
366
|
+
if (!timelineElements.some((item) => keys.includes(item.key ?? item.id))) {
|
|
368
367
|
showToast("This element is inside a sub-composition and has no timeline row to hide.");
|
|
369
368
|
return;
|
|
370
369
|
}
|
|
@@ -372,7 +371,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
372
371
|
await toggleTimelineElementHidden({
|
|
373
372
|
projectId: pid,
|
|
374
373
|
activeCompPath,
|
|
375
|
-
timelineElements
|
|
374
|
+
timelineElements,
|
|
376
375
|
elementKey,
|
|
377
376
|
hidden,
|
|
378
377
|
previewIframe: previewIframeRef.current,
|
|
@@ -390,7 +389,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
390
389
|
},
|
|
391
390
|
[
|
|
392
391
|
activeCompPath,
|
|
393
|
-
|
|
392
|
+
timelineElements,
|
|
394
393
|
previewIframeRef,
|
|
395
394
|
writeProjectFile,
|
|
396
395
|
recordEdit,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { dispatchModifierKey, dispatchPlainKey } from "./appHotkeysDispatch";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { dispatchModifierKey, dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
|
|
4
4
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
6
|
import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
|
|
6
7
|
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
7
8
|
import type { TimelineElement } from "../player/store/timelineElement";
|
|
@@ -19,7 +20,7 @@ const bgmElement: TimelineElement = {
|
|
|
19
20
|
/** Every callback dispatchPlainKey can reach, so a test can assert which one
|
|
20
21
|
* a key resolved to. Unannotated on purpose: the parameter type is not
|
|
21
22
|
* exported, and structural inference checks it at the call site. */
|
|
22
|
-
function callbacks() {
|
|
23
|
+
function callbacks(overrides: Partial<HotkeyCallbacks> = {}) {
|
|
23
24
|
return {
|
|
24
25
|
handleTimelineElementDelete: vi.fn(async () => {}),
|
|
25
26
|
handleTimelineElementsDelete: vi.fn(async () => {}),
|
|
@@ -30,10 +31,15 @@ function callbacks() {
|
|
|
30
31
|
handleCopy: vi.fn(() => false),
|
|
31
32
|
handlePaste: vi.fn(async () => {}),
|
|
32
33
|
handleCut: vi.fn(async () => false),
|
|
34
|
+
handleDuplicate: vi.fn(async () => false),
|
|
35
|
+
onGroupSelection: vi.fn(),
|
|
36
|
+
onUngroupSelection: vi.fn(),
|
|
33
37
|
onResetKeyframes: vi.fn(() => true),
|
|
34
38
|
onDeleteSelectedKeyframes: vi.fn(),
|
|
35
39
|
showToast: vi.fn(),
|
|
36
40
|
domEditSelectionRef: { current: null },
|
|
41
|
+
readOnlyPreview: false,
|
|
42
|
+
...overrides,
|
|
37
43
|
};
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -298,3 +304,65 @@ describe('dispatchPlainKey — "A" returns to select while the razor is armed',
|
|
|
298
304
|
expect(e.defaultPrevented).toBe(false);
|
|
299
305
|
});
|
|
300
306
|
});
|
|
307
|
+
|
|
308
|
+
describe("hotkeys with the preview read-only", () => {
|
|
309
|
+
beforeEach(() => {
|
|
310
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: null });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("does not delete the selected element on Delete", () => {
|
|
314
|
+
const cb = callbacks({ readOnlyPreview: true });
|
|
315
|
+
cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
|
|
316
|
+
dispatchPlainKey(press("Delete"), "delete", cb);
|
|
317
|
+
expect(cb.handleDomEditElementDelete).not.toHaveBeenCalled();
|
|
318
|
+
expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("does not split on s", () => {
|
|
322
|
+
usePlayerStore.setState({
|
|
323
|
+
currentTime: 3,
|
|
324
|
+
elements: [{ ...bgmElement, hfId: "hf-bgm" }],
|
|
325
|
+
selectedElementId: "bgm",
|
|
326
|
+
});
|
|
327
|
+
const cb = callbacks({ readOnlyPreview: true });
|
|
328
|
+
dispatchPlainKey(press("s"), "s", cb);
|
|
329
|
+
expect(cb.handleTimelineElementSplit).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it.each([
|
|
333
|
+
["x", "handleCut"],
|
|
334
|
+
["d", "handleDuplicate"],
|
|
335
|
+
["v", "handlePaste"],
|
|
336
|
+
["g", "onGroupSelection"],
|
|
337
|
+
] as const)("does not run %s", (key, callback) => {
|
|
338
|
+
const cb = callbacks({ readOnlyPreview: true });
|
|
339
|
+
cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
|
|
340
|
+
dispatchModifierKey(chord(key), key, cb);
|
|
341
|
+
expect(cb[callback]).not.toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("still undoes, because history covers timeline edits", () => {
|
|
345
|
+
const cb = callbacks({ readOnlyPreview: true });
|
|
346
|
+
dispatchModifierKey(chord("z"), "z", cb);
|
|
347
|
+
expect(cb.handleUndo).toHaveBeenCalledTimes(1);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("keeps timeline paste when the mirrored preview selection is not the owner", () => {
|
|
351
|
+
usePlayerStore.setState({ selectedElementId: "bgm" });
|
|
352
|
+
const cb = callbacks({ readOnlyPreview: true });
|
|
353
|
+
cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
|
|
354
|
+
const event = chord("v");
|
|
355
|
+
const timeline = document.createElement("div");
|
|
356
|
+
timeline.dataset.studioTimeline = "true";
|
|
357
|
+
Object.defineProperty(event, "target", { value: timeline });
|
|
358
|
+
dispatchModifierKey(event, "v", cb);
|
|
359
|
+
expect(cb.handlePaste).toHaveBeenCalledTimes(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("control: with the flag off Delete removes the selected element", () => {
|
|
363
|
+
const cb = callbacks();
|
|
364
|
+
cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
|
|
365
|
+
dispatchPlainKey(press("Delete"), "delete", cb);
|
|
366
|
+
expect(cb.handleDomEditElementDelete).toHaveBeenCalledTimes(1);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -101,6 +101,8 @@ interface UseAppHotkeysParams {
|
|
|
101
101
|
onUngroupSelection?: () => void;
|
|
102
102
|
/** Active composition path — used to decide whether undo/redo must resync the SDK session. */
|
|
103
103
|
activeCompPath?: string | null;
|
|
104
|
+
/** Clicks still select and report; the preview cannot move, edit or delete anything. */
|
|
105
|
+
readOnlyPreview: boolean;
|
|
104
106
|
/**
|
|
105
107
|
* Force-reload the SDK session after undo/redo reverts the active comp file,
|
|
106
108
|
* bypassing the self-write suppress window. Without this, the suppress window
|
|
@@ -135,6 +137,7 @@ export function useAppHotkeys({
|
|
|
135
137
|
onUngroupSelection,
|
|
136
138
|
activeCompPath,
|
|
137
139
|
forceReloadSdkSession,
|
|
140
|
+
readOnlyPreview,
|
|
138
141
|
}: UseAppHotkeysParams) {
|
|
139
142
|
const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
|
|
140
143
|
|
|
@@ -203,6 +206,7 @@ export function useAppHotkeys({
|
|
|
203
206
|
onUngroupSelection,
|
|
204
207
|
domEditSelectionRef,
|
|
205
208
|
showToast,
|
|
209
|
+
readOnlyPreview,
|
|
206
210
|
};
|
|
207
211
|
|
|
208
212
|
// ── Keydown dispatch ──
|
|
@@ -89,6 +89,7 @@ export interface UseDomEditCommitsParams {
|
|
|
89
89
|
) => Promise<CutoverResult>;
|
|
90
90
|
/** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
|
|
91
91
|
onReorderShadow?: (targets: string[]) => void;
|
|
92
|
+
readOnlyPreview: boolean;
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
export function useDomEditCommits({
|
|
@@ -112,6 +113,7 @@ export function useDomEditCommits({
|
|
|
112
113
|
onTrySdkPersist,
|
|
113
114
|
onTrySdkDelete,
|
|
114
115
|
onReorderShadow,
|
|
116
|
+
readOnlyPreview,
|
|
115
117
|
}: UseDomEditCommitsParams) {
|
|
116
118
|
const resolveImportedFontAsset = useCallback(
|
|
117
119
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
@@ -452,6 +454,7 @@ export function useDomEditCommits({
|
|
|
452
454
|
previewIframeRef,
|
|
453
455
|
showToast,
|
|
454
456
|
commitPositionPatchToHtml,
|
|
457
|
+
readOnlyPreview,
|
|
455
458
|
});
|
|
456
459
|
|
|
457
460
|
// ── Element lifecycle (delete, z-index reorder) ──
|
|
@@ -58,6 +58,7 @@ export interface UseDomEditSessionParams extends DomEditTimelineParams {
|
|
|
58
58
|
/** The timeline context menu's delete op — a canvas selection that IS a
|
|
59
59
|
* timeline row hands off here instead of the REST remove-elements path. */
|
|
60
60
|
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
61
|
+
readOnlyPreview: boolean;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export function useDomEditSession({
|
|
@@ -98,8 +99,10 @@ export function useDomEditSession({
|
|
|
98
99
|
publishSdkSession,
|
|
99
100
|
forceReloadSdkSession,
|
|
100
101
|
handleTimelineElementsDelete,
|
|
102
|
+
readOnlyPreview,
|
|
101
103
|
}: UseDomEditSessionParams) {
|
|
102
104
|
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
105
|
+
const previewCaptionEditMode = captionEditMode && !readOnlyPreview;
|
|
103
106
|
void _setRefreshKey;
|
|
104
107
|
const {
|
|
105
108
|
domEditSelection,
|
|
@@ -125,7 +128,7 @@ export function useDomEditSession({
|
|
|
125
128
|
activeCompPath,
|
|
126
129
|
isMasterView,
|
|
127
130
|
compIdToSrc,
|
|
128
|
-
captionEditMode,
|
|
131
|
+
captionEditMode: previewCaptionEditMode,
|
|
129
132
|
previewIframeRef,
|
|
130
133
|
timelineElements,
|
|
131
134
|
getTimelineSelectionSet,
|
|
@@ -247,6 +250,7 @@ export function useDomEditSession({
|
|
|
247
250
|
refreshDomEditSelectionFromPreview,
|
|
248
251
|
buildDomSelectionFromTarget,
|
|
249
252
|
forceReloadSdkSession,
|
|
253
|
+
readOnlyPreview,
|
|
250
254
|
onTrySdkPersist: sdkSession
|
|
251
255
|
? (selection, operations, originalContent, targetPath, options) => {
|
|
252
256
|
// Decoupled tripwire, runs regardless of the cutover flag. originalContent lets
|
|
@@ -405,7 +409,6 @@ export function useDomEditSession({
|
|
|
405
409
|
handleGsapRemoveAllKeyframes,
|
|
406
410
|
handleResetSelectedElementKeyframes,
|
|
407
411
|
} = useDomEditWiring({
|
|
408
|
-
// fallow-ignore-next-line code-duplication
|
|
409
412
|
projectId,
|
|
410
413
|
activeCompPath,
|
|
411
414
|
domEditSelection,
|
|
@@ -414,7 +417,7 @@ export function useDomEditSession({
|
|
|
414
417
|
refreshDomEditGroupSelectionsFromPreview,
|
|
415
418
|
previewIframeRef,
|
|
416
419
|
previewIframe,
|
|
417
|
-
captionEditMode,
|
|
420
|
+
captionEditMode: previewCaptionEditMode,
|
|
418
421
|
refreshKey,
|
|
419
422
|
gsapCacheVersion,
|
|
420
423
|
bumpGsapCache,
|
|
@@ -451,7 +454,7 @@ export function useDomEditSession({
|
|
|
451
454
|
handleBlockedDomMove,
|
|
452
455
|
handleDomManualDragStart,
|
|
453
456
|
} = usePreviewInteraction({
|
|
454
|
-
captionEditMode,
|
|
457
|
+
captionEditMode: previewCaptionEditMode,
|
|
455
458
|
compositionLoading,
|
|
456
459
|
previewIframeRef,
|
|
457
460
|
showToast,
|