@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.
- package/dist/assets/hyperframes-player-CgnoxDoD.js +464 -0
- package/dist/assets/{index-CX_4MYbk.js → index-CsG24Jub.js} +190 -187
- package/dist/assets/{index-C_n__Uh3.js → index-DZGVvqDT.js} +1 -1
- package/dist/assets/{index-DdpuHaGN.js → index-J9HXe7mU.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 +40 -16
- package/dist/index.html +1 -1
- package/dist/index.js +1918 -1408
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -1
- package/src/components/editor/TimelineFxPopover.test.tsx +1 -1
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsParsing.ts +4 -0
- package/src/components/editor/useFxAudition.ts +3 -3
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -2
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/TimelineEditContext.tsx +2 -0
- package/src/hooks/liveLanes.ts +147 -0
- package/src/hooks/timelineAudioGroupVolume.ts +97 -53
- package/src/hooks/timelineEditPermission.ts +55 -13
- package/src/hooks/timelineEditingHelpers.test.ts +16 -0
- package/src/hooks/timelineEditingHelpers.ts +69 -43
- package/src/hooks/timelineElementFxAttribute.ts +92 -40
- package/src/hooks/useAppHotkeys.ts +1 -1
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +125 -21
- package/src/hooks/useAutomationSelectionKeyboard.ts +21 -25
- package/src/hooks/useBlockHandlers.test.tsx +65 -0
- package/src/hooks/useBlockHandlers.ts +7 -4
- package/src/hooks/useClipboard.source.test.ts +340 -0
- package/src/hooks/useClipboard.ts +141 -62
- package/src/hooks/useDomEditAttributeCommits.ts +3 -17
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useEditHistoryActions.ts +4 -3
- package/src/hooks/useElementPicker.test.ts +172 -0
- package/src/hooks/useElementPicker.ts +141 -93
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +49 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +15 -6
- 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 +59 -47
- package/src/hooks/useTimelineGroupEditing.ts +2 -1
- package/src/hooks/useToast.ts +2 -1
- package/src/index.ts +1 -0
- package/src/player/components/ImageThumbnail.tsx +3 -12
- package/src/player/components/Player.test.ts +28 -0
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +38 -0
- package/src/player/components/TimelineAutomationLane.tsx +18 -13
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +0 -2
- package/src/player/components/TimelineClipFades.test.tsx +24 -3
- package/src/player/components/TimelineClipFades.tsx +5 -2
- package/src/player/components/TimelineFxButton.tsx +1 -1
- package/src/player/components/TimelineGroupHeader.tsx +1 -1
- package/src/player/components/TimelineGroupRow.test.tsx +0 -1
- package/src/player/components/TimelineGroupRow.tsx +17 -11
- package/src/player/components/TimelineTrackHeader.tsx +21 -6
- package/src/player/components/VideoThumbnail.test.tsx +13 -0
- package/src/player/components/VideoThumbnail.tsx +1 -1
- package/src/player/components/groupAutomationElement.ts +9 -1
- package/src/player/components/timelineCallbacks.ts +5 -2
- package/src/player/components/timelineClipDragPreview.test.ts +41 -0
- package/src/player/components/timelineClipDragPreview.ts +15 -24
- package/src/player/components/timelineEditing.test.ts +99 -1
- package/src/player/components/timelineEditing.ts +5 -8
- package/src/player/components/timelineGroupEditing.test.ts +27 -0
- package/src/player/components/timelineGroupEditing.ts +18 -15
- package/src/player/components/timelineStackingSync.ts +5 -5
- package/src/player/components/timelineZones.ts +2 -3
- package/src/player/components/useAutomationEdgeStretch.ts +4 -4
- package/src/player/components/useAutomationLaneGestures.ts +1 -1
- package/src/player/components/useAutomationLanes.save.test.tsx +1406 -0
- package/src/player/components/useAutomationLanes.ts +58 -74
- 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/automationStoreSync.ts +30 -3
- package/src/player/lib/thumbnailImageDecoder.test.ts +82 -0
- package/src/player/lib/thumbnailImageDecoder.ts +73 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +102 -6
- package/src/player/lib/thumbnailVideoDecoder.ts +20 -5
- package/src/player/lib/timelineDOM.test.ts +28 -8
- package/src/player/lib/timelineElementHelpers.ts +3 -4
- package/src/player/lib/timelineGroupInfo.ts +23 -23
- package/src/utils/authoredSource.ts +92 -0
- package/src/utils/gsapSoftReload.test.ts +29 -0
- package/src/utils/gsapSoftReload.ts +3 -7
- package/src/utils/studioFileHistory.ts +36 -30
- package/dist/assets/hyperframes-player-BdnQGwG5.js +0 -464
- package/src/player/components/automationGestureKeys.test.ts +0 -49
- package/src/player/components/automationGestureKeys.ts +0 -49
- /package/dist/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
- /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
|
|
73
|
+
function getSelectedDomElement(
|
|
66
74
|
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
|
|
67
75
|
selection: DomEditSelection,
|
|
68
76
|
activeCompositionPath: string | null,
|
|
69
|
-
):
|
|
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)
|
|
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
|
|
249
|
+
const findSelectedClips = useCallback((): {
|
|
207
250
|
elements: TimelineElement[];
|
|
208
|
-
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
302
|
+
return null;
|
|
257
303
|
}
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
343
|
-
if (!
|
|
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
|
|
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
|
-
|
|
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
|
|
394
|
-
|
|
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
|
-
}, [
|
|
484
|
+
}, [
|
|
485
|
+
copyToClipboard,
|
|
486
|
+
domEditSelectionRef,
|
|
487
|
+
handleTimelineElementsDelete,
|
|
488
|
+
handleDomEditElementDelete,
|
|
489
|
+
]);
|
|
411
490
|
|
|
412
491
|
const canPaste = useCallback(() => clipboardRef.current !== null, []);
|
|
413
492
|
|