@hyperframes/studio 0.8.53 → 0.8.55

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 (43) hide show
  1. package/dist/assets/{hyperframes-player-DKWM-Ons.js → hyperframes-player-BjAGc2ID.js} +1 -1
  2. package/dist/assets/{index-Y4OnpTEM.js → index-BEIRytvV.js} +1 -1
  3. package/dist/assets/{index-UvW6BEwf.js → index-DaNpNFJC.js} +1 -1
  4. package/dist/assets/{index-HfP3sap2.js → index-TcBZyzNA.js} +191 -191
  5. package/dist/index.d.ts +440 -10
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1891 -1544
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/nle/TimelinePane.tsx +11 -1
  11. package/src/hooks/gsapDragCommit.ts +1 -1
  12. package/src/hooks/gsapEditOutcome.test.ts +115 -0
  13. package/src/hooks/gsapEditOutcome.ts +39 -10
  14. package/src/hooks/gsapResizeIntercept.test.ts +42 -3
  15. package/src/hooks/gsapResizeIntercept.ts +12 -2
  16. package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
  17. package/src/hooks/gsapRuntimeBridge.ts +14 -3
  18. package/src/hooks/timelineAudioGroupCreate.ts +17 -1
  19. package/src/hooks/timelineAudioGroupVolume.ts +35 -2
  20. package/src/hooks/timelineEditPermission.ts +31 -0
  21. package/src/hooks/timelineElementFxAttribute.ts +34 -2
  22. package/src/hooks/useFileManager.ts +21 -123
  23. package/src/hooks/usePersistentEditHistory.ts +1 -1
  24. package/src/hooks/useProjectFileWriter.ts +150 -0
  25. package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
  26. package/src/hooks/useTimelineAssetDropOps.ts +40 -3
  27. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  28. package/src/hooks/useTimelineEditing.test.tsx +481 -4
  29. package/src/hooks/useTimelineEditing.ts +151 -27
  30. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  31. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  32. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  33. package/src/hostMount.test.tsx +222 -0
  34. package/src/index.ts +40 -2
  35. package/src/player/components/Timeline.tsx +6 -6
  36. package/src/player/components/TimelineTypes.ts +3 -0
  37. package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
  38. package/src/player/components/usePublishRangeSelection.ts +42 -0
  39. package/src/player/index.ts +1 -0
  40. package/src/player/store/playerStore.ts +8 -51
  41. package/src/player/store/rangeSelectionSlice.ts +13 -0
  42. package/src/player/store/timelineResetState.ts +51 -0
  43. package/src/utils/studioSaveDiagnostics.ts +12 -0
@@ -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,18 +1,13 @@
1
- import { useState, useCallback, useMemo, useRef } from "react";
1
+ import { useState, useCallback, useRef } from "react";
2
2
  import type { EditingFile } from "../utils/studioHelpers";
3
3
  import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
4
4
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
5
5
  import type { EditHistoryKind } from "../utils/editHistory";
6
6
  import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
7
- import {
8
- createStudioSaveHttpError,
9
- retryStudioSave,
10
- StudioFileConflictError,
11
- StudioSaveNetworkError,
12
- } from "../utils/studioSaveDiagnostics";
13
- import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
7
+ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
14
8
  import { useFileTree } from "./useFileTree";
15
9
  import { useEditorSave } from "./useEditorSave";
10
+ import { useProjectFileWriter } from "./useProjectFileWriter";
16
11
 
17
12
  // ── Types ──
18
13
 
@@ -50,17 +45,13 @@ export function useFileManager({
50
45
  projectIdRef.current = projectId;
51
46
 
52
47
  const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
53
- const fileVersionScope = useMemo(
54
- () => ({ projectId, versions: new Map<string, string | null>() }),
55
- [projectId],
56
- );
57
- const fileVersions = fileVersionScope.versions;
58
- const observeProjectFileVersion = useCallback(
59
- (path: string, version: string | null) => {
60
- fileVersions.set(path, version);
61
- },
62
- [fileVersions],
63
- );
48
+ const {
49
+ fileVersions,
50
+ readProjectFile,
51
+ writeProjectFile: writeProjectFileRaw,
52
+ readOptionalProjectFile,
53
+ observeProjectFileVersion,
54
+ } = useProjectFileWriter({ projectId });
64
55
 
65
56
  // ── File tree ──
66
57
 
@@ -74,117 +65,24 @@ export function useFileManager({
74
65
  fontAssets,
75
66
  } = useFileTree({ projectId, projectIdRef });
76
67
 
77
- // ── Core file I/O ──
78
-
79
- const readProjectFile = useCallback(
80
- async (path: string): Promise<string> => {
81
- if (!projectId) throw new Error("No active project");
82
- const response = await fetch(
83
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
84
- );
85
- if (!response.ok) throw new Error(`Failed to read ${path}`);
86
- const data = (await response.json()) as { content?: string; version?: string };
87
- if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
88
- fileVersions.set(path, data.version ?? response.headers.get("etag"));
89
- return data.content;
90
- },
91
- [fileVersions, projectId],
92
- );
93
-
94
- const writeProjectFile = useCallback(
95
- async (path: string, content: string, expectedContent?: string): Promise<void> => {
96
- if (!projectId) throw new Error("No active project");
97
- const writeProjectId = projectId;
98
- let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
99
- if (expectedVersion === undefined) {
100
- const preflight = await fetch(
101
- `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
102
- );
103
- if (preflight.ok) {
104
- const data = (await preflight.json()) as { content?: string; version?: string };
105
- throw new StudioFileConflictError({
106
- filePath: path,
107
- currentVersion: data.version ?? preflight.headers.get("etag"),
108
- currentContent: data.content ?? null,
109
- attemptedContent: content,
110
- });
111
- } else if (preflight.status === 404) {
112
- expectedVersion = null;
113
- } else {
114
- throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
115
- }
116
- }
117
- await retryStudioSave(async () => {
118
- // Each request gets its own receipt identity. If a committed request loses its response,
119
- // the retry can produce a second filesystem receipt that must be suppressed independently.
120
- let response: Response;
121
- try {
122
- response = await fetch(
123
- `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
124
- {
125
- method: "PUT",
126
- headers: {
127
- "Content-Type": "text/plain",
128
- ...studioWriteHeaders(),
129
- ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
130
- },
131
- body: content,
132
- },
133
- );
134
- } catch (error) {
135
- throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
136
- cause: error,
137
- });
138
- }
139
- if (response.status === 409) {
140
- const conflict = (await response.json().catch(() => null)) as {
141
- currentVersion?: string | null;
142
- currentContent?: string | null;
143
- } | null;
144
- const currentVersion = conflict?.currentVersion ?? null;
145
- if (currentVersion && conflict?.currentContent === content) {
146
- fileVersions.set(path, currentVersion);
147
- return;
148
- }
149
- throw new StudioFileConflictError({
150
- filePath: path,
151
- currentVersion,
152
- currentContent: conflict?.currentContent ?? null,
153
- attemptedContent: content,
154
- });
155
- }
156
- if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
157
- const result = (await response.json()) as { version?: string };
158
- const version = result.version ?? response.headers.get("etag");
159
- if (!version)
160
- throw new Error(`Save response for ${path} did not include a content version`);
161
- fileVersions.set(path, version);
162
- });
163
- if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
164
- setEditingFile({ path, content });
165
- }
166
- },
167
- [fileVersions, projectId],
168
- );
169
-
170
68
  const updateEditingFileContent = useCallback((path: string, content: string) => {
171
69
  if (editingPathRef.current === path) {
172
70
  setEditingFile({ path, content });
173
71
  }
174
72
  }, []);
175
73
 
176
- const readOptionalProjectFile = useCallback(
177
- async (path: string): Promise<string> => {
178
- if (!projectId) throw new Error("No active project");
179
- const response = await fetch(
180
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
181
- );
182
- if (!response.ok) throw new Error(`Failed to read ${path}`);
183
- const data = (await response.json()) as { content?: string; version?: string };
184
- fileVersions.set(path, data.version ?? response.headers.get("etag"));
185
- return typeof data.content === "string" ? data.content : "";
74
+ // Studio's own editor tab mirrors whatever it just wrote, on top of the
75
+ // shared writer — a host mounting useProjectFileWriter directly has no
76
+ // editor tab and does not need this.
77
+ const writeProjectFile = useCallback(
78
+ async (path: string, content: string, expectedContent?: string): Promise<void> => {
79
+ const writeProjectId = projectIdRef.current;
80
+ await writeProjectFileRaw(path, content, expectedContent);
81
+ if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
82
+ setEditingFile({ path, content });
83
+ }
186
84
  },
187
- [fileVersions, projectId],
85
+ [writeProjectFileRaw],
188
86
  );
189
87
 
190
88
  // ── Editor save (debounced content change) ──
@@ -33,7 +33,7 @@ interface ApplyCallbacks {
33
33
  serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
34
34
  }
35
35
 
36
- interface UsePersistentEditHistoryOptions {
36
+ export interface UsePersistentEditHistoryOptions {
37
37
  projectId: string | null;
38
38
  storage?: EditHistoryStorageAdapter;
39
39
  now?: () => number;
@@ -0,0 +1,150 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import {
3
+ createStudioSaveHttpError,
4
+ retryStudioSave,
5
+ StudioFileConflictError,
6
+ StudioSaveNetworkError,
7
+ } from "../utils/studioSaveDiagnostics";
8
+ import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
9
+
10
+ export interface UseProjectFileWriterOptions {
11
+ projectId: string | null;
12
+ }
13
+
14
+ // The etag-guarded project-file read/write pair every Studio save path
15
+ // shares. A host mounting hand editing outside Studio needs only this, not
16
+ // the full file manager (file tree, editor tabs, uploads, font assets).
17
+ export function useProjectFileWriter({ projectId }: UseProjectFileWriterOptions) {
18
+ const fileVersionScope = useMemo(
19
+ () => ({ projectId, versions: new Map<string, string | null>() }),
20
+ [projectId],
21
+ );
22
+ const fileVersions = fileVersionScope.versions;
23
+
24
+ const observeProjectFileVersion = useCallback(
25
+ (path: string, version: string | null) => {
26
+ fileVersions.set(path, version);
27
+ },
28
+ [fileVersions],
29
+ );
30
+
31
+ const readProjectFile = useCallback(
32
+ async (path: string): Promise<string> => {
33
+ if (!projectId) throw new Error("No active project");
34
+ const response = await fetch(
35
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
36
+ );
37
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
38
+ const data = (await response.json()) as { content?: string; version?: string };
39
+ if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
40
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
41
+ return data.content;
42
+ },
43
+ [fileVersions, projectId],
44
+ );
45
+
46
+ const readOptionalProjectFile = useCallback(
47
+ async (path: string): Promise<string> => {
48
+ if (!projectId) throw new Error("No active project");
49
+ const response = await fetch(
50
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
51
+ );
52
+ if (!response.ok) throw new Error(`Failed to read ${path}`);
53
+ const data = (await response.json()) as { content?: string; version?: string };
54
+ fileVersions.set(path, data.version ?? response.headers.get("etag"));
55
+ return typeof data.content === "string" ? data.content : "";
56
+ },
57
+ [fileVersions, projectId],
58
+ );
59
+
60
+ const writeProjectFile = useCallback(
61
+ async (path: string, content: string, expectedContent?: string): Promise<void> => {
62
+ if (!projectId) throw new Error("No active project");
63
+ const writeProjectId = projectId;
64
+ let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
65
+ if (expectedVersion === undefined) {
66
+ const preflight = await fetch(
67
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
68
+ );
69
+ if (preflight.ok) {
70
+ const data = (await preflight.json()) as { content?: string; version?: string };
71
+ throw new StudioFileConflictError({
72
+ filePath: path,
73
+ currentVersion: data.version ?? preflight.headers.get("etag"),
74
+ currentContent: data.content ?? null,
75
+ attemptedContent: content,
76
+ });
77
+ } else if (preflight.status === 404) {
78
+ expectedVersion = null;
79
+ } else {
80
+ throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
81
+ }
82
+ }
83
+ // fallow-ignore-next-line complexity
84
+ await retryStudioSave(async () => {
85
+ // Each request gets its own receipt identity. If a committed request loses its response,
86
+ // the retry can produce a second filesystem receipt that must be suppressed independently.
87
+ let response: Response;
88
+ try {
89
+ response = await fetch(
90
+ `/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
91
+ {
92
+ method: "PUT",
93
+ headers: {
94
+ "Content-Type": "text/plain",
95
+ ...studioWriteHeaders(),
96
+ ...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
97
+ },
98
+ body: content,
99
+ },
100
+ );
101
+ } catch (error) {
102
+ throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
103
+ cause: error,
104
+ });
105
+ }
106
+ if (response.status === 409) {
107
+ const conflict = (await response.json().catch(() => null)) as {
108
+ currentVersion?: string | null;
109
+ currentContent?: string | null;
110
+ } | null;
111
+ const currentVersion = conflict?.currentVersion ?? null;
112
+ if (currentVersion && conflict?.currentContent === content) {
113
+ fileVersions.set(path, currentVersion);
114
+ return;
115
+ }
116
+ throw new StudioFileConflictError({
117
+ filePath: path,
118
+ currentVersion,
119
+ currentContent: conflict?.currentContent ?? null,
120
+ attemptedContent: content,
121
+ });
122
+ }
123
+ if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
124
+ const result = (await response.json()) as { version?: string };
125
+ const version = result.version ?? response.headers.get("etag");
126
+ if (!version)
127
+ throw new Error(`Save response for ${path} did not include a content version`);
128
+ fileVersions.set(path, version);
129
+ });
130
+ },
131
+ [fileVersions, projectId],
132
+ );
133
+
134
+ return useMemo(
135
+ () => ({
136
+ fileVersions,
137
+ readProjectFile,
138
+ writeProjectFile,
139
+ readOptionalProjectFile,
140
+ observeProjectFileVersion,
141
+ }),
142
+ [
143
+ fileVersions,
144
+ readProjectFile,
145
+ writeProjectFile,
146
+ readOptionalProjectFile,
147
+ observeProjectFileVersion,
148
+ ],
149
+ );
150
+ }
@@ -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
  });
@@ -38,6 +38,20 @@ function fileDropPlacement(
38
38
  return index === 0 ? { ...dropped, ...next } : { ...next, track: landedTrack ?? next.track };
39
39
  }
40
40
 
41
+ function timelineDropTarget(
42
+ sourceFile: string,
43
+ placement: Pick<TimelineElement, "start" | "track">,
44
+ ): TimelineElement {
45
+ return {
46
+ id: "timeline-drop",
47
+ tag: "div",
48
+ start: placement.start,
49
+ duration: 0,
50
+ track: placement.track,
51
+ sourceFile,
52
+ };
53
+ }
54
+
41
55
  interface UseTimelineAssetDropOpsOptions {
42
56
  projectIdRef: MutableRefObject<string | null>;
43
57
  activeCompPath: string | null;
@@ -50,6 +64,7 @@ interface UseTimelineAssetDropOpsOptions {
50
64
  isRecordingRef?: RefObject<boolean>;
51
65
  forceReloadSdkSession?: () => void;
52
66
  observeProjectFileVersion?: (path: string, version: string | null) => void;
67
+ checkEditable?: (targets: readonly TimelineElement[]) => boolean;
53
68
  }
54
69
 
55
70
  export function useTimelineAssetDropOps({
@@ -64,6 +79,7 @@ export function useTimelineAssetDropOps({
64
79
  isRecordingRef,
65
80
  forceReloadSdkSession,
66
81
  observeProjectFileVersion,
82
+ checkEditable,
67
83
  }: UseTimelineAssetDropOpsOptions) {
68
84
  // fallow-ignore-next-line complexity
69
85
  const dropAssetAt = useCallback(
@@ -77,6 +93,10 @@ export function useTimelineAssetDropOps({
77
93
  showToast("Cannot edit timeline while recording", "error");
78
94
  return undefined;
79
95
  }
96
+ const targetPath = activeCompPath || "index.html";
97
+ if (checkEditable && !checkEditable([timelineDropTarget(targetPath, placement)])) {
98
+ return undefined;
99
+ }
80
100
  const pid = projectIdRef.current;
81
101
  if (!pid) throw new Error("No active project");
82
102
 
@@ -86,7 +106,6 @@ export function useTimelineAssetDropOps({
86
106
  return undefined;
87
107
  }
88
108
 
89
- const targetPath = activeCompPath || "index.html";
90
109
  try {
91
110
  const originalContent = await readFileContent(pid, targetPath);
92
111
 
@@ -172,6 +191,7 @@ export function useTimelineAssetDropOps({
172
191
  reloadPreview,
173
192
  isRecordingRef,
174
193
  forceReloadSdkSession,
194
+ checkEditable,
175
195
  ],
176
196
  );
177
197
 
@@ -190,6 +210,11 @@ export function useTimelineAssetDropOps({
190
210
  showToast("Cannot edit timeline while recording", "error");
191
211
  return;
192
212
  }
213
+ const targetPath = activeCompPath || "index.html";
214
+ const initialPlacement = placement ?? { start: 0, track: 0 };
215
+ if (checkEditable && !checkEditable([timelineDropTarget(targetPath, initialPlacement)])) {
216
+ return;
217
+ }
193
218
  const pid = projectIdRef.current;
194
219
  if (!pid) return;
195
220
  const uploaded = await uploadProjectFiles(files);
@@ -215,7 +240,15 @@ export function useTimelineAssetDropOps({
215
240
  if (index === 0) landedTrack = track;
216
241
  }
217
242
  },
218
- [dropAssetAt, projectIdRef, uploadProjectFiles, isRecordingRef, showToast],
243
+ [
244
+ activeCompPath,
245
+ checkEditable,
246
+ dropAssetAt,
247
+ projectIdRef,
248
+ uploadProjectFiles,
249
+ isRecordingRef,
250
+ showToast,
251
+ ],
219
252
  );
220
253
 
221
254
  const handleTimelineCompositionDrop = useCallback(
@@ -224,9 +257,12 @@ export function useTimelineAssetDropOps({
224
257
  showToast("Cannot edit timeline while recording", "error");
225
258
  return;
226
259
  }
260
+ const targetPath = activeCompPath || "index.html";
261
+ if (checkEditable && !checkEditable([timelineDropTarget(targetPath, placement)])) {
262
+ return;
263
+ }
227
264
  const pid = projectIdRef.current;
228
265
  if (!pid) throw new Error("No active project");
229
- const targetPath = activeCompPath || "index.html";
230
266
  try {
231
267
  await commitTimelineCompositionInsertion({
232
268
  projectId: pid,
@@ -251,6 +287,7 @@ export function useTimelineAssetDropOps({
251
287
  },
252
288
  [
253
289
  activeCompPath,
290
+ checkEditable,
254
291
  forceReloadSdkSession,
255
292
  isRecordingRef,
256
293
  observeProjectFileVersion,