@hyperframes/studio 0.8.53-desktop.0 → 0.8.54
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-PTEnAHhg.js → hyperframes-player-Cy_AwUa8.js} +1 -1
- package/dist/assets/{index-BjslOS9g.js → index-CTa-6Kso.js} +1 -1
- package/dist/assets/{index-CNyviK_l.js → index-DPUJoO8n.js} +203 -203
- package/dist/assets/{index--myXp8ui.js → index-DkAzKuPZ.js} +1 -1
- package/dist/assets/{index-BnXZuIJD.css → index-DrHQAuGO.css} +1 -1
- package/dist/index.d.ts +43 -8
- package/dist/index.html +2 -2
- package/dist/index.js +7896 -7717
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -0
- package/package.json +8 -7
- package/src/components/StudioHeader.tsx +1 -1
- package/src/components/nle/TimelinePane.tsx +4 -1
- package/src/components/renders/RenderQueue.test.tsx +133 -25
- package/src/components/renders/RenderQueue.tsx +99 -92
- package/src/components/renders/RenderQueueItem.tsx +69 -64
- package/src/components/ui/Select.tsx +4 -0
- package/src/headerExports.test.tsx +23 -0
- package/src/hooks/timelineAudioGroupCreate.ts +17 -1
- package/src/hooks/timelineAudioGroupVolume.ts +35 -2
- package/src/hooks/timelineElementFxAttribute.ts +34 -2
- package/src/hooks/useRightPanelIntents.ts +2 -1
- package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
- package/src/hooks/useTimelineAssetDropOps.ts +40 -3
- package/src/hooks/useTimelineEditing.test.tsx +160 -2
- package/src/hooks/useTimelineEditing.ts +76 -39
- package/src/index.ts +5 -2
- package/src/main.tsx +26 -0
- package/src/player/components/Timeline.tsx +6 -6
- package/src/player/components/TimelineTrackHeader.test.tsx +5 -26
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/TimelineTypes.ts +3 -0
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +30 -30
- package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
- package/src/player/components/usePublishRangeSelection.ts +42 -0
- package/src/player/index.ts +1 -0
- package/src/player/store/playerStore.ts +8 -51
- package/src/player/store/rangeSelectionSlice.ts +13 -0
- package/src/player/store/timelineResetState.ts +51 -0
- package/src/styles/hex-baseline.json +2 -2
- package/src/styles/theme.css +0 -27
|
@@ -2,6 +2,8 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
|
2
2
|
import { memo, useCallback, useState } from "react";
|
|
3
3
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
4
4
|
import { Button } from "../ui/Button";
|
|
5
|
+
import { IconButton } from "../ui/IconButton";
|
|
6
|
+
import { Tooltip } from "../ui/Tooltip";
|
|
5
7
|
import type { RenderJob } from "./useRenderQueue";
|
|
6
8
|
|
|
7
9
|
interface RenderQueueItemProps {
|
|
@@ -66,7 +68,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
66
68
|
setVideoReady(false);
|
|
67
69
|
setConfirmingDelete(false);
|
|
68
70
|
}}
|
|
69
|
-
className="px-3 py-2.5 border-b border-
|
|
71
|
+
className="px-3 py-2.5 border-b border-border last:border-0 transition-colors duration-150 hover:bg-hover/30"
|
|
70
72
|
>
|
|
71
73
|
<div className="flex items-center gap-2.5">
|
|
72
74
|
{/* Thumbnail — static frame; swaps to live video on hover.
|
|
@@ -77,8 +79,8 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
77
79
|
disabled={!isComplete}
|
|
78
80
|
aria-label={isComplete ? `Open ${job.filename} in a new tab` : undefined}
|
|
79
81
|
className={[
|
|
80
|
-
"w-20 h-[45px] rounded-md overflow-hidden bg-
|
|
81
|
-
"outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-
|
|
82
|
+
"w-20 h-[45px] rounded-md overflow-hidden bg-input shrink-0 relative",
|
|
83
|
+
"outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent",
|
|
82
84
|
isComplete ? "cursor-pointer" : "cursor-default",
|
|
83
85
|
].join(" ")}
|
|
84
86
|
>
|
|
@@ -108,17 +110,17 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
108
110
|
)}
|
|
109
111
|
{isRendering && (
|
|
110
112
|
<div className="w-full h-full flex items-center justify-center">
|
|
111
|
-
<div className="w-2 h-2 rounded-full bg-
|
|
113
|
+
<div className="w-2 h-2 rounded-full bg-accent animate-pulse motion-reduce:animate-none" />
|
|
112
114
|
</div>
|
|
113
115
|
)}
|
|
114
116
|
{job.status === "failed" && (
|
|
115
117
|
<div className="w-full h-full flex items-center justify-center">
|
|
116
|
-
<div className="w-2 h-2 rounded-full bg-
|
|
118
|
+
<div className="w-2 h-2 rounded-full bg-danger" />
|
|
117
119
|
</div>
|
|
118
120
|
)}
|
|
119
121
|
{job.status === "cancelled" && (
|
|
120
122
|
<div className="w-full h-full flex items-center justify-center">
|
|
121
|
-
<div className="w-2 h-2 rounded-full bg-
|
|
123
|
+
<div className="w-2 h-2 rounded-full bg-text-5" />
|
|
122
124
|
</div>
|
|
123
125
|
)}
|
|
124
126
|
</button>
|
|
@@ -126,11 +128,9 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
126
128
|
{/* Info */}
|
|
127
129
|
<div className="flex-1 min-w-0">
|
|
128
130
|
<div className="flex items-center gap-1.5">
|
|
129
|
-
<span className="text-
|
|
130
|
-
{job.filename}
|
|
131
|
-
</span>
|
|
131
|
+
<span className="text-step-11 font-medium text-text-2 truncate">{job.filename}</span>
|
|
132
132
|
{job.durationMs && (
|
|
133
|
-
<span className="text-
|
|
133
|
+
<span className="text-step-9 text-text-5 shrink-0">
|
|
134
134
|
{formatDuration(job.durationMs)}
|
|
135
135
|
</span>
|
|
136
136
|
)}
|
|
@@ -139,11 +139,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
139
139
|
{isRendering && (
|
|
140
140
|
<div className="mt-1">
|
|
141
141
|
<div className="flex items-center justify-between mb-0.5">
|
|
142
|
-
<span className="text-
|
|
143
|
-
<span className="text-
|
|
142
|
+
<span className="text-step-9 text-text-4">{job.stage || "Rendering"}</span>
|
|
143
|
+
<span className="text-step-9 font-mono text-accent">{job.progress}%</span>
|
|
144
144
|
</div>
|
|
145
145
|
<div
|
|
146
|
-
className="w-full h-1 bg-
|
|
146
|
+
className="w-full h-1 bg-border rounded-full overflow-hidden"
|
|
147
147
|
role="progressbar"
|
|
148
148
|
aria-valuenow={job.progress}
|
|
149
149
|
aria-valuemin={0}
|
|
@@ -151,7 +151,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
151
151
|
aria-label={`Render progress: ${job.progress}%`}
|
|
152
152
|
>
|
|
153
153
|
<div
|
|
154
|
-
className="h-full bg-
|
|
154
|
+
className="h-full bg-accent rounded-full transition-all duration-300"
|
|
155
155
|
style={{ width: `${job.progress}%` }}
|
|
156
156
|
/>
|
|
157
157
|
</div>
|
|
@@ -159,14 +159,14 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
159
159
|
)}
|
|
160
160
|
|
|
161
161
|
{job.status === "failed" && job.error && (
|
|
162
|
-
<span className="text-
|
|
162
|
+
<span className="text-step-9 text-danger mt-0.5 block">{job.error}</span>
|
|
163
163
|
)}
|
|
164
164
|
{job.status === "cancelled" && (
|
|
165
|
-
<span className="text-
|
|
165
|
+
<span className="text-step-9 text-text-4 mt-0.5 block">Cancelled</span>
|
|
166
166
|
)}
|
|
167
167
|
|
|
168
168
|
{!isRendering && (
|
|
169
|
-
<span className="text-
|
|
169
|
+
<span className="text-step-9 text-text-5">{formatTimeAgo(job.createdAt)}</span>
|
|
170
170
|
)}
|
|
171
171
|
</div>
|
|
172
172
|
|
|
@@ -209,53 +209,58 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
209
209
|
</>
|
|
210
210
|
) : (
|
|
211
211
|
<>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
212
|
+
{/* The tooltip explains the control whether or not it is
|
|
213
|
+
reachable; `disabled:` on the shared Button keeps pointer
|
|
214
|
+
events alive precisely so a disabled one can still say why. */}
|
|
215
|
+
<Tooltip label={isComplete ? "Download" : "Available when the render finishes"}>
|
|
216
|
+
<IconButton
|
|
217
|
+
size="sm"
|
|
218
|
+
onClick={handleDownload}
|
|
219
|
+
disabled={!isComplete}
|
|
220
|
+
aria-label={`Download ${job.filename}`}
|
|
221
|
+
icon={
|
|
222
|
+
<svg
|
|
223
|
+
width="12"
|
|
224
|
+
height="12"
|
|
225
|
+
viewBox="0 0 24 24"
|
|
226
|
+
fill="none"
|
|
227
|
+
stroke="currentColor"
|
|
228
|
+
strokeWidth="2"
|
|
229
|
+
strokeLinecap="round"
|
|
230
|
+
strokeLinejoin="round"
|
|
231
|
+
aria-hidden="true"
|
|
232
|
+
>
|
|
233
|
+
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
|
234
|
+
<polyline points="7 10 12 15 17 10" />
|
|
235
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
236
|
+
</svg>
|
|
237
|
+
}
|
|
238
|
+
/>
|
|
239
|
+
</Tooltip>
|
|
240
|
+
<Tooltip label="Delete render file">
|
|
241
|
+
<IconButton
|
|
242
|
+
size="sm"
|
|
243
|
+
onClick={(e) => {
|
|
244
|
+
e.stopPropagation();
|
|
245
|
+
setConfirmingDelete(true);
|
|
246
|
+
}}
|
|
247
|
+
aria-label={`Delete ${job.filename}`}
|
|
248
|
+
icon={
|
|
249
|
+
<svg
|
|
250
|
+
width="12"
|
|
251
|
+
height="12"
|
|
252
|
+
viewBox="0 0 24 24"
|
|
253
|
+
fill="none"
|
|
254
|
+
stroke="currentColor"
|
|
255
|
+
strokeWidth="2"
|
|
256
|
+
strokeLinecap="round"
|
|
257
|
+
aria-hidden="true"
|
|
258
|
+
>
|
|
259
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
260
|
+
</svg>
|
|
261
|
+
}
|
|
262
|
+
/>
|
|
263
|
+
</Tooltip>
|
|
259
264
|
</>
|
|
260
265
|
)}
|
|
261
266
|
</div>
|
|
@@ -11,6 +11,8 @@ import type { PreviewState } from "./Button";
|
|
|
11
11
|
export interface SelectOption {
|
|
12
12
|
label: string;
|
|
13
13
|
value: string;
|
|
14
|
+
/** Offered but not choosable; the label stays visible so the reason stays visible too. */
|
|
15
|
+
disabled?: boolean;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface SelectProps {
|
|
@@ -81,11 +83,13 @@ export function Select({
|
|
|
81
83
|
<BaseSelect.Item
|
|
82
84
|
key={option.value}
|
|
83
85
|
value={option.value}
|
|
86
|
+
disabled={option.disabled}
|
|
84
87
|
className={cn(
|
|
85
88
|
"flex h-ctl-sm cursor-pointer select-none items-center gap-2 px-2.5",
|
|
86
89
|
"text-step-11 text-text-2 outline-hidden",
|
|
87
90
|
"data-[highlighted]:bg-hover data-[highlighted]:text-text-0",
|
|
88
91
|
"data-[selected]:text-text-0",
|
|
92
|
+
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40",
|
|
89
93
|
)}
|
|
90
94
|
>
|
|
91
95
|
<BaseSelect.ItemText className="truncate">{option.label}</BaseSelect.ItemText>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Imports the header logo and button size classes the way a host app does: by package name.
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { HyperframesLogo, buttonSizes } from "@hyperframes/studio";
|
|
7
|
+
|
|
8
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
9
|
+
|
|
10
|
+
describe("header package exports", () => {
|
|
11
|
+
it("mounts the logo mark", async () => {
|
|
12
|
+
const el = document.createElement("div");
|
|
13
|
+
document.body.append(el);
|
|
14
|
+
const root = createRoot(el);
|
|
15
|
+
await act(async () => root.render(<HyperframesLogo />));
|
|
16
|
+
expect(el.querySelector("svg")).not.toBeNull();
|
|
17
|
+
await act(async () => root.unmount());
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("exposes the button size classes a host reuses for matching chrome", () => {
|
|
21
|
+
expect(buttonSizes.md).toEqual(expect.any(String));
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -28,6 +28,19 @@ import {
|
|
|
28
28
|
type UseTimelineElementVisibilityEditingInput,
|
|
29
29
|
} from "./timelineTrackVisibility";
|
|
30
30
|
|
|
31
|
+
interface AudioGroupCarveInput extends UseTimelineElementVisibilityEditingInput {
|
|
32
|
+
checkEditable?: (elements: readonly TimelineElement[]) => boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertAudioGroupEditable(
|
|
36
|
+
checkEditable: AudioGroupCarveInput["checkEditable"],
|
|
37
|
+
elements: readonly TimelineElement[],
|
|
38
|
+
): void {
|
|
39
|
+
if (checkEditable && !checkEditable(elements)) {
|
|
40
|
+
throw new Error("Timeline edit blocked");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
/**
|
|
32
45
|
* Assign (or restore) `data-audio-group` across a set of members.
|
|
33
46
|
*
|
|
@@ -267,7 +280,8 @@ export function useAudioGroupCarveAssignment({
|
|
|
267
280
|
previewIframeRef,
|
|
268
281
|
pendingTimelineEditPathRef,
|
|
269
282
|
isRecordingRef,
|
|
270
|
-
|
|
283
|
+
checkEditable,
|
|
284
|
+
}: AudioGroupCarveInput): (
|
|
271
285
|
clipIds: readonly string[],
|
|
272
286
|
groupId: string,
|
|
273
287
|
groupLabel?: string,
|
|
@@ -289,6 +303,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
289
303
|
const domId = runtimeAudioId(item);
|
|
290
304
|
return domId !== null && wanted.has(domId);
|
|
291
305
|
});
|
|
306
|
+
if (elements.length === wanted.size) assertAudioGroupEditable(checkEditable, elements);
|
|
292
307
|
try {
|
|
293
308
|
// Loud, not silent: an unresolved id used to leave `elements` short,
|
|
294
309
|
// `createAudioGroupAndAssignMembers` returning early with no write, and
|
|
@@ -333,6 +348,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
333
348
|
isRecordingRef,
|
|
334
349
|
showToast,
|
|
335
350
|
projectIdRef,
|
|
351
|
+
checkEditable,
|
|
336
352
|
],
|
|
337
353
|
);
|
|
338
354
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback } from "react";
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
2
|
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
3
3
|
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
4
4
|
import { usePlayerStore } from "../player";
|
|
@@ -29,6 +29,10 @@ function patchLiveGroupAttribute(
|
|
|
29
29
|
invalidateGroupInfoCache(iframe?.contentDocument);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function audioGroupAttributeLiveKey(groupId: string, attr: string): string {
|
|
33
|
+
return `${groupId}\0${attr}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
32
36
|
/**
|
|
33
37
|
* `data-volume` exactly as core reads it (`readAudioGroupVolume`): an absent or
|
|
34
38
|
* empty attribute is UNITY, not zero.
|
|
@@ -210,9 +214,16 @@ export function useSetAudioGroupAttribute({
|
|
|
210
214
|
}: UseTimelineElementVisibilityEditingInput): {
|
|
211
215
|
setLive: (groupId: string, attr: string, value: string | null) => void;
|
|
212
216
|
setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
|
|
217
|
+
revertLive: (groupId: string, attr: string) => void;
|
|
213
218
|
} {
|
|
219
|
+
const liveBeforeRef = useRef(new Map<string, string | null>());
|
|
214
220
|
const setLive = useCallback(
|
|
215
221
|
(groupId: string, attr: string, value: string | null) => {
|
|
222
|
+
const key = audioGroupAttributeLiveKey(groupId, attr);
|
|
223
|
+
const target = previewIframeRef.current?.contentDocument?.getElementById(groupId);
|
|
224
|
+
if (!liveBeforeRef.current.has(key)) {
|
|
225
|
+
liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
|
|
226
|
+
}
|
|
216
227
|
patchLiveGroupAttribute(previewIframeRef.current, groupId, attr, value);
|
|
217
228
|
// Live too, not just on commit: a fader drag is `setLive` per frame and
|
|
218
229
|
// `setQuiet` once on release, so without this the strip's own readout
|
|
@@ -221,6 +232,26 @@ export function useSetAudioGroupAttribute({
|
|
|
221
232
|
},
|
|
222
233
|
[previewIframeRef],
|
|
223
234
|
);
|
|
235
|
+
const revertLive = useCallback(
|
|
236
|
+
(groupId: string, attr: string) => {
|
|
237
|
+
const key = audioGroupAttributeLiveKey(groupId, attr);
|
|
238
|
+
if (!liveBeforeRef.current.has(key)) return;
|
|
239
|
+
patchLiveGroupAttribute(
|
|
240
|
+
previewIframeRef.current,
|
|
241
|
+
groupId,
|
|
242
|
+
attr,
|
|
243
|
+
liveBeforeRef.current.get(key) ?? null,
|
|
244
|
+
);
|
|
245
|
+
syncStoredGroupAttribute(
|
|
246
|
+
groupId,
|
|
247
|
+
attr,
|
|
248
|
+
previewIframeRef.current?.contentDocument?.getElementById(groupId)?.getAttribute(attr) ??
|
|
249
|
+
null,
|
|
250
|
+
);
|
|
251
|
+
liveBeforeRef.current.delete(key);
|
|
252
|
+
},
|
|
253
|
+
[previewIframeRef],
|
|
254
|
+
);
|
|
224
255
|
const setQuiet = useCallback(
|
|
225
256
|
async (groupId: string, attr: string, value: string | null, label: string) => {
|
|
226
257
|
if (isRecordingRef?.current) {
|
|
@@ -242,6 +273,7 @@ export function useSetAudioGroupAttribute({
|
|
|
242
273
|
recordEdit,
|
|
243
274
|
pendingTimelineEditPathRef,
|
|
244
275
|
});
|
|
276
|
+
liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
|
|
245
277
|
syncStoredGroupAttribute(groupId, attr, value);
|
|
246
278
|
} catch (error) {
|
|
247
279
|
// `persistElementAttribute` leaves the live DOM at the previous value
|
|
@@ -257,6 +289,7 @@ export function useSetAudioGroupAttribute({
|
|
|
257
289
|
console.error("[Timeline] Failed to set group attribute", error);
|
|
258
290
|
const message = error instanceof Error ? error.message : "Failed to update group";
|
|
259
291
|
showToast(message);
|
|
292
|
+
liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
|
|
260
293
|
}
|
|
261
294
|
},
|
|
262
295
|
[
|
|
@@ -270,5 +303,5 @@ export function useSetAudioGroupAttribute({
|
|
|
270
303
|
projectIdRef,
|
|
271
304
|
],
|
|
272
305
|
);
|
|
273
|
-
return { setLive, setQuiet };
|
|
306
|
+
return { setLive, setQuiet, revertLive };
|
|
274
307
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* shared core `setAudioGroupAttribute` also uses.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { useCallback } from "react";
|
|
10
|
+
import { useCallback, useRef } from "react";
|
|
11
11
|
import type { TimelineElement } from "../player";
|
|
12
12
|
import {
|
|
13
13
|
buildPatchTarget,
|
|
@@ -32,6 +32,14 @@ function patchLiveElementAttribute(
|
|
|
32
32
|
else target.setAttribute(attr, value);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function elementAttributeLiveKey(
|
|
36
|
+
element: TimelineElement,
|
|
37
|
+
activeCompPath: string | null,
|
|
38
|
+
attr: string,
|
|
39
|
+
): string {
|
|
40
|
+
return `${element.sourceFile || activeCompPath || "index.html"}\0${element.key ?? element.domId ?? element.id}\0${attr}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
interface SetElementAttributeInput {
|
|
36
44
|
projectId: string;
|
|
37
45
|
activeCompPath: string | null;
|
|
@@ -92,13 +100,35 @@ export function useSetElementAttribute({
|
|
|
92
100
|
value: string | null,
|
|
93
101
|
label: string,
|
|
94
102
|
) => Promise<void>;
|
|
103
|
+
revertLive: (element: TimelineElement, attr: string) => void;
|
|
95
104
|
} {
|
|
105
|
+
const liveBeforeRef = useRef(new Map<string, string | null>());
|
|
96
106
|
const setLive = useCallback(
|
|
97
107
|
(element: TimelineElement, attr: string, value: string | null) => {
|
|
108
|
+
const key = elementAttributeLiveKey(element, activeCompPath, attr);
|
|
109
|
+
const target = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
|
|
110
|
+
if (!liveBeforeRef.current.has(key)) {
|
|
111
|
+
liveBeforeRef.current.set(key, target?.getAttribute(attr) ?? null);
|
|
112
|
+
}
|
|
98
113
|
patchLiveElementAttribute(previewIframeRef.current, element, attr, value, activeCompPath);
|
|
99
114
|
},
|
|
100
115
|
[previewIframeRef, activeCompPath],
|
|
101
116
|
);
|
|
117
|
+
const revertLive = useCallback(
|
|
118
|
+
(element: TimelineElement, attr: string) => {
|
|
119
|
+
const key = elementAttributeLiveKey(element, activeCompPath, attr);
|
|
120
|
+
if (!liveBeforeRef.current.has(key)) return;
|
|
121
|
+
patchLiveElementAttribute(
|
|
122
|
+
previewIframeRef.current,
|
|
123
|
+
element,
|
|
124
|
+
attr,
|
|
125
|
+
liveBeforeRef.current.get(key) ?? null,
|
|
126
|
+
activeCompPath,
|
|
127
|
+
);
|
|
128
|
+
liveBeforeRef.current.delete(key);
|
|
129
|
+
},
|
|
130
|
+
[previewIframeRef, activeCompPath],
|
|
131
|
+
);
|
|
102
132
|
const setQuiet = useCallback(
|
|
103
133
|
async (element: TimelineElement, attr: string, value: string | null, label: string) => {
|
|
104
134
|
if (isRecordingRef?.current) {
|
|
@@ -120,10 +150,12 @@ export function useSetElementAttribute({
|
|
|
120
150
|
recordEdit,
|
|
121
151
|
pendingTimelineEditPathRef,
|
|
122
152
|
});
|
|
153
|
+
liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
|
|
123
154
|
} catch (error) {
|
|
124
155
|
console.error("[Timeline] Failed to set element attribute", error);
|
|
125
156
|
const message = error instanceof Error ? error.message : "Failed to update effect";
|
|
126
157
|
showToast(message);
|
|
158
|
+
liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
|
|
127
159
|
}
|
|
128
160
|
},
|
|
129
161
|
[
|
|
@@ -137,5 +169,5 @@ export function useSetElementAttribute({
|
|
|
137
169
|
projectIdRef,
|
|
138
170
|
],
|
|
139
171
|
);
|
|
140
|
-
return { setLive, setQuiet };
|
|
172
|
+
return { setLive, setQuiet, revertLive };
|
|
141
173
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { useDockLayoutStore, visiblePanelInZone } from "../components/dock/dockLayoutStore";
|
|
3
3
|
|
|
4
|
-
/** Opens the Slideshow panel when the file becomes a slideshow
|
|
4
|
+
/** Opens the Slideshow panel when the file becomes a slideshow, closes it when it stops being one.
|
|
5
|
+
* A user's own close, and a restored layout's placement, stick. */
|
|
5
6
|
export function useSlideshowDockPanel(isSlideshowComposition: boolean) {
|
|
6
7
|
const controller = useDockLayoutStore((state) => state.controller);
|
|
7
8
|
const wasSlideshow = useRef(false);
|
|
@@ -16,19 +16,29 @@ afterEach(() => {
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDrop"];
|
|
19
|
+
type FileDropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineFileDrop"];
|
|
20
|
+
type CompositionDropFn = ReturnType<
|
|
21
|
+
typeof useTimelineAssetDropOps
|
|
22
|
+
>["handleTimelineCompositionDrop"];
|
|
19
23
|
|
|
20
24
|
function renderDropHook(
|
|
21
25
|
sourceContent: string,
|
|
22
26
|
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
|
23
27
|
timelineElements: TimelineElement[] = [],
|
|
28
|
+
checkEditable?: (targets: readonly TimelineElement[]) => boolean,
|
|
29
|
+
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]> = vi
|
|
30
|
+
.fn()
|
|
31
|
+
.mockResolvedValue([]),
|
|
24
32
|
) {
|
|
25
33
|
vi.stubGlobal(
|
|
26
34
|
"fetch",
|
|
27
35
|
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ content: sourceContent }) }),
|
|
28
36
|
);
|
|
29
37
|
let drop: DropFn | null = null;
|
|
38
|
+
let fileDrop: FileDropFn | null = null;
|
|
39
|
+
let compositionDrop: CompositionDropFn | null = null;
|
|
30
40
|
function Harness() {
|
|
31
|
-
const
|
|
41
|
+
const handlers = useTimelineAssetDropOps({
|
|
32
42
|
projectIdRef: { current: "project" },
|
|
33
43
|
activeCompPath: "index.html",
|
|
34
44
|
timelineElements,
|
|
@@ -36,13 +46,19 @@ function renderDropHook(
|
|
|
36
46
|
writeProjectFile,
|
|
37
47
|
recordEdit: vi.fn().mockResolvedValue(undefined),
|
|
38
48
|
reloadPreview: vi.fn(),
|
|
39
|
-
uploadProjectFiles
|
|
49
|
+
uploadProjectFiles,
|
|
50
|
+
checkEditable,
|
|
40
51
|
});
|
|
41
|
-
drop = handleTimelineAssetDrop;
|
|
52
|
+
drop = handlers.handleTimelineAssetDrop;
|
|
53
|
+
fileDrop = handlers.handleTimelineFileDrop;
|
|
54
|
+
compositionDrop = handlers.handleTimelineCompositionDrop;
|
|
42
55
|
return null;
|
|
43
56
|
}
|
|
44
57
|
mountReactHarness(<Harness />);
|
|
45
|
-
return () => drop
|
|
58
|
+
return Object.assign(() => drop!, {
|
|
59
|
+
file: () => fileDrop!,
|
|
60
|
+
composition: () => compositionDrop!,
|
|
61
|
+
});
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
@@ -124,4 +140,46 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
124
140
|
expect(written).toContain('id="hidden" data-start="0" data-track-index="1"');
|
|
125
141
|
expect(written).toMatch(/<video id="clip"[^>]*data-track-index="1"/);
|
|
126
142
|
});
|
|
143
|
+
|
|
144
|
+
it("refuses an asset drop before reading or writing when editing is blocked", async () => {
|
|
145
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
146
|
+
const checkEditable = vi.fn(() => false);
|
|
147
|
+
const getDrop = renderDropHook(
|
|
148
|
+
'<main data-composition-id="scene" data-duration="10"></main>',
|
|
149
|
+
writeProjectFile,
|
|
150
|
+
[],
|
|
151
|
+
checkEditable,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
await getDrop()("clip.mp4", { start: 1, track: 0 }, 2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
159
|
+
expect(checkEditable).toHaveBeenCalledWith([
|
|
160
|
+
expect.objectContaining({ sourceFile: "index.html", start: 1, track: 0 }),
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("refuses file and composition drops before their writes when editing is blocked", async () => {
|
|
165
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
166
|
+
const uploadProjectFiles = vi.fn().mockResolvedValue(["clip.mp4"]);
|
|
167
|
+
const getDrop = renderDropHook(
|
|
168
|
+
'<main data-composition-id="scene" data-duration="10"></main>',
|
|
169
|
+
writeProjectFile,
|
|
170
|
+
[],
|
|
171
|
+
() => false,
|
|
172
|
+
uploadProjectFiles,
|
|
173
|
+
);
|
|
174
|
+
const fetchMock = vi.mocked(globalThis.fetch);
|
|
175
|
+
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await getDrop.file()([new File(["clip"], "clip.mp4")], { start: 1, track: 0 });
|
|
178
|
+
await getDrop.composition()("scene.html", { start: 1, track: 0 });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(uploadProjectFiles).not.toHaveBeenCalled();
|
|
182
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
183
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
127
185
|
});
|