@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.
Files changed (42) hide show
  1. package/dist/assets/{hyperframes-player-PTEnAHhg.js → hyperframes-player-Cy_AwUa8.js} +1 -1
  2. package/dist/assets/{index-BjslOS9g.js → index-CTa-6Kso.js} +1 -1
  3. package/dist/assets/{index-CNyviK_l.js → index-DPUJoO8n.js} +203 -203
  4. package/dist/assets/{index--myXp8ui.js → index-DkAzKuPZ.js} +1 -1
  5. package/dist/assets/{index-BnXZuIJD.css → index-DrHQAuGO.css} +1 -1
  6. package/dist/index.d.ts +43 -8
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7896 -7717
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -0
  11. package/package.json +8 -7
  12. package/src/components/StudioHeader.tsx +1 -1
  13. package/src/components/nle/TimelinePane.tsx +4 -1
  14. package/src/components/renders/RenderQueue.test.tsx +133 -25
  15. package/src/components/renders/RenderQueue.tsx +99 -92
  16. package/src/components/renders/RenderQueueItem.tsx +69 -64
  17. package/src/components/ui/Select.tsx +4 -0
  18. package/src/headerExports.test.tsx +23 -0
  19. package/src/hooks/timelineAudioGroupCreate.ts +17 -1
  20. package/src/hooks/timelineAudioGroupVolume.ts +35 -2
  21. package/src/hooks/timelineElementFxAttribute.ts +34 -2
  22. package/src/hooks/useRightPanelIntents.ts +2 -1
  23. package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
  24. package/src/hooks/useTimelineAssetDropOps.ts +40 -3
  25. package/src/hooks/useTimelineEditing.test.tsx +160 -2
  26. package/src/hooks/useTimelineEditing.ts +76 -39
  27. package/src/index.ts +5 -2
  28. package/src/main.tsx +26 -0
  29. package/src/player/components/Timeline.tsx +6 -6
  30. package/src/player/components/TimelineTrackHeader.test.tsx +5 -26
  31. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  32. package/src/player/components/TimelineTypes.ts +3 -0
  33. package/src/player/components/timelineTheme.test.ts +4 -4
  34. package/src/player/components/timelineTheme.ts +30 -30
  35. package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
  36. package/src/player/components/usePublishRangeSelection.ts +42 -0
  37. package/src/player/index.ts +1 -0
  38. package/src/player/store/playerStore.ts +8 -51
  39. package/src/player/store/rangeSelectionSlice.ts +13 -0
  40. package/src/player/store/timelineResetState.ts +51 -0
  41. package/src/styles/hex-baseline.json +2 -2
  42. 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-panel-border last:border-0 transition-colors duration-150 hover:bg-panel-hover/30"
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-panel-input shrink-0 relative",
81
- "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent",
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-panel-accent animate-pulse motion-reduce:animate-none" />
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-red-400" />
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-neutral-600" />
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-[11px] font-medium text-panel-text-2 truncate">
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-[9px] text-panel-text-5 shrink-0">
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-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
143
- <span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
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-panel-border rounded-full overflow-hidden"
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-panel-accent rounded-full transition-all duration-300"
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-[9px] text-red-400 mt-0.5 block">{job.error}</span>
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-[9px] text-panel-text-4 mt-0.5 block">Cancelled</span>
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-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
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
- <button
213
- onClick={isComplete ? handleDownload : undefined}
214
- className={`p-1.5 min-w-6 min-h-6 rounded transition-colors outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent ${
215
- isComplete
216
- ? "text-panel-text-5 hover:text-panel-accent"
217
- : "text-panel-text-5/30 cursor-default"
218
- }`}
219
- title={isComplete ? "Download" : undefined}
220
- aria-label={`Download ${job.filename}`}
221
- disabled={!isComplete}
222
- >
223
- <svg
224
- width="12"
225
- height="12"
226
- viewBox="0 0 24 24"
227
- fill="none"
228
- stroke="currentColor"
229
- strokeWidth="2"
230
- strokeLinecap="round"
231
- strokeLinejoin="round"
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
- </button>
238
- <button
239
- onClick={(e) => {
240
- e.stopPropagation();
241
- setConfirmingDelete(true);
242
- }}
243
- className="p-1.5 min-w-6 min-h-6 rounded-sm text-panel-text-5 hover:text-red-400 transition-colors outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent"
244
- title="Delete render file"
245
- aria-label={`Delete ${job.filename}`}
246
- >
247
- <svg
248
- width="12"
249
- height="12"
250
- viewBox="0 0 24 24"
251
- fill="none"
252
- stroke="currentColor"
253
- strokeWidth="2"
254
- strokeLinecap="round"
255
- >
256
- <path d="M18 6L6 18M6 6l12 12" />
257
- </svg>
258
- </button>
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
- }: UseTimelineElementVisibilityEditingInput): (
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 and closes it when it stops being one; a user's own close, and a restored layout's placement, stick. */
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 { handleTimelineAssetDrop } = useTimelineAssetDropOps({
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: vi.fn().mockResolvedValue([]),
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
  });