@hyperframes/studio 0.8.77 → 0.8.79

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 (47) hide show
  1. package/dist/assets/hyperframes-player-Bx_Kw4Ey.js +464 -0
  2. package/dist/assets/{index-DcxWQ7Za.js → index-CJP92F4-.js} +1 -1
  3. package/dist/assets/{index-2GgHjQq-.js → index-DBKySIo7.js} +187 -187
  4. package/dist/assets/{index-CsFCRdMo.js → index-ORZXYB0k.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 +23 -15
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +459 -257
  10. package/dist/index.js.map +1 -1
  11. package/package.json +8 -7
  12. package/src/App.tsx +1 -0
  13. package/src/components/nle/useTimelineEditCallbacks.ts +8 -2
  14. package/src/hooks/timelineAudioGroupVolume.ts +21 -11
  15. package/src/hooks/timelineEditPermission.ts +47 -13
  16. package/src/hooks/timelineElementFxAttribute.ts +19 -13
  17. package/src/hooks/useClipboard.source.test.ts +340 -0
  18. package/src/hooks/useClipboard.ts +141 -62
  19. package/src/hooks/useElementPicker.test.ts +172 -0
  20. package/src/hooks/useElementPicker.ts +141 -93
  21. package/src/hooks/useFrameCapture.test.tsx +67 -0
  22. package/src/hooks/useFrameCapture.ts +18 -9
  23. package/src/hooks/usePersistentEditHistory.test.ts +2 -2
  24. package/src/hooks/usePersistentEditHistory.ts +2 -2
  25. package/src/hooks/useTimelineEditing.test.tsx +59 -0
  26. package/src/hooks/useTimelineEditing.ts +42 -33
  27. package/src/index.ts +8 -0
  28. package/src/player/components/Player.test.ts +5 -0
  29. package/src/player/components/Player.tsx +1 -0
  30. package/src/player/components/VideoThumbnail.test.tsx +13 -0
  31. package/src/player/components/VideoThumbnail.tsx +1 -1
  32. package/src/player/components/timelineCallbacks.ts +3 -2
  33. package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
  34. package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
  35. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  36. package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
  37. package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
  38. package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
  39. package/src/telemetry/client.test.ts +26 -0
  40. package/src/telemetry/policy.test.ts +10 -0
  41. package/src/telemetry/policy.ts +7 -0
  42. package/src/utils/authoredSource.ts +92 -0
  43. package/src/utils/gsapSoftReload.test.ts +29 -0
  44. package/src/utils/gsapSoftReload.ts +3 -7
  45. package/dist/assets/hyperframes-player-BSDTJ9EF.js +0 -464
  46. /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
  47. /package/dist/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.77",
3
+ "version": "0.8.79",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,11 +52,12 @@
52
52
  "dockview-react": "8.3.1",
53
53
  "gsap": "^3.13.0",
54
54
  "mediabunny": "^1.45.3",
55
- "@hyperframes/core": "0.8.77",
56
- "@hyperframes/parsers": "0.8.77",
57
- "@hyperframes/studio-server": "0.8.77",
58
- "@hyperframes/player": "0.8.77",
59
- "@hyperframes/sdk": "0.8.77"
55
+ "@hyperframes/core": "0.8.79",
56
+ "@hyperframes/parsers": "0.8.79",
57
+ "@hyperframes/sdk": "0.8.79",
58
+ "@hyperframes/player": "0.8.79",
59
+ "@hyperframes/shader-transitions": "0.8.79",
60
+ "@hyperframes/studio-server": "0.8.79"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@tailwindcss/vite": "^4.3.3",
@@ -72,7 +73,7 @@
72
73
  "vite": "^6.4.2",
73
74
  "vitest": "^4.1.11",
74
75
  "zustand": "^5.0.0",
75
- "@hyperframes/producer": "0.8.77"
76
+ "@hyperframes/producer": "0.8.79"
76
77
  },
77
78
  "peerDependencies": {
78
79
  "react": "19",
package/src/App.tsx CHANGED
@@ -239,6 +239,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
239
239
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
240
240
  handleDomEditElementDelete: domEditDeleteBridge,
241
241
  previewIframeRef,
242
+ waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
242
243
  });
243
244
  const appHotkeys = useAppHotkeys({
244
245
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
@@ -1,3 +1,4 @@
1
+ import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
1
2
  import { useCallback, useMemo } from "react";
2
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
4
  import type { TimelineElement } from "../../player";
@@ -40,7 +41,12 @@ export interface TimelineEditCallbackDeps {
40
41
  handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
41
42
  setAudioGroupAttribute: {
42
43
  setLive: (groupId: string, attr: string, value: string | null) => void;
43
- setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
44
+ setQuiet: (
45
+ groupId: string,
46
+ attr: string,
47
+ value: string | null,
48
+ label: string,
49
+ ) => Promise<TimelineEditOutcome | void>;
44
50
  };
45
51
  handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
46
52
  handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -60,7 +66,7 @@ export interface TimelineEditCallbackDeps {
60
66
  attr: string,
61
67
  value: string | null,
62
68
  label: string,
63
- ) => Promise<void>;
69
+ ) => Promise<TimelineEditOutcome | void>;
64
70
  };
65
71
  }
66
72
 
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
+ import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission";
2
3
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
3
4
  import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
4
5
  import { usePlayerStore } from "../player";
@@ -167,7 +168,7 @@ async function setAudioGroupAttribute({
167
168
  writeProjectFile,
168
169
  recordEdit,
169
170
  pendingTimelineEditPathRef,
170
- }: SetAudioGroupAttributeInput): Promise<string[]> {
171
+ }: SetAudioGroupAttributeInput): Promise<string[] | null> {
171
172
  // The file that actually CONTAINS the group element, not just the active
172
173
  // composition. A hand-authored sub-composition can declare both the members
173
174
  // and their `<hf-audio-group>`, and until sub-comp children inherited
@@ -179,7 +180,7 @@ async function setAudioGroupAttribute({
179
180
  const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
180
181
  const targetPath = resolveGroupSourceFile(groupEl) || activeCompPath || "index.html";
181
182
  const patchTarget = buildPatchTarget({ domId: groupId });
182
- if (!patchTarget) return [];
183
+ if (!patchTarget) return null;
183
184
 
184
185
  return persistElementAttribute({
185
186
  projectId,
@@ -213,7 +214,12 @@ export function useSetAudioGroupAttribute({
213
214
  isRecordingRef,
214
215
  }: UseTimelineElementVisibilityEditingInput): {
215
216
  setLive: (groupId: string, attr: string, value: string | null) => void;
216
- setQuiet: (groupId: string, attr: string, value: string | null, label: string) => Promise<void>;
217
+ setQuiet: (
218
+ groupId: string,
219
+ attr: string,
220
+ value: string | null,
221
+ label: string,
222
+ ) => Promise<TimelineEditOutcome>;
217
223
  revertLive: (groupId: string, attr: string) => void;
218
224
  } {
219
225
  const liveBeforeRef = useRef(new Map<string, string | null>());
@@ -253,15 +259,16 @@ export function useSetAudioGroupAttribute({
253
259
  [previewIframeRef],
254
260
  );
255
261
  const setQuiet = useCallback(
256
- async (groupId: string, attr: string, value: string | null, label: string) => {
257
- if (isRecordingRef?.current) {
258
- showToast("Cannot edit timeline while recording", "error");
259
- return;
260
- }
261
- const pid = projectIdRef.current;
262
- if (!pid) return;
262
+ async (
263
+ groupId: string,
264
+ attr: string,
265
+ value: string | null,
266
+ label: string,
267
+ ): Promise<TimelineEditOutcome> => {
268
+ const pid = projectForTimelineSave(isRecordingRef?.current, projectIdRef.current, showToast);
269
+ if (typeof pid !== "string") return pid;
263
270
  try {
264
- await setAudioGroupAttribute({
271
+ const written = await setAudioGroupAttribute({
265
272
  projectId: pid,
266
273
  activeCompPath,
267
274
  groupId,
@@ -274,7 +281,9 @@ export function useSetAudioGroupAttribute({
274
281
  pendingTimelineEditPathRef,
275
282
  });
276
283
  liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
284
+ if (!written) return { status: "failed", reason: "This group has no id to save it by" };
277
285
  syncStoredGroupAttribute(groupId, attr, value);
286
+ return { status: "saved" };
278
287
  } catch (error) {
279
288
  // `persistElementAttribute` leaves the live DOM at the previous value
280
289
  // however it failed — it unwinds a failed save, and an unresolvable
@@ -290,6 +299,7 @@ export function useSetAudioGroupAttribute({
290
299
  const message = error instanceof Error ? error.message : "Failed to update group";
291
300
  showToast(message);
292
301
  liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
302
+ return { status: "failed", reason: message };
293
303
  }
294
304
  },
295
305
  [
@@ -6,26 +6,60 @@ export type TimelineEditPermission = true | { blocked: true; reason: string };
6
6
 
7
7
  export type CanEditTimelineElement = (element: TimelineElement) => TimelineEditPermission;
8
8
 
9
+ /** What an effect save did: landed, refused before writing, or failed while writing. */
10
+ export type TimelineEditOutcome =
11
+ | { status: "saved" }
12
+ | { status: "refused" | "failed"; reason: string };
13
+
14
+ /** The project to save into, or why the save stops before it writes. */
15
+ export function projectForTimelineSave(
16
+ isRecording: boolean | undefined,
17
+ projectId: string | null,
18
+ showToast: (message: string, tone?: "error" | "info") => void,
19
+ ): string | TimelineEditOutcome {
20
+ if (isRecording) {
21
+ showToast("Cannot edit timeline while recording", "error");
22
+ return { status: "refused", reason: "Cannot edit timeline while recording" };
23
+ }
24
+ return projectId ?? { status: "failed", reason: "No project is open" };
25
+ }
26
+
27
+ function timelineEditRefusal(
28
+ canEdit: CanEditTimelineElement | undefined,
29
+ targets: readonly TimelineElement[],
30
+ ): string | null {
31
+ for (const element of targets) {
32
+ const verdict = canEdit?.(element) ?? true;
33
+ if (verdict !== true) return verdict.reason;
34
+ }
35
+ return null;
36
+ }
37
+
9
38
  /**
10
- * Refuses a write when any target element is blocked, toasting the host's
11
- * reason. Absent `canEdit` never refuses, so Studio itself is unchanged.
39
+ * The host's reason to refuse a write when any target element is blocked, toasted,
40
+ * or null. Absent `canEdit` never refuses, so Studio itself is unchanged.
12
41
  */
13
- export function useTimelineEditGate(
42
+ export function useTimelineEditRefusal(
14
43
  canEdit: CanEditTimelineElement | undefined,
15
44
  showToast: (message: string, tone?: "error" | "info") => void,
16
45
  ) {
17
46
  return useCallback(
18
- (targets: readonly TimelineElement[]): boolean => {
19
- if (!canEdit) return true;
20
- for (const element of targets) {
21
- const verdict = canEdit(element);
22
- if (verdict !== true) {
23
- showToast(verdict.reason, "error");
24
- return false;
25
- }
26
- }
27
- return true;
47
+ (targets: readonly TimelineElement[]): string | null => {
48
+ const reason = timelineEditRefusal(canEdit, targets);
49
+ if (reason !== null) showToast(reason, "error");
50
+ return reason;
28
51
  },
29
52
  [canEdit, showToast],
30
53
  );
31
54
  }
55
+
56
+ export function useTimelineEditGate(
57
+ canEdit: CanEditTimelineElement | undefined,
58
+ showToast: (message: string, tone?: "error" | "info") => void,
59
+ ) {
60
+ const refuse = useTimelineEditRefusal(canEdit, showToast);
61
+ return useCallback(
62
+ (targets: readonly TimelineElement[]): boolean => refuse(targets) === null,
63
+ [refuse],
64
+ );
65
+ }
@@ -18,6 +18,7 @@ import type {
18
18
  MutableRef,
19
19
  UseTimelineElementVisibilityEditingInput,
20
20
  } from "./timelineTrackVisibility";
21
+ import { projectForTimelineSave, type TimelineEditOutcome } from "./timelineEditPermission";
21
22
 
22
23
  function patchLiveElementAttribute(
23
24
  iframe: HTMLIFrameElement | null,
@@ -64,10 +65,10 @@ async function setElementAttribute({
64
65
  writeProjectFile,
65
66
  recordEdit,
66
67
  pendingTimelineEditPathRef,
67
- }: SetElementAttributeInput): Promise<string[]> {
68
+ }: SetElementAttributeInput): Promise<string[] | null> {
68
69
  const targetPath = element.sourceFile || activeCompPath || "index.html";
69
70
  const patchTarget = buildPatchTarget(element);
70
- if (!patchTarget) return [];
71
+ if (!patchTarget) return null;
71
72
 
72
73
  return persistElementAttribute({
73
74
  projectId,
@@ -99,7 +100,7 @@ export function useSetElementAttribute({
99
100
  attr: string,
100
101
  value: string | null,
101
102
  label: string,
102
- ) => Promise<void>;
103
+ ) => Promise<TimelineEditOutcome>;
103
104
  revertLive: (element: TimelineElement, attr: string) => void;
104
105
  } {
105
106
  const liveBeforeRef = useRef(new Map<string, string | null>());
@@ -130,15 +131,17 @@ export function useSetElementAttribute({
130
131
  [previewIframeRef, activeCompPath],
131
132
  );
132
133
  const setQuiet = useCallback(
133
- async (element: TimelineElement, attr: string, value: string | null, label: string) => {
134
- if (isRecordingRef?.current) {
135
- showToast("Cannot edit timeline while recording", "error");
136
- return;
137
- }
138
- const pid = projectIdRef.current;
139
- if (!pid) return;
134
+ async (
135
+ element: TimelineElement,
136
+ attr: string,
137
+ value: string | null,
138
+ label: string,
139
+ ): Promise<TimelineEditOutcome> => {
140
+ const pid = projectForTimelineSave(isRecordingRef?.current, projectIdRef.current, showToast);
141
+ if (typeof pid !== "string") return pid;
142
+ const liveKey = elementAttributeLiveKey(element, activeCompPath, attr);
140
143
  try {
141
- await setElementAttribute({
144
+ const written = await setElementAttribute({
142
145
  projectId: pid,
143
146
  activeCompPath,
144
147
  element,
@@ -150,12 +153,15 @@ export function useSetElementAttribute({
150
153
  recordEdit,
151
154
  pendingTimelineEditPathRef,
152
155
  });
153
- liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
156
+ liveBeforeRef.current.delete(liveKey);
157
+ if (written) return { status: "saved" };
158
+ return { status: "failed", reason: "This clip has no id to save it by" };
154
159
  } catch (error) {
155
160
  console.error("[Timeline] Failed to set element attribute", error);
156
161
  const message = error instanceof Error ? error.message : "Failed to update effect";
157
162
  showToast(message);
158
- liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
163
+ liveBeforeRef.current.delete(liveKey);
164
+ return { status: "failed", reason: message };
159
165
  }
160
166
  },
161
167
  [
@@ -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
+ });