@hyperframes/studio 0.8.78 → 0.8.80

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 (99) hide show
  1. package/dist/assets/hyperframes-player-CgnoxDoD.js +464 -0
  2. package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
  3. package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
  4. package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.js} +1 -1
  5. package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
  6. package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
  7. package/dist/index.d.ts +40 -16
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1918 -1408
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -8
  12. package/src/App.tsx +6 -1
  13. package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
  14. package/src/components/editor/TimelineFxPopover.tsx +1 -1
  15. package/src/components/editor/manualEdits.ts +1 -0
  16. package/src/components/editor/manualEditsParsing.ts +4 -0
  17. package/src/components/editor/useFxAudition.ts +3 -3
  18. package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
  19. package/src/contexts/DomEditContext.tsx +4 -0
  20. package/src/contexts/TimelineEditContext.tsx +2 -0
  21. package/src/hooks/liveLanes.ts +147 -0
  22. package/src/hooks/timelineAudioGroupVolume.ts +97 -53
  23. package/src/hooks/timelineEditPermission.ts +55 -13
  24. package/src/hooks/timelineEditingHelpers.test.ts +16 -0
  25. package/src/hooks/timelineEditingHelpers.ts +69 -43
  26. package/src/hooks/timelineElementFxAttribute.ts +92 -40
  27. package/src/hooks/useAppHotkeys.ts +1 -1
  28. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
  29. package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
  30. package/src/hooks/useBlockHandlers.test.tsx +65 -0
  31. package/src/hooks/useBlockHandlers.ts +7 -4
  32. package/src/hooks/useClipboard.source.test.ts +340 -0
  33. package/src/hooks/useClipboard.ts +141 -62
  34. package/src/hooks/useDomEditAttributeCommits.ts +3 -17
  35. package/src/hooks/useDomEditSession.ts +1 -0
  36. package/src/hooks/useEditHistoryActions.ts +4 -3
  37. package/src/hooks/useElementPicker.test.ts +172 -0
  38. package/src/hooks/useElementPicker.ts +141 -93
  39. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
  40. package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
  41. package/src/hooks/useFrameCapture.test.tsx +67 -0
  42. package/src/hooks/useFrameCapture.ts +18 -9
  43. package/src/hooks/usePersistentEditHistory.test.ts +2 -2
  44. package/src/hooks/usePersistentEditHistory.ts +2 -2
  45. package/src/hooks/useTimelineEditing.test.tsx +59 -0
  46. package/src/hooks/useTimelineEditing.ts +59 -47
  47. package/src/hooks/useTimelineGroupEditing.ts +2 -1
  48. package/src/hooks/useToast.ts +2 -1
  49. package/src/index.ts +1 -0
  50. package/src/player/components/ImageThumbnail.tsx +3 -12
  51. package/src/player/components/Player.test.ts +28 -0
  52. package/src/player/components/Player.tsx +5 -2
  53. package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
  54. package/src/player/components/TimelineAutomationLane.tsx +18 -13
  55. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
  56. package/src/player/components/TimelineClipFades.test.tsx +24 -3
  57. package/src/player/components/TimelineClipFades.tsx +5 -2
  58. package/src/player/components/TimelineFxButton.tsx +1 -1
  59. package/src/player/components/TimelineGroupHeader.tsx +1 -1
  60. package/src/player/components/TimelineGroupRow.test.tsx +0 -1
  61. package/src/player/components/TimelineGroupRow.tsx +17 -11
  62. package/src/player/components/TimelineTrackHeader.tsx +21 -6
  63. package/src/player/components/VideoThumbnail.test.tsx +13 -0
  64. package/src/player/components/VideoThumbnail.tsx +1 -1
  65. package/src/player/components/groupAutomationElement.ts +9 -1
  66. package/src/player/components/timelineCallbacks.ts +5 -2
  67. package/src/player/components/timelineClipDragPreview.test.ts +41 -0
  68. package/src/player/components/timelineClipDragPreview.ts +15 -24
  69. package/src/player/components/timelineEditing.test.ts +99 -1
  70. package/src/player/components/timelineEditing.ts +5 -8
  71. package/src/player/components/timelineGroupEditing.test.ts +27 -0
  72. package/src/player/components/timelineGroupEditing.ts +18 -15
  73. package/src/player/components/timelineStackingSync.ts +5 -5
  74. package/src/player/components/timelineZones.ts +2 -3
  75. package/src/player/components/useAutomationEdgeStretch.ts +4 -4
  76. package/src/player/components/useAutomationLaneGestures.ts +1 -1
  77. package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
  78. package/src/player/components/useAutomationLanes.ts +58 -74
  79. package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
  80. package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
  81. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  82. package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
  83. package/src/player/lib/automationStoreSync.ts +30 -3
  84. package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
  85. package/src/player/lib/thumbnailImageDecoder.ts +73 -0
  86. package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
  87. package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
  88. package/src/player/lib/timelineDOM.test.ts +28 -8
  89. package/src/player/lib/timelineElementHelpers.ts +3 -4
  90. package/src/player/lib/timelineGroupInfo.ts +23 -23
  91. package/src/utils/authoredSource.ts +92 -0
  92. package/src/utils/gsapSoftReload.test.ts +29 -0
  93. package/src/utils/gsapSoftReload.ts +3 -7
  94. package/src/utils/studioFileHistory.ts +36 -30
  95. package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
  96. package/src/player/components/automationGestureKeys.test.ts +0 -49
  97. package/src/player/components/automationGestureKeys.ts +0 -49
  98. /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
  99. /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
@@ -0,0 +1,340 @@
1
+ // @vitest-environment jsdom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { useClipboard } from "./useClipboard";
7
+ import { usePlayerStore, type TimelineElement } from "../player";
8
+ import type { DomEditSelection } from "../components/editor/domEditing";
9
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
10
+
11
+ // Saved without hf-ids and with no element id: the preview stamps ids only in memory.
12
+ const SAVED = `<!doctype html><html><body>
13
+ <div data-composition-id="main" data-start="0" data-duration="10">
14
+ <h1 class="clip" data-start="2" data-duration="3" data-track-index="0">Title</h1>
15
+ </div></body></html>`;
16
+
17
+ const SUB = `<template id="sub-template"><div data-composition-id="sub" data-start="0" data-duration="4">
18
+ <h2 class="clip" data-start="1" data-duration="2" data-track-index="0">Sub</h2>
19
+ </div></template>`;
20
+
21
+ // What the runtime leaves on a clip while the playhead is before its start.
22
+ const RUNTIME_HIDE = "visibility: hidden; display: none; position: absolute";
23
+
24
+ function stampedHfId(source: string, selector: string): string {
25
+ const doc = new DOMParser().parseFromString(ensureHfIds(source), "text/html");
26
+ const roots = [doc, ...Array.from(doc.querySelectorAll("template"), (t) => t.content)];
27
+ const el = roots.map((root) => root.querySelector(selector)).find(Boolean);
28
+ return el?.getAttribute("data-hf-id") ?? "";
29
+ }
30
+
31
+ const TITLE_HF_ID = stampedHfId(SAVED, "h1");
32
+ const SUB_HF_ID = stampedHfId(SUB, "h2");
33
+
34
+ const TITLE: TimelineElement = {
35
+ id: TITLE_HF_ID,
36
+ hfId: TITLE_HF_ID,
37
+ tag: "h1",
38
+ start: 2,
39
+ duration: 3,
40
+ track: 0,
41
+ authoredTrack: 0,
42
+ sourceFile: "index.html",
43
+ };
44
+
45
+ let root: Root | null = null;
46
+ afterEach(() => {
47
+ act(() => root?.unmount());
48
+ root = null;
49
+ vi.unstubAllGlobals();
50
+ document.body.innerHTML = "";
51
+ });
52
+
53
+ // The preview page: the saved markup with the preview's in-memory ids, a sub-composition
54
+ // mounted inline, and the runtime's hide on both clips.
55
+ function mountPreview(sub: string, bundle: (host: Element) => void): HTMLIFrameElement {
56
+ const iframe = document.createElement("iframe");
57
+ document.body.appendChild(iframe);
58
+ const doc = iframe.contentDocument as Document;
59
+ doc.open();
60
+ doc.write(ensureHfIds(SAVED));
61
+ doc.close();
62
+ const parsed = new DOMParser().parseFromString(ensureHfIds(sub), "text/html");
63
+ const subRoot = (parsed.querySelector("template") as HTMLTemplateElement).content
64
+ .firstElementChild;
65
+ const host = doc.createElement("div");
66
+ host.setAttribute("data-composition-src", "compositions/sub.html");
67
+ host.append(doc.importNode(subRoot as Element, true));
68
+ bundle(host);
69
+ doc.querySelector('[data-composition-id="main"]')?.append(host);
70
+ for (const clip of doc.querySelectorAll(".clip"))
71
+ (clip as HTMLElement).style.cssText = RUNTIME_HIDE;
72
+ return iframe;
73
+ }
74
+
75
+ function stubFiles(
76
+ files: Record<string, string>,
77
+ fail: { on: boolean },
78
+ delayMs: Record<string, number>,
79
+ ) {
80
+ vi.stubGlobal(
81
+ "fetch",
82
+ vi.fn(async (url: string) => {
83
+ const path = decodeURIComponent(String(url).split("/files/")[1] ?? "");
84
+ // The server answers later, so a write that lands first is what the read sees.
85
+ await new Promise((resolve) => setTimeout(resolve, delayMs[path] ?? 0));
86
+ if (fail.on) return new Response("", { status: 500 });
87
+ return new Response(JSON.stringify({ content: files[path] }));
88
+ }),
89
+ );
90
+ }
91
+
92
+ function mountClipboard(
93
+ domSelection: DomEditSelection | null = null,
94
+ sub = SUB,
95
+ bundle: (host: Element) => void = () => {},
96
+ ) {
97
+ const files: Record<string, string> = { "index.html": SAVED, "compositions/sub.html": sub };
98
+ const fail = { on: false };
99
+ const delayMs: Record<string, number> = {};
100
+ const domEditSave = { pending: Promise.resolve() };
101
+ const domSelectionRef = { current: domSelection };
102
+ stubFiles(files, fail, delayMs);
103
+ const iframe = mountPreview(sub, bundle);
104
+ const writes: string[] = [];
105
+ const deleted: string[] = [];
106
+ const writeProjectFile = async (_path: string, content: string) => {
107
+ writes.push(content);
108
+ };
109
+ let api: ReturnType<typeof useClipboard> | null = null;
110
+ function Harness() {
111
+ api = useClipboard({
112
+ projectId: "p",
113
+ activeCompPath: "index.html",
114
+ domEditSelectionRef: domSelectionRef,
115
+ showToast: () => {},
116
+ writeProjectFile,
117
+ recordEdit: async () => {},
118
+ reloadPreview: () => {},
119
+ handleTimelineElementsDelete: async (elements) => {
120
+ deleted.push(...elements.map((el) => el.id));
121
+ files["index.html"] = SAVED.replace(/<h1[\s\S]*<\/h1>/, "");
122
+ },
123
+ handleDomEditElementDelete: async () => {},
124
+ previewIframeRef: { current: iframe },
125
+ waitForPendingDomEditSaves: () => domEditSave.pending,
126
+ });
127
+ return null;
128
+ }
129
+ root = createRoot(document.createElement("div"));
130
+ act(() => root?.render(React.createElement(Harness)));
131
+ const clipboard = () => api as ReturnType<typeof useClipboard>;
132
+ return {
133
+ clipboard,
134
+ writes,
135
+ deleted,
136
+ files,
137
+ fail,
138
+ writeProjectFile,
139
+ iframe,
140
+ delayMs,
141
+ domEditSave,
142
+ domSelectionRef,
143
+ };
144
+ }
145
+
146
+ function selectTitle() {
147
+ usePlayerStore.setState({
148
+ elements: [TITLE],
149
+ selectedElementId: TITLE.id,
150
+ selectedElementIds: new Set([TITLE.id]),
151
+ currentTime: 6,
152
+ });
153
+ }
154
+
155
+ function clearSelection() {
156
+ usePlayerStore.setState({ selectedElementId: null, selectedElementIds: new Set() });
157
+ }
158
+
159
+ describe("copy takes a clip's saved markup, not the runtime's live styling", () => {
160
+ it("pastes a timeline clip copied while hidden, matched through the preview's hf-id", async () => {
161
+ selectTitle();
162
+ const { clipboard, writes } = mountClipboard();
163
+ expect(clipboard().handleCopy()).toBe(true);
164
+ await clipboard().handlePaste();
165
+ expect(writes).toHaveLength(1);
166
+ expect(writes[0]).toContain(">Title</h1>");
167
+ expect(writes[0]).not.toContain("display: none");
168
+ expect(writes[0]).not.toContain("visibility: hidden");
169
+ });
170
+
171
+ it("pastes a sub-composition clip copied while hidden, found inside its template", async () => {
172
+ clearSelection();
173
+ const selection = {
174
+ hfId: SUB_HF_ID,
175
+ selector: "h2",
176
+ selectorIndex: 0,
177
+ sourceFile: "compositions/sub.html",
178
+ } as DomEditSelection;
179
+ const { clipboard, writes } = mountClipboard(selection);
180
+ expect(clipboard().handleCopy()).toBe(true);
181
+ await clipboard().handlePaste();
182
+ expect(writes).toHaveLength(1);
183
+ expect(writes[0]).toContain(">Sub</h2>");
184
+ expect(writes[0]).not.toContain("display: none");
185
+ });
186
+
187
+ it("cuts only once the saved markup is read, and pastes it", async () => {
188
+ selectTitle();
189
+ const { clipboard, writes, deleted } = mountClipboard();
190
+ await clipboard().handleCut();
191
+ expect(deleted).toEqual([TITLE.id]);
192
+ await clipboard().handlePaste();
193
+ expect(writes[0]).toContain(">Title</h1>");
194
+ expect(writes[0]).not.toContain("display: none");
195
+ });
196
+
197
+ it("reads a save still in flight when the copy starts", async () => {
198
+ selectTitle();
199
+ const { clipboard, writes, files, writeProjectFile } = mountClipboard();
200
+ void serializeStudioFileMutations(writeProjectFile, ["index.html"], async () => {
201
+ await new Promise((resolve) => setTimeout(resolve, 5));
202
+ // The host pins hf-ids on disk, so a Studio save keeps the clip's id.
203
+ files["index.html"] = ensureHfIds(SAVED).replace(">Title<", ">Title v2<");
204
+ });
205
+ clipboard().handleCopy();
206
+ await clipboard().handlePaste();
207
+ expect(writes[0]?.match(/>Title v2<\/h1>/g)).toHaveLength(2);
208
+ expect(writes[0]).not.toContain(">Title</h1>");
209
+ });
210
+
211
+ it("finds a clip by its id once its saved markup no longer hashes to the preview's hf-id", async () => {
212
+ selectTitle();
213
+ const { clipboard, writes, files, iframe } = mountClipboard();
214
+ iframe.contentDocument?.querySelector("h1")?.setAttribute("id", "title");
215
+ files["index.html"] = SAVED.replace("<h1 ", '<h1 id="title" ').replace(">Title<", ">Title v2<");
216
+ clipboard().handleCopy();
217
+ await clipboard().handlePaste();
218
+ expect(writes[0]?.match(/>Title v2<\/h1>/g)).toHaveLength(2);
219
+ expect(writes[0]).not.toContain("display: none");
220
+ });
221
+ });
222
+
223
+ const SUB_SELECTION = {
224
+ hfId: SUB_HF_ID,
225
+ selector: "h2",
226
+ selectorIndex: 0,
227
+ sourceFile: "compositions/sub.html",
228
+ } as DomEditSelection;
229
+
230
+ describe("copy of a sub-composition clip", () => {
231
+ it("rebases its relative asset paths to the project root, as the preview does", async () => {
232
+ clearSelection();
233
+ const sub = SUB.replace(
234
+ ">Sub<",
235
+ '><img src="../assets/logo.png?v=2"><img src="logo.png"><img src="assets/shared.png">' +
236
+ '<img src="../assets/100%.png"><span style="background: url(\'../fonts/My Font.woff2\')"></span>Sub<',
237
+ );
238
+ const selection = { ...SUB_SELECTION, hfId: stampedHfId(sub, "h2") };
239
+ const { clipboard, writes } = mountClipboard(selection, sub, (host) => {
240
+ const [up, sibling] = Array.from(host.querySelectorAll("img"));
241
+ up?.setAttribute("src", "assets/logo.png?v=2");
242
+ sibling?.setAttribute("src", "compositions/logo.png");
243
+ });
244
+ clipboard().handleCopy();
245
+ await clipboard().handlePaste();
246
+ expect(writes[0]).toContain('src="assets/logo.png?v=2"');
247
+ expect(writes[0]).toContain('src="compositions/logo.png"');
248
+ expect(writes[0]).toContain('src="assets/shared.png"');
249
+ expect(writes[0]).toContain('src="assets/100%.png"');
250
+ expect(writes[0]).toContain("url('fonts/My Font.woff2')");
251
+ expect(writes[0]).not.toContain("../");
252
+ expect(writes[0]).not.toContain("display: none");
253
+ });
254
+ });
255
+
256
+ describe("copy order", () => {
257
+ it("waits for a DOM edit save still in flight", async () => {
258
+ selectTitle();
259
+ const { clipboard, writes, files, domEditSave } = mountClipboard();
260
+ domEditSave.pending = new Promise((resolve) =>
261
+ setTimeout(() => {
262
+ files["index.html"] = ensureHfIds(SAVED).replace(">Title<", ">Title v2<");
263
+ resolve();
264
+ }, 5),
265
+ );
266
+ clipboard().handleCopy();
267
+ await clipboard().handlePaste();
268
+ expect(writes[0]?.match(/>Title v2<\/h1>/g)).toHaveLength(2);
269
+ });
270
+
271
+ it("still copies after a DOM edit save failed", async () => {
272
+ selectTitle();
273
+ const { clipboard, writes, domEditSave } = mountClipboard();
274
+ domEditSave.pending = Promise.reject(new Error("save conflict"));
275
+ domEditSave.pending.catch(() => {});
276
+ clipboard().handleCopy();
277
+ await clipboard().handlePaste();
278
+ expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(2);
279
+ });
280
+
281
+ it("pastes the second of two copies", async () => {
282
+ selectTitle();
283
+ const { clipboard, writes, domSelectionRef } = mountClipboard();
284
+ clipboard().handleCopy();
285
+ await new Promise((resolve) => setTimeout(resolve, 10));
286
+ clearSelection();
287
+ domSelectionRef.current = SUB_SELECTION;
288
+ clipboard().handleCopy();
289
+ await clipboard().handlePaste();
290
+ expect(writes[0]).toContain(">Sub</h2>");
291
+ expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
292
+ });
293
+
294
+ it("pastes the later copy even when the earlier copy's read lands last", async () => {
295
+ selectTitle();
296
+ const { clipboard, writes, delayMs, domSelectionRef } = mountClipboard();
297
+ delayMs["index.html"] = 20;
298
+ clipboard().handleCopy();
299
+ clearSelection();
300
+ domSelectionRef.current = SUB_SELECTION;
301
+ clipboard().handleCopy();
302
+ await new Promise((resolve) => setTimeout(resolve, 40));
303
+ delayMs["index.html"] = 0;
304
+ await clipboard().handlePaste();
305
+ expect(writes[0]).toContain(">Sub</h2>");
306
+ expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
307
+ });
308
+
309
+ it("duplicates a hidden clip with its saved markup", async () => {
310
+ selectTitle();
311
+ const { clipboard, writes } = mountClipboard();
312
+ await clipboard().handleDuplicate();
313
+ expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(2);
314
+ expect(writes[0]).not.toContain("display: none");
315
+ });
316
+ });
317
+
318
+ describe("a copy that fails", () => {
319
+ it("leaves the previous copy on the clipboard", async () => {
320
+ selectTitle();
321
+ const { clipboard, writes, fail } = mountClipboard();
322
+ clipboard().handleCopy();
323
+ await new Promise((resolve) => setTimeout(resolve, 10));
324
+ fail.on = true;
325
+ clipboard().handleCopy();
326
+ await new Promise((resolve) => setTimeout(resolve, 10));
327
+ fail.on = false;
328
+ await clipboard().handlePaste();
329
+ expect(writes).toHaveLength(1);
330
+ expect(writes[0]).toContain(">Title</h1>");
331
+ });
332
+
333
+ it("deletes nothing on a cut", async () => {
334
+ selectTitle();
335
+ const { clipboard, deleted, fail } = mountClipboard();
336
+ fail.on = true;
337
+ expect(await clipboard().handleCut()).toBe(false);
338
+ expect(deleted).toEqual([]);
339
+ });
340
+ });
@@ -19,6 +19,13 @@ import { findElementForSelection } from "../components/editor/domEditingElement"
19
19
  import { findTimelineElementInIframe, readFileContent } from "./timelineEditingHelpers";
20
20
  import { buildTimelineElementKey } from "../player/lib/timelineElementHelpers";
21
21
  import { timeRangesOverlap } from "../player/components/timelineCollision";
22
+ import {
23
+ authoredMarkup,
24
+ findAuthoredElement,
25
+ findAuthoredElementById,
26
+ parseSavedSource,
27
+ } from "../utils/authoredSource";
28
+ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
22
29
 
23
30
  interface RecordEditInput {
24
31
  label: string;
@@ -37,6 +44,7 @@ interface UseClipboardOptions {
37
44
  handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
38
45
  handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
39
46
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
47
+ waitForPendingDomEditSaves: () => Promise<void>;
40
48
  }
41
49
 
42
50
  /** The timeline element(s) a copy/cut/duplicate acts on: the multi-selection
@@ -62,11 +70,11 @@ function clipToast(verbPast: string, clipCount: number): string {
62
70
  return clipCount > 1 ? `${verbPast} ${clipCount} clips` : `${verbPast} clip`;
63
71
  }
64
72
 
65
- function getElementOuterHtml(
73
+ function getSelectedDomElement(
66
74
  iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
67
75
  selection: DomEditSelection,
68
76
  activeCompositionPath: string | null,
69
- ): string | null {
77
+ ): Element | null {
70
78
  let doc: Document | null = null;
71
79
  try {
72
80
  doc = iframeRef.current?.contentDocument ?? null;
@@ -75,7 +83,27 @@ function getElementOuterHtml(
75
83
  }
76
84
  if (!doc) return null;
77
85
 
78
- return findElementForSelection(doc, selection, activeCompositionPath)?.outerHTML ?? null;
86
+ return findElementForSelection(doc, selection, activeCompositionPath);
87
+ }
88
+
89
+ function savedMarkupElseLive(saved: Document, live: Element, sourceFile: string): string {
90
+ const authored = findAuthoredElement(saved, live) ?? findAuthoredElementById(saved, live);
91
+ return authored ? authoredMarkup(authored, live, sourceFile) : live.outerHTML;
92
+ }
93
+
94
+ async function readSavedMarkup(
95
+ readSaved: (path: string) => Promise<string>,
96
+ paths: string[],
97
+ lives: Element[],
98
+ ): Promise<string[]> {
99
+ const sources = new Map<string, Promise<Document>>();
100
+ return Promise.all(
101
+ paths.map(async (path, index) => {
102
+ if (!sources.has(path)) sources.set(path, readSaved(path).then(parseSavedSource));
103
+ const saved = await (sources.get(path) as Promise<Document>);
104
+ return savedMarkupElseLive(saved, lives[index] as Element, path);
105
+ }),
106
+ );
79
107
  }
80
108
 
81
109
  export interface PlacedClip {
@@ -195,86 +223,130 @@ export function useClipboard({
195
223
  handleTimelineElementsDelete,
196
224
  handleDomEditElementDelete,
197
225
  previewIframeRef,
226
+ waitForPendingDomEditSaves,
198
227
  }: UseClipboardOptions) {
199
- const clipboardRef = useRef<ClipboardPayload | null>(null);
228
+ const clipboardRef = useRef<Promise<ClipboardPayload | null> | null>(null);
200
229
  const projectIdRef = useRef(projectId);
201
230
  projectIdRef.current = projectId;
202
231
 
232
+ // After any save still in flight on the file, so a copy right after an edit takes the edit.
233
+ const readSaved = useCallback(
234
+ async (path: string): Promise<string> => {
235
+ const pid = projectIdRef.current;
236
+ if (!pid) throw new Error("No project is open.");
237
+ // Only the order matters here; a failed save already shows its own banner.
238
+ await waitForPendingDomEditSaves().catch(() => {});
239
+ return serializeStudioFileMutations(writeProjectFile, [path], () =>
240
+ readFileContent(pid, path),
241
+ );
242
+ },
243
+ [waitForPendingDomEditSaves, writeProjectFile],
244
+ );
245
+
203
246
  // Resolved through findTimelineElementInIframe, the same composition-aware
204
247
  // lookup every other timeline editor uses — unlike findElementForSelection,
205
248
  // it can address a composition-instance clip's root.
206
- const collectSelectedClips = useCallback((): {
249
+ const findSelectedClips = useCallback((): {
207
250
  elements: TimelineElement[];
208
- clips: TimelineClipboardClip[];
251
+ lives: Element[];
209
252
  } | null => {
210
253
  const selected = getSelectedElements();
211
254
  if (selected.length === 0) return null;
212
-
213
- const clips: TimelineClipboardClip[] = [];
255
+ const lives: Element[] = [];
214
256
  for (const element of selected) {
215
- const html =
216
- findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath)?.outerHTML ??
217
- null;
218
- if (!html) {
257
+ const live = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
258
+ if (!live) {
219
259
  showToast(`Unable to copy "${element.label ?? element.id}".`, "info");
220
260
  return null;
221
261
  }
222
- clips.push({
223
- html,
262
+ lives.push(live);
263
+ }
264
+ return { elements: selected, lives };
265
+ }, [activeCompPath, previewIframeRef, showToast]);
266
+
267
+ const readClips = useCallback(
268
+ async (targets: {
269
+ elements: TimelineElement[];
270
+ lives: Element[];
271
+ }): Promise<TimelineClipboardClip[]> => {
272
+ const paths = targets.elements.map((el) => el.sourceFile || activeCompPath || "index.html");
273
+ const markup = await readSavedMarkup(readSaved, paths, targets.lives);
274
+ return targets.elements.map((element, index) => ({
275
+ html: markup[index] as string,
224
276
  start: element.start,
225
277
  duration: element.duration,
226
278
  // authoredTrack, not track: `track` can be a display-lane number
227
279
  // remapped by normalizeToZones, and writing THAT into data-track-index
228
280
  // re-targets the wrong track in the sparse authored file.
229
281
  track: element.authoredTrack ?? element.track,
230
- });
231
- }
232
- return { elements: selected, clips };
233
- }, [activeCompPath, previewIframeRef, showToast]);
234
-
235
- // Two independent copy modes (timeline clip vs DOM element) in one handler.
236
- // fallow-ignore-next-line complexity
237
- const handleCopy = useCallback((): boolean => {
238
- if (usePlayerStore.getState().selectedElementId) {
239
- const result = collectSelectedClips();
240
- if (!result) return false;
241
- const targetPath = result.elements[0]?.sourceFile || activeCompPath || "index.html";
242
- clipboardRef.current = { kind: "timeline-clip", clips: result.clips, sourceFile: targetPath };
243
- showToast(
244
- result.clips.length > 1 ? `Copied ${result.clips.length} clips` : "Copied clip",
245
- "info",
246
- );
247
- return true;
248
- }
282
+ }));
283
+ },
284
+ [activeCompPath, readSaved],
285
+ );
249
286
 
250
- // DOM element copy
251
- const domSelection = domEditSelectionRef.current;
252
- if (domSelection) {
253
- const html = getElementOuterHtml(previewIframeRef, domSelection, activeCompPath);
254
- if (!html) {
287
+ const copyTimelineSelection = useCallback((): Promise<ClipboardPayload> | null => {
288
+ const targets = findSelectedClips();
289
+ if (!targets) return null;
290
+ const sourceFile = targets.elements[0]?.sourceFile || activeCompPath || "index.html";
291
+ return readClips(targets).then((clips) => {
292
+ showToast(clips.length > 1 ? `Copied ${clips.length} clips` : "Copied clip", "info");
293
+ return { kind: "timeline-clip", clips, sourceFile };
294
+ });
295
+ }, [activeCompPath, findSelectedClips, readClips, showToast]);
296
+
297
+ const copyDomSelection = useCallback(
298
+ (domSelection: DomEditSelection): Promise<ClipboardPayload> | null => {
299
+ const live = getSelectedDomElement(previewIframeRef, domSelection, activeCompPath);
300
+ if (!live) {
255
301
  showToast("Unable to copy this element.", "info");
256
- return false;
302
+ return null;
257
303
  }
258
- const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
259
- clipboardRef.current = {
260
- kind: "dom-element",
261
- html,
262
- sourceFile: targetPath,
263
- originSelector: domSelection.selector,
264
- originSelectorIndex: domSelection.selectorIndex,
265
- };
266
- showToast("Copied element", "info");
267
- return true;
268
- }
304
+ const sourceFile = domSelection.sourceFile || activeCompPath || "index.html";
305
+ return readSaved(sourceFile).then((content) => {
306
+ showToast("Copied element", "info");
307
+ return {
308
+ kind: "dom-element",
309
+ html: savedMarkupElseLive(parseSavedSource(content), live, sourceFile),
310
+ sourceFile,
311
+ originSelector: domSelection.selector,
312
+ originSelectorIndex: domSelection.selectorIndex,
313
+ };
314
+ });
315
+ },
316
+ [activeCompPath, previewIframeRef, readSaved, showToast],
317
+ );
269
318
 
270
- showToast("Nothing selected to copy.", "info");
271
- return false;
272
- }, [activeCompPath, collectSelectedClips, domEditSelectionRef, previewIframeRef, showToast]);
319
+ // The key handler needs its answer now, so the targets are found here and only the file read
320
+ // is pending. Returns this copy's own result; a failed copy leaves the clipboard as it was.
321
+ const copyToClipboard = useCallback((): Promise<ClipboardPayload | null> | null => {
322
+ const domSelection = domEditSelectionRef.current;
323
+ let pending: Promise<ClipboardPayload> | null;
324
+ if (usePlayerStore.getState().selectedElementId) pending = copyTimelineSelection();
325
+ else if (domSelection) pending = copyDomSelection(domSelection);
326
+ else {
327
+ showToast("Nothing selected to copy.", "info");
328
+ return null;
329
+ }
330
+ if (!pending) return null;
331
+ const own = pending.catch((error: unknown) => {
332
+ showToast(error instanceof Error ? error.message : "Failed to copy", "error");
333
+ return null;
334
+ });
335
+ const previous = clipboardRef.current;
336
+ const settled = own.then((payload) => payload ?? previous);
337
+ clipboardRef.current = settled;
338
+ void settled.then((payload) => {
339
+ if (!payload && clipboardRef.current === settled) clipboardRef.current = null;
340
+ });
341
+ return own;
342
+ }, [copyDomSelection, copyTimelineSelection, domEditSelectionRef, showToast]);
343
+
344
+ const handleCopy = useCallback((): boolean => copyToClipboard() !== null, [copyToClipboard]);
273
345
 
274
346
  // Two independent paste modes (timeline clip vs DOM element) behind one guarded save.
275
347
  // fallow-ignore-next-line complexity
276
348
  const handlePaste = useCallback(async () => {
277
- const payload = clipboardRef.current;
349
+ const payload = await clipboardRef.current;
278
350
  if (!payload) {
279
351
  showToast("Nothing to paste.", "info");
280
352
  return;
@@ -339,16 +411,17 @@ export function useClipboard({
339
411
  // pasteTimelineClips with handlePaste; only the anchor and clip source
340
412
  // differ (the selection's own end here, the playhead there).
341
413
  const handleDuplicate = useCallback(async (): Promise<boolean> => {
342
- const result = collectSelectedClips();
343
- if (!result) return false;
414
+ const targets = findSelectedClips();
415
+ if (!targets) return false;
344
416
  const pid = projectIdRef.current;
345
417
  if (!pid) return false;
346
418
 
347
- const { elements, clips } = result;
419
+ const { elements } = targets;
348
420
  const targetPath = elements[0]?.sourceFile || activeCompPath || "index.html";
349
421
  const anchorTime = Math.max(...elements.map((el) => el.start + el.duration));
350
422
 
351
423
  try {
424
+ const clips = await readClips(targets);
352
425
  const originalContent = await readFileContent(pid, targetPath);
353
426
  const liveElements = usePlayerStore.getState().elements;
354
427
  const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
@@ -381,7 +454,8 @@ export function useClipboard({
381
454
  }
382
455
  }, [
383
456
  activeCompPath,
384
- collectSelectedClips,
457
+ findSelectedClips,
458
+ readClips,
385
459
  recordEdit,
386
460
  reloadPreview,
387
461
  showToast,
@@ -390,8 +464,9 @@ export function useClipboard({
390
464
 
391
465
  const handleCut = useCallback(async (): Promise<boolean> => {
392
466
  const selected = getSelectedElements();
393
- const copied = handleCopy();
394
- if (!copied) return false;
467
+ const domSelection = domEditSelectionRef.current;
468
+ const copied = copyToClipboard();
469
+ if (!copied || !(await copied)) return false;
395
470
 
396
471
  if (selected.length > 0) {
397
472
  // One call for the whole selection, not one per element: the batched
@@ -401,13 +476,17 @@ export function useClipboard({
401
476
  return true;
402
477
  }
403
478
 
404
- const domSelection = domEditSelectionRef.current;
405
479
  if (domSelection) {
406
480
  await handleDomEditElementDelete(domSelection);
407
481
  return true;
408
482
  }
409
483
  return true;
410
- }, [handleCopy, domEditSelectionRef, handleTimelineElementsDelete, handleDomEditElementDelete]);
484
+ }, [
485
+ copyToClipboard,
486
+ domEditSelectionRef,
487
+ handleTimelineElementsDelete,
488
+ handleDomEditElementDelete,
489
+ ]);
411
490
 
412
491
  const canPaste = useCallback(() => clipboardRef.current !== null, []);
413
492