@hyperframes/studio 0.8.105 → 0.8.106
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-tnfCiVdR.js → hyperframes-player-dl6K6ABp.js} +1 -1
- package/dist/assets/{index-DEDhPmPD.js → index-CMiYVGv5.js} +202 -202
- package/dist/assets/{index-zpe332hb.js → index-DKT5NF1Y.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-ooaZZhKf.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-ICTZJPQQ.js} +2 -1
- package/dist/chunk-ICTZJPQQ.js.map +1 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.html +11 -2
- package/dist/index.js +6601 -6022
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -9
- package/src/captions/keyboard.test.ts +6 -0
- package/src/captions/keyboard.ts +2 -1
- package/src/components/StudioHeader.dom.test.tsx +31 -9
- package/src/components/StudioHeader.tsx +4 -1
- package/src/components/ThemeToggle.tsx +99 -0
- package/src/components/editor/DomEditOverlay.tsx +37 -5
- package/src/components/editor/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
- package/src/components/editor/manualEditsDom.ts +33 -0
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -0
- package/src/components/editor/propertyPanelFont.test.tsx +80 -3
- package/src/components/editor/propertyPanelFont.tsx +69 -46
- package/src/components/editor/propertyPanelHelpers.ts +1 -2
- package/src/components/editor/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +31 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useInlineTextEditing.tsx +1 -1
- package/src/components/editor/useMotionPathData.test.tsx +64 -0
- package/src/components/editor/useMotionPathData.ts +5 -1
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.test.ts +32 -0
- package/src/hooks/useAppHotkeys.ts +1 -2
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- package/src/hooks/useDomEditSession.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +7 -43
- package/src/hooks/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.ts +16 -2
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +2 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/index.ts +2 -0
- package/src/livePreviewIframeExport.test.tsx +33 -0
- package/src/main.tsx +4 -1
- package/src/player/components/AudioWaveform.test.tsx +36 -15
- package/src/player/components/AudioWaveform.tsx +24 -7
- package/src/player/components/TimelineClip.test.tsx +38 -0
- package/src/player/components/TimelineClip.tsx +14 -6
- package/src/player/components/TimelineClipFades.test.tsx +376 -74
- package/src/player/components/TimelineClipFades.tsx +368 -137
- package/src/player/components/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
- package/src/player/hooks/useShadowPreviewReload.ts +72 -5
- package/src/player/lib/automationStoreSync.test.ts +18 -1
- package/src/player/lib/automationStoreSync.ts +29 -17
- package/src/player/sceneSwap.ts +18 -0
- package/src/styles/components.css +26 -0
- package/src/styles/studio.css +6 -0
- package/src/styles/theme.css +10 -0
- package/src/utils/clipboardPayload.ts +10 -1
- package/src/utils/studioPendingEdits.ts +21 -0
- package/src/utils/studioTheme.test.tsx +141 -0
- package/src/utils/studioTheme.ts +34 -0
- package/src/utils/studioUiPreferences.ts +3 -0
- package/src/utils/typingTarget.test.ts +8 -1
- package/src/utils/typingTarget.ts +5 -0
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/chunk-QADHJFDF.js.map +0 -1
|
@@ -60,6 +60,38 @@ afterEach(() => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe("keys on a focused slider", () => {
|
|
64
|
+
const slider = () => {
|
|
65
|
+
const el = document.createElement("div");
|
|
66
|
+
el.setAttribute("role", "slider");
|
|
67
|
+
document.body.append(el);
|
|
68
|
+
return el;
|
|
69
|
+
};
|
|
70
|
+
const from = (target: HTMLElement, init: KeyboardEventInit) => {
|
|
71
|
+
const event = new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true });
|
|
72
|
+
Object.defineProperty(event, "target", { value: target });
|
|
73
|
+
return event;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
it("leaves Delete to the slider instead of deleting the selected clip", () => {
|
|
77
|
+
usePlayerStore.setState({
|
|
78
|
+
elements: [bgmElement],
|
|
79
|
+
selectedElementId: "bgm",
|
|
80
|
+
selectedElementIds: new Set(["bgm"]),
|
|
81
|
+
});
|
|
82
|
+
const cb = callbacks();
|
|
83
|
+
dispatchPlainKey(from(slider(), { key: "Delete" }), "delete", cb);
|
|
84
|
+
expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
|
|
85
|
+
expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("still undoes with Cmd+Z while a slider has focus", () => {
|
|
89
|
+
const cb = callbacks();
|
|
90
|
+
dispatchModifierKey(from(slider(), { key: "z", metaKey: true }), "z", cb);
|
|
91
|
+
expect(cb.handleUndo).toHaveBeenCalledTimes(1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
63
95
|
describe("dispatchPlainKey — select leftward / rightward", () => {
|
|
64
96
|
const clips = [
|
|
65
97
|
{ ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
|
|
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
-
import { isTypingTarget } from "../utils/typingTarget";
|
|
6
5
|
import { useCaptionStore } from "../captions/store";
|
|
7
6
|
import {
|
|
8
7
|
applyCaptionModelToIframe,
|
|
@@ -220,7 +219,7 @@ export function useAppHotkeys({
|
|
|
220
219
|
dispatchModifierKey(event, key, cb);
|
|
221
220
|
return;
|
|
222
221
|
}
|
|
223
|
-
|
|
222
|
+
dispatchPlainKey(event, key, cb);
|
|
224
223
|
}, []);
|
|
225
224
|
|
|
226
225
|
// eslint-disable-next-line no-restricted-syntax
|
|
@@ -98,6 +98,7 @@ function mountClipboard(
|
|
|
98
98
|
sub = SUB,
|
|
99
99
|
bundle: (host: Element) => void = () => {},
|
|
100
100
|
saved = SAVED,
|
|
101
|
+
view = { path: "index.html" },
|
|
101
102
|
) {
|
|
102
103
|
const files: Record<string, string> = { "index.html": saved, "compositions/sub.html": sub };
|
|
103
104
|
const fail = { on: false };
|
|
@@ -115,7 +116,7 @@ function mountClipboard(
|
|
|
115
116
|
function Harness() {
|
|
116
117
|
api = useClipboard({
|
|
117
118
|
projectId: "p",
|
|
118
|
-
activeCompPath:
|
|
119
|
+
activeCompPath: view.path,
|
|
119
120
|
domEditSelectionRef: domSelectionRef,
|
|
120
121
|
showToast: () => {},
|
|
121
122
|
writeProjectFile,
|
|
@@ -134,7 +135,9 @@ function mountClipboard(
|
|
|
134
135
|
root = createRoot(document.createElement("div"));
|
|
135
136
|
act(() => root?.render(React.createElement(Harness)));
|
|
136
137
|
const clipboard = () => api as ReturnType<typeof useClipboard>;
|
|
138
|
+
const rerender = () => act(() => root?.render(React.createElement(Harness)));
|
|
137
139
|
return {
|
|
140
|
+
rerender,
|
|
138
141
|
clipboard,
|
|
139
142
|
writes,
|
|
140
143
|
deleted,
|
|
@@ -253,6 +256,17 @@ const SUB_SELECTION = {
|
|
|
253
256
|
sourceFile: "compositions/sub.html",
|
|
254
257
|
} as DomEditSelection;
|
|
255
258
|
|
|
259
|
+
const copySubNext = ({ clipboard, domSelectionRef }: ReturnType<typeof mountClipboard>) => {
|
|
260
|
+
clearSelection();
|
|
261
|
+
domSelectionRef.current = SUB_SELECTION;
|
|
262
|
+
clipboard().handleCopy();
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const expectSubPastedAlone = (writes: string[]) => {
|
|
266
|
+
expect(writes[0]).toContain(">Sub</h2>");
|
|
267
|
+
expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
|
|
268
|
+
};
|
|
269
|
+
|
|
256
270
|
describe("paste of an element styled by its id", () => {
|
|
257
271
|
it("gives the copy the look its original has", async () => {
|
|
258
272
|
clearSelection();
|
|
@@ -339,30 +353,24 @@ describe("copy order", () => {
|
|
|
339
353
|
|
|
340
354
|
it("pastes the second of two copies", async () => {
|
|
341
355
|
selectTitle();
|
|
342
|
-
const
|
|
343
|
-
clipboard().handleCopy();
|
|
356
|
+
const view = mountClipboard();
|
|
357
|
+
view.clipboard().handleCopy();
|
|
344
358
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
await clipboard().handlePaste();
|
|
349
|
-
expect(writes[0]).toContain(">Sub</h2>");
|
|
350
|
-
expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
|
|
359
|
+
copySubNext(view);
|
|
360
|
+
await view.clipboard().handlePaste();
|
|
361
|
+
expectSubPastedAlone(view.writes);
|
|
351
362
|
});
|
|
352
363
|
|
|
353
364
|
it("pastes the later copy even when the earlier copy's read lands last", async () => {
|
|
354
365
|
selectTitle();
|
|
355
|
-
const
|
|
356
|
-
delayMs["index.html"] = 20;
|
|
357
|
-
clipboard().handleCopy();
|
|
358
|
-
|
|
359
|
-
domSelectionRef.current = SUB_SELECTION;
|
|
360
|
-
clipboard().handleCopy();
|
|
366
|
+
const view = mountClipboard();
|
|
367
|
+
view.delayMs["index.html"] = 20;
|
|
368
|
+
view.clipboard().handleCopy();
|
|
369
|
+
copySubNext(view);
|
|
361
370
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
362
|
-
delayMs["index.html"] = 0;
|
|
363
|
-
await clipboard().handlePaste();
|
|
364
|
-
|
|
365
|
-
expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
|
|
371
|
+
view.delayMs["index.html"] = 0;
|
|
372
|
+
await view.clipboard().handlePaste();
|
|
373
|
+
expectSubPastedAlone(view.writes);
|
|
366
374
|
});
|
|
367
375
|
|
|
368
376
|
it("duplicates a hidden clip with its saved markup", async () => {
|
|
@@ -374,6 +382,78 @@ describe("copy order", () => {
|
|
|
374
382
|
});
|
|
375
383
|
});
|
|
376
384
|
|
|
385
|
+
describe("a copy in place, as the canvas makes one", () => {
|
|
386
|
+
// The copy's start and track: the saved original has no data-hf-id, its copy is minted one.
|
|
387
|
+
const copyAt = (html: string | undefined) => {
|
|
388
|
+
const copy = html?.match(/<h1[^>]*data-hf-id[^>]*>Title<\/h1>/)?.[0] ?? "";
|
|
389
|
+
return [copy.match(/data-start="([^"]*)"/)?.[1], copy.match(/data-track-index="([^"]*)"/)?.[1]];
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
it("duplicates at the clip's own time, on the next free track", async () => {
|
|
393
|
+
selectTitle();
|
|
394
|
+
const { clipboard, writes } = mountClipboard();
|
|
395
|
+
await clipboard().handleDuplicate({ inPlace: true });
|
|
396
|
+
expect(copyAt(writes[0])).toEqual(["2", "1"]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("pastes at the copied clip's time, not the playhead's", async () => {
|
|
400
|
+
selectTitle();
|
|
401
|
+
const { clipboard, writes } = mountClipboard();
|
|
402
|
+
clipboard().handleCopy();
|
|
403
|
+
await clipboard().handlePaste({ inPlace: true });
|
|
404
|
+
expect(copyAt(writes[0])).toEqual(["2", "1"]);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Host at 2 s: the clip's local 1-3 s shows as a 3-5 s master row, the clock the film's own timeline has.
|
|
408
|
+
const SUB_CLIP: TimelineElement = {
|
|
409
|
+
id: SUB_HF_ID,
|
|
410
|
+
hfId: SUB_HF_ID,
|
|
411
|
+
tag: "h2",
|
|
412
|
+
start: 3,
|
|
413
|
+
duration: 2,
|
|
414
|
+
track: 0,
|
|
415
|
+
authoredTrack: 0,
|
|
416
|
+
sourceFile: "compositions/sub.html",
|
|
417
|
+
parentCompositionStart: 2,
|
|
418
|
+
};
|
|
419
|
+
const pastedSubStarts = (html: string | undefined) =>
|
|
420
|
+
[...(html ?? "").matchAll(/<h2[^>]*data-hf-id[^>]*data-start="([^"]+)"/g)].map((m) => m[1]);
|
|
421
|
+
const mountSubClip = (currentTime = 0, view = { path: "index.html" }) => {
|
|
422
|
+
usePlayerStore.setState({
|
|
423
|
+
elements: [TITLE, SUB_CLIP],
|
|
424
|
+
selectedElementId: SUB_CLIP.id,
|
|
425
|
+
selectedElementIds: new Set([SUB_CLIP.id]),
|
|
426
|
+
currentTime,
|
|
427
|
+
});
|
|
428
|
+
return mountClipboard(null, SUB, (host) => host.setAttribute("data-start", "2"), SAVED, view);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
it("pastes a sub-composition's clip into the film at the moment the film shows it", async () => {
|
|
432
|
+
const { clipboard, writes } = mountSubClip(7);
|
|
433
|
+
clipboard().handleCopy();
|
|
434
|
+
await clipboard().handlePaste({ inPlace: true });
|
|
435
|
+
expect(pastedSubStarts(writes[0])).toEqual(["3"]);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("duplicates a sub-composition's clip at its own local time, on another track", async () => {
|
|
439
|
+
const { clipboard, writes } = mountSubClip();
|
|
440
|
+
await clipboard().handleDuplicate({ inPlace: true });
|
|
441
|
+
const copy = /<h2[^>]*data-hf-id[^>]*>/.exec(writes[0] ?? "")?.[0] ?? "";
|
|
442
|
+
expect(copy).toContain('data-start="1"');
|
|
443
|
+
expect(copy).toContain('data-track-index="1"');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("pastes at the playhead once the edited composition is another, whose clock differs", async () => {
|
|
447
|
+
const view = { path: "index.html" };
|
|
448
|
+
const { clipboard, writes, rerender } = mountSubClip(0.5, view);
|
|
449
|
+
clipboard().handleCopy();
|
|
450
|
+
view.path = "compositions/sub.html";
|
|
451
|
+
rerender();
|
|
452
|
+
await clipboard().handlePaste({ inPlace: true });
|
|
453
|
+
expect(pastedSubStarts(writes[0])).toEqual(["0.5"]);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
377
457
|
describe("duplicate of a clip inside a sub-composition", () => {
|
|
378
458
|
it("writes the copy at local time right after the original", async () => {
|
|
379
459
|
// Host at 2 s, so the clip's local 1-3 s shows as a 3-5 s master row.
|
|
@@ -30,6 +30,26 @@ import {
|
|
|
30
30
|
} from "../utils/authoredSource";
|
|
31
31
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
32
32
|
|
|
33
|
+
type InPlace = { inPlace?: boolean };
|
|
34
|
+
|
|
35
|
+
const pasteStart = (
|
|
36
|
+
payload: Extract<ClipboardPayload, { kind: "timeline-clip" }>,
|
|
37
|
+
targetPath: string,
|
|
38
|
+
playhead: number,
|
|
39
|
+
options?: InPlace,
|
|
40
|
+
) =>
|
|
41
|
+
options?.inPlace && payload.copiedInComposition === targetPath
|
|
42
|
+
? Math.min(...payload.clips.map((clip) => clip.start))
|
|
43
|
+
: playhead;
|
|
44
|
+
|
|
45
|
+
const pasteWords = (payload: ClipboardPayload) =>
|
|
46
|
+
payload.kind === "timeline-clip"
|
|
47
|
+
? {
|
|
48
|
+
label: clipLabel("Paste", payload.clips.length),
|
|
49
|
+
toast: clipToast("Pasted", payload.clips.length),
|
|
50
|
+
}
|
|
51
|
+
: { label: "Paste element", toast: "Pasted element" };
|
|
52
|
+
|
|
33
53
|
interface RecordEditInput {
|
|
34
54
|
label: string;
|
|
35
55
|
coalesceKey?: string;
|
|
@@ -313,6 +333,7 @@ export function useClipboard({
|
|
|
313
333
|
clips,
|
|
314
334
|
sourceFile,
|
|
315
335
|
projectId: projectIdRef.current ?? undefined,
|
|
336
|
+
copiedInComposition: activeCompPath || "index.html",
|
|
316
337
|
};
|
|
317
338
|
});
|
|
318
339
|
}, [activeCompPath, findSelectedClips, readClips, showToast]);
|
|
@@ -369,136 +390,136 @@ export function useClipboard({
|
|
|
369
390
|
|
|
370
391
|
// Two independent paste modes (timeline clip vs DOM element) behind one guarded save.
|
|
371
392
|
// fallow-ignore-next-line complexity
|
|
372
|
-
const handlePaste = useCallback(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
const pid = projectIdRef.current;
|
|
379
|
-
if (!pid) return;
|
|
380
|
-
|
|
381
|
-
const targetPath = activeCompPath || "index.html";
|
|
382
|
-
try {
|
|
383
|
-
let pastedIds: string[] = [];
|
|
384
|
-
const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid;
|
|
385
|
-
const paste = (originalContent: string) => {
|
|
386
|
-
if (payload.kind !== "timeline-clip")
|
|
387
|
-
return pasteElementHtml(originalContent, payload, fromThisFile);
|
|
388
|
-
const { currentTime, elements } = usePlayerStore.getState();
|
|
389
|
-
const pasted = pasteTimelineClips(
|
|
390
|
-
originalContent,
|
|
391
|
-
payload.clips,
|
|
392
|
-
currentTime,
|
|
393
|
-
elements,
|
|
394
|
-
fromThisFile,
|
|
395
|
-
);
|
|
396
|
-
pastedIds = pasted.ids;
|
|
397
|
-
// A clip pasted past the current composition end would exist in the
|
|
398
|
-
// file but never appear on the timeline or in playback/export (the
|
|
399
|
-
// root's data-duration is what actually bounds the render).
|
|
400
|
-
return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
const label =
|
|
404
|
-
payload.kind === "timeline-clip"
|
|
405
|
-
? clipLabel("Paste", payload.clips.length)
|
|
406
|
-
: "Paste element";
|
|
407
|
-
|
|
408
|
-
await saveProjectFilesWithHistory({
|
|
409
|
-
projectId: pid,
|
|
410
|
-
label,
|
|
411
|
-
files: { [targetPath]: paste },
|
|
412
|
-
readFile: (path) => readFileContent(pid, path),
|
|
413
|
-
writeFile: writeProjectFile,
|
|
414
|
-
recordEdit,
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
// CapCut: the pasted clip(s) become the selection; the playhead does not
|
|
418
|
-
// move. reloadPreview is a bare refresh-key bump with no selection
|
|
419
|
-
// snapshot of its own; calling setSelection before it is what makes the
|
|
420
|
-
// post-reload store state land on the pasted ids instead of stale ones.
|
|
421
|
-
if (pastedIds.length > 0) {
|
|
422
|
-
usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
|
|
393
|
+
const handlePaste = useCallback(
|
|
394
|
+
async (options?: InPlace) => {
|
|
395
|
+
const payload = await clipboardRef.current;
|
|
396
|
+
if (!payload) {
|
|
397
|
+
showToast("Nothing to paste.", "info");
|
|
398
|
+
return;
|
|
423
399
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
400
|
+
const pid = projectIdRef.current;
|
|
401
|
+
if (!pid) return;
|
|
402
|
+
|
|
403
|
+
const targetPath = activeCompPath || "index.html";
|
|
404
|
+
try {
|
|
405
|
+
let pastedIds: string[] = [];
|
|
406
|
+
const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid;
|
|
407
|
+
const paste = (originalContent: string) => {
|
|
408
|
+
if (payload.kind !== "timeline-clip")
|
|
409
|
+
return pasteElementHtml(originalContent, payload, fromThisFile);
|
|
410
|
+
const { currentTime, elements } = usePlayerStore.getState();
|
|
411
|
+
const pasted = pasteTimelineClips(
|
|
412
|
+
originalContent,
|
|
413
|
+
payload.clips,
|
|
414
|
+
pasteStart(payload, targetPath, currentTime, options),
|
|
415
|
+
elements,
|
|
416
|
+
fromThisFile,
|
|
417
|
+
);
|
|
418
|
+
pastedIds = pasted.ids;
|
|
419
|
+
// A clip pasted past the current composition end would exist in the
|
|
420
|
+
// file but never appear on the timeline or in playback/export (the
|
|
421
|
+
// root's data-duration is what actually bounds the render).
|
|
422
|
+
return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
423
|
+
};
|
|
436
424
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
425
|
+
const { label, toast } = pasteWords(payload);
|
|
426
|
+
|
|
427
|
+
await saveProjectFilesWithHistory({
|
|
428
|
+
projectId: pid,
|
|
429
|
+
label,
|
|
430
|
+
files: { [targetPath]: paste },
|
|
431
|
+
readFile: (path) => readFileContent(pid, path),
|
|
432
|
+
writeFile: writeProjectFile,
|
|
433
|
+
recordEdit,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// CapCut: the pasted clip(s) become the selection; the playhead does not
|
|
437
|
+
// move. reloadPreview is a bare refresh-key bump with no selection
|
|
438
|
+
// snapshot of its own; calling setSelection before it is what makes the
|
|
439
|
+
// post-reload store state land on the pasted ids instead of stale ones.
|
|
440
|
+
if (pastedIds.length > 0) {
|
|
441
|
+
usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
|
|
442
|
+
}
|
|
443
|
+
reloadPreview();
|
|
444
|
+
showToast(toast, "info");
|
|
445
|
+
} catch (error) {
|
|
446
|
+
const message = error instanceof Error ? error.message : "Failed to paste";
|
|
447
|
+
showToast(message);
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
[activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile],
|
|
451
|
+
);
|
|
455
452
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
};
|
|
453
|
+
// Duplicates the selection right after it, or with `inPlace` at its own time on a track of its own,
|
|
454
|
+
// without touching the clipboard — a pending copy must survive a Cmd+D. Shares
|
|
455
|
+
// pasteTimelineClips with handlePaste; only the anchor and clip source differ (the
|
|
456
|
+
// selection's end or start here, the playhead or the copy's own time there).
|
|
457
|
+
const handleDuplicate = useCallback(
|
|
458
|
+
async (options?: InPlace): Promise<boolean> => {
|
|
459
|
+
const targets = findSelectedClips();
|
|
460
|
+
if (!targets) return false;
|
|
461
|
+
const pid = projectIdRef.current;
|
|
462
|
+
if (!pid) return false;
|
|
463
|
+
|
|
464
|
+
const { elements } = targets;
|
|
465
|
+
const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html";
|
|
466
|
+
const targetPath = pathOf(elements[0]!);
|
|
467
|
+
// The copy lands in targetPath's own clock, so anchor and lane check are local to it.
|
|
468
|
+
const anchorTime = toAuthoredStart(
|
|
469
|
+
elements[0]!,
|
|
470
|
+
options?.inPlace
|
|
471
|
+
? Math.min(...elements.map((el) => el.start))
|
|
472
|
+
: Math.max(...elements.map((el) => el.start + el.duration)),
|
|
473
|
+
);
|
|
468
474
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
475
|
+
try {
|
|
476
|
+
const clips = await readClips(targets);
|
|
477
|
+
let ids: string[] = [];
|
|
478
|
+
const duplicate = (originalContent: string) => {
|
|
479
|
+
const liveElements = usePlayerStore
|
|
480
|
+
.getState()
|
|
481
|
+
.elements.filter((el) => pathOf(el) === targetPath)
|
|
482
|
+
.map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
|
|
483
|
+
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
|
|
484
|
+
ids = pasted.ids;
|
|
485
|
+
return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
486
|
+
};
|
|
477
487
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
488
|
+
await saveProjectFilesWithHistory({
|
|
489
|
+
projectId: pid,
|
|
490
|
+
label: clipLabel("Duplicate", clips.length),
|
|
491
|
+
files: { [targetPath]: duplicate },
|
|
492
|
+
readFile: (path) => readFileContent(pid, path),
|
|
493
|
+
writeFile: writeProjectFile,
|
|
494
|
+
recordEdit,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// The duplicate becomes the selection, mirroring CapCut's own paste
|
|
498
|
+
// convention — there is no CapCut Duplicate to match directly (Cmd+D is
|
|
499
|
+
// a no-op there); this is our own choice for consistency with paste.
|
|
500
|
+
// Select before reloading, same reason as handlePaste above.
|
|
501
|
+
if (ids.length > 0) {
|
|
502
|
+
usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath)));
|
|
503
|
+
}
|
|
504
|
+
reloadPreview();
|
|
505
|
+
showToast(clipToast("Duplicated", clips.length), "info");
|
|
506
|
+
return true;
|
|
507
|
+
} catch (error) {
|
|
508
|
+
const message = error instanceof Error ? error.message : "Failed to duplicate";
|
|
509
|
+
showToast(message);
|
|
510
|
+
return false;
|
|
484
511
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
readClips,
|
|
497
|
-
recordEdit,
|
|
498
|
-
reloadPreview,
|
|
499
|
-
showToast,
|
|
500
|
-
writeProjectFile,
|
|
501
|
-
]);
|
|
512
|
+
},
|
|
513
|
+
[
|
|
514
|
+
activeCompPath,
|
|
515
|
+
findSelectedClips,
|
|
516
|
+
readClips,
|
|
517
|
+
recordEdit,
|
|
518
|
+
reloadPreview,
|
|
519
|
+
showToast,
|
|
520
|
+
writeProjectFile,
|
|
521
|
+
],
|
|
522
|
+
);
|
|
502
523
|
|
|
503
524
|
const handleCut = useCallback(async (): Promise<boolean> => {
|
|
504
525
|
const selected = getSelectedElements();
|