@hyperframes/studio 0.8.78 → 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.
- package/dist/assets/{hyperframes-player-BdnQGwG5.js → hyperframes-player-Bx_Kw4Ey.js} +3 -3
- package/dist/assets/{index-C_n__Uh3.js → index-CJP92F4-.js} +1 -1
- package/dist/assets/{index-CX_4MYbk.js → index-DBKySIo7.js} +187 -187
- package/dist/assets/{index-DdpuHaGN.js → index-ORZXYB0k.js} +1 -1
- package/dist/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
- package/dist/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
- package/dist/index.d.ts +22 -15
- package/dist/index.html +1 -1
- package/dist/index.js +452 -256
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +8 -2
- package/src/hooks/timelineAudioGroupVolume.ts +21 -11
- package/src/hooks/timelineEditPermission.ts +47 -13
- package/src/hooks/timelineElementFxAttribute.ts +19 -13
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useFrameCapture.test.tsx +67 -0
- package/src/hooks/useFrameCapture.ts +18 -9
- package/src/hooks/usePersistentEditHistory.test.ts +2 -2
- package/src/hooks/usePersistentEditHistory.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +59 -0
- package/src/hooks/useTimelineEditing.ts +42 -33
- package/src/index.ts +1 -0
- package/src/player/components/Player.test.ts +5 -0
- package/src/player/components/Player.tsx +1 -0
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/timelineCallbacks.ts +3 -2
- package/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +75 -0
- package/src/player/hooks/useTimelinePlayer.play.test.ts +36 -0
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +12 -4
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /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.
|
|
3
|
+
"version": "0.8.79",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -52,12 +52,12 @@
|
|
|
52
52
|
"dockview-react": "8.3.1",
|
|
53
53
|
"gsap": "^3.13.0",
|
|
54
54
|
"mediabunny": "^1.45.3",
|
|
55
|
-
"@hyperframes/
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
59
|
-
"@hyperframes/
|
|
60
|
-
"@hyperframes/
|
|
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"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"vite": "^6.4.2",
|
|
74
74
|
"vitest": "^4.1.11",
|
|
75
75
|
"zustand": "^5.0.0",
|
|
76
|
-
"@hyperframes/producer": "0.8.
|
|
76
|
+
"@hyperframes/producer": "0.8.79"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
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: (
|
|
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: (
|
|
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 (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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[]):
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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<
|
|
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 (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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(
|
|
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(
|
|
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
|
+
});
|