@hyperframes/studio 0.8.48 → 0.8.50
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-CxxDxHTW.js +464 -0
- package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.html +2 -2
- package/dist/index.js +6209 -5924
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +4 -2
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +20 -51
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +10 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -2,14 +2,23 @@ import { useCallback, useRef } from "react";
|
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import { usePlayerStore } from "../player";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type ClipboardPayload,
|
|
7
|
+
type TimelineClipboardClip,
|
|
8
|
+
ID_ATTR_RE,
|
|
9
|
+
deduplicateIds,
|
|
10
|
+
insertAsSibling,
|
|
11
|
+
} from "../utils/clipboardPayload";
|
|
6
12
|
import { collectHtmlIds } from "../utils/studioHelpers";
|
|
7
13
|
import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
|
|
14
|
+
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
8
15
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
9
16
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
10
17
|
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
11
18
|
import { findElementForSelection } from "../components/editor/domEditingElement";
|
|
12
|
-
import { readFileContent } from "./timelineEditingHelpers";
|
|
19
|
+
import { findTimelineElementInIframe, readFileContent } from "./timelineEditingHelpers";
|
|
20
|
+
import { buildTimelineElementKey } from "../player/lib/timelineElementHelpers";
|
|
21
|
+
import { timeRangesOverlap } from "../player/components/timelineCollision";
|
|
13
22
|
|
|
14
23
|
interface RecordEditInput {
|
|
15
24
|
label: string;
|
|
@@ -26,11 +35,34 @@ interface UseClipboardOptions {
|
|
|
26
35
|
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
27
36
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
28
37
|
reloadPreview: () => void;
|
|
29
|
-
|
|
38
|
+
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
30
39
|
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
|
|
31
40
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
32
41
|
}
|
|
33
42
|
|
|
43
|
+
/** The timeline element(s) a copy/cut/duplicate acts on: the multi-selection
|
|
44
|
+
* when one is active, else the single primary selection. */
|
|
45
|
+
function getSelectedElements(): TimelineElement[] {
|
|
46
|
+
const { selectedElementId, selectedElementIds, elements } = usePlayerStore.getState();
|
|
47
|
+
if (!selectedElementId) return [];
|
|
48
|
+
const ids = selectedElementIds.size > 1 ? selectedElementIds : new Set([selectedElementId]);
|
|
49
|
+
return elements.filter((el) => ids.has(el.key ?? el.id));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function elementKey(domId: string, sourceFile: string): string {
|
|
53
|
+
return buildTimelineElementKey({ id: domId, fallbackIndex: 0, domId, sourceFile });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** "Paste clip" / "Paste clips" — the edit-history label, no count shown. */
|
|
57
|
+
function clipLabel(verb: string, clipCount: number): string {
|
|
58
|
+
return `${verb} ${clipCount > 1 ? "clips" : "clip"}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** "Pasted clip" / "Pasted 3 clips" — the toast message, count shown once plural. */
|
|
62
|
+
function clipToast(verbPast: string, clipCount: number): string {
|
|
63
|
+
return clipCount > 1 ? `${verbPast} ${clipCount} clips` : `${verbPast} clip`;
|
|
64
|
+
}
|
|
65
|
+
|
|
34
66
|
function getElementOuterHtml(
|
|
35
67
|
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
|
|
36
68
|
selection: DomEditSelection,
|
|
@@ -47,6 +79,94 @@ function getElementOuterHtml(
|
|
|
47
79
|
return findElementForSelection(doc, selection, activeCompositionPath)?.outerHTML ?? null;
|
|
48
80
|
}
|
|
49
81
|
|
|
82
|
+
export interface PlacedClip {
|
|
83
|
+
track: number;
|
|
84
|
+
start: number;
|
|
85
|
+
duration: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tracksOverlap(a: PlacedClip, b: PlacedClip): boolean {
|
|
89
|
+
return (
|
|
90
|
+
a.track === b.track &&
|
|
91
|
+
timeRangesOverlap(a.start, a.start + a.duration, b.start, b.start + b.duration)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** CapCut: keeps the preferred track if free at the new time, else the next
|
|
96
|
+
* unused track index. Exported so placement is tested with values directly. */
|
|
97
|
+
export function resolveFreeTrack(preferred: PlacedClip, taken: readonly PlacedClip[]): number {
|
|
98
|
+
if (!taken.some((t) => tracksOverlap(preferred, t))) return preferred.track;
|
|
99
|
+
const maxTrack = taken.reduce((max, t) => Math.max(max, t.track), preferred.track);
|
|
100
|
+
return maxTrack + 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Strips data-hf-id from the root and every descendant so a clone re-mints
|
|
104
|
+
* its own. DOMParser, not a bracket-scoped regex, so a `>` inside an earlier
|
|
105
|
+
* attribute value or a single-quoted id can't defeat the strip. */
|
|
106
|
+
function stripHfIds(html: string, parser: DOMParser): string {
|
|
107
|
+
const root = parser.parseFromString(html, "text/html").body.firstElementChild;
|
|
108
|
+
if (!root) {
|
|
109
|
+
// A tag the HTML parser hoists out of <body> (title/meta/style/base/link)
|
|
110
|
+
// never reaches the walk above — fall back to a direct strip so
|
|
111
|
+
// data-hf-id still can't survive, even though no real clip root is one
|
|
112
|
+
// of these tags today.
|
|
113
|
+
return html.replace(/\sdata-hf-id=("[^"]*"|'[^']*')/g, "");
|
|
114
|
+
}
|
|
115
|
+
root.querySelectorAll("[data-hf-id]").forEach((el) => el.removeAttribute("data-hf-id"));
|
|
116
|
+
root.removeAttribute("data-hf-id");
|
|
117
|
+
return root.outerHTML;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Shared insertion path for paste and duplicate, anchored at the playhead or
|
|
121
|
+
* the selection's end respectively. Returns the final ids so the caller can
|
|
122
|
+
* select what it just placed, and the furthest end any clip lands at so the
|
|
123
|
+
* caller can grow the root composition's duration to cover it. */
|
|
124
|
+
export function pasteTimelineClips(
|
|
125
|
+
content: string,
|
|
126
|
+
clips: readonly TimelineClipboardClip[],
|
|
127
|
+
anchorTime: number,
|
|
128
|
+
liveElements: readonly TimelineElement[],
|
|
129
|
+
): { content: string; ids: string[]; requiredEnd: number } {
|
|
130
|
+
const groupMinStart = Math.min(...clips.map((c) => c.start));
|
|
131
|
+
let existingIds = collectHtmlIds(content);
|
|
132
|
+
const taken: PlacedClip[] = liveElements.map((el) => ({
|
|
133
|
+
track: el.authoredTrack ?? el.track,
|
|
134
|
+
start: el.start,
|
|
135
|
+
duration: el.duration,
|
|
136
|
+
}));
|
|
137
|
+
const ids: string[] = [];
|
|
138
|
+
let result = content;
|
|
139
|
+
let requiredEnd = 0;
|
|
140
|
+
const domParser = new DOMParser();
|
|
141
|
+
for (const clip of clips) {
|
|
142
|
+
const stripped = stripHfIds(clip.html, domParser);
|
|
143
|
+
const deduped = deduplicateIds(stripped, existingIds);
|
|
144
|
+
existingIds = existingIds.concat(collectHtmlIds(deduped));
|
|
145
|
+
const newStart = anchorTime + (clip.start - groupMinStart);
|
|
146
|
+
const newTrack = resolveFreeTrack(
|
|
147
|
+
{ track: clip.track, start: newStart, duration: clip.duration },
|
|
148
|
+
taken,
|
|
149
|
+
);
|
|
150
|
+
taken.push({ track: newTrack, start: newStart, duration: clip.duration });
|
|
151
|
+
requiredEnd = Math.max(requiredEnd, newStart + clip.duration);
|
|
152
|
+
|
|
153
|
+
// Only rewrite the outermost opening tag. The non-global regex matches
|
|
154
|
+
// the first occurrence, which is always in the root tag since outerHTML
|
|
155
|
+
// starts with it. Nested clips keep their own timing and track.
|
|
156
|
+
const rootTagEnd = deduped.indexOf(">");
|
|
157
|
+
const rootTag = rootTagEnd >= 0 ? deduped.slice(0, rootTagEnd + 1) : deduped;
|
|
158
|
+
const patchedRootTag = rootTag
|
|
159
|
+
.replace(/data-start="[^"]*"/, `data-start="${formatTimelineAttributeNumber(newStart)}"`)
|
|
160
|
+
.replace(/data-track-index="[^"]*"/, `data-track-index="${newTrack}"`);
|
|
161
|
+
const withPatched = patchedRootTag + deduped.slice(rootTagEnd + 1);
|
|
162
|
+
result = insertTimelineAssetIntoSource(result, withPatched);
|
|
163
|
+
|
|
164
|
+
const id = patchedRootTag.match(ID_ATTR_RE)?.[1];
|
|
165
|
+
if (id) ids.push(id);
|
|
166
|
+
}
|
|
167
|
+
return { content: result, ids, requiredEnd };
|
|
168
|
+
}
|
|
169
|
+
|
|
50
170
|
export function useClipboard({
|
|
51
171
|
projectId,
|
|
52
172
|
activeCompPath,
|
|
@@ -55,7 +175,7 @@ export function useClipboard({
|
|
|
55
175
|
writeProjectFile,
|
|
56
176
|
recordEdit,
|
|
57
177
|
reloadPreview,
|
|
58
|
-
|
|
178
|
+
handleTimelineElementsDelete,
|
|
59
179
|
handleDomEditElementDelete,
|
|
60
180
|
previewIframeRef,
|
|
61
181
|
}: UseClipboardOptions) {
|
|
@@ -63,47 +183,50 @@ export function useClipboard({
|
|
|
63
183
|
const projectIdRef = useRef(projectId);
|
|
64
184
|
projectIdRef.current = projectId;
|
|
65
185
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (!element) return false;
|
|
76
|
-
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
77
|
-
|
|
78
|
-
let html: string | null = null;
|
|
79
|
-
try {
|
|
80
|
-
const doc = previewIframeRef.current?.contentDocument;
|
|
81
|
-
if (doc) {
|
|
82
|
-
html =
|
|
83
|
-
findElementForSelection(
|
|
84
|
-
doc,
|
|
85
|
-
{
|
|
86
|
-
hfId: element.hfId,
|
|
87
|
-
id: element.domId,
|
|
88
|
-
selector: element.selector,
|
|
89
|
-
selectorIndex: element.selectorIndex,
|
|
90
|
-
sourceFile: targetPath,
|
|
91
|
-
},
|
|
92
|
-
activeCompPath,
|
|
93
|
-
)?.outerHTML ?? null;
|
|
94
|
-
}
|
|
95
|
-
} catch {
|
|
96
|
-
// cross-origin frame
|
|
97
|
-
}
|
|
186
|
+
// Resolved through findTimelineElementInIframe, the same composition-aware
|
|
187
|
+
// lookup every other timeline editor uses — unlike findElementForSelection,
|
|
188
|
+
// it can address a composition-instance clip's root.
|
|
189
|
+
const collectSelectedClips = useCallback((): {
|
|
190
|
+
elements: TimelineElement[];
|
|
191
|
+
clips: TimelineClipboardClip[];
|
|
192
|
+
} | null => {
|
|
193
|
+
const selected = getSelectedElements();
|
|
194
|
+
if (selected.length === 0) return null;
|
|
98
195
|
|
|
196
|
+
const clips: TimelineClipboardClip[] = [];
|
|
197
|
+
for (const element of selected) {
|
|
198
|
+
const html =
|
|
199
|
+
findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath)?.outerHTML ??
|
|
200
|
+
null;
|
|
99
201
|
if (!html) {
|
|
100
|
-
showToast(
|
|
101
|
-
return
|
|
202
|
+
showToast(`Unable to copy "${element.label ?? element.id}".`, "info");
|
|
203
|
+
return null;
|
|
102
204
|
}
|
|
205
|
+
clips.push({
|
|
206
|
+
html,
|
|
207
|
+
start: element.start,
|
|
208
|
+
duration: element.duration,
|
|
209
|
+
// authoredTrack, not track: `track` can be a display-lane number
|
|
210
|
+
// remapped by normalizeToZones, and writing THAT into data-track-index
|
|
211
|
+
// re-targets the wrong track in the sparse authored file.
|
|
212
|
+
track: element.authoredTrack ?? element.track,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return { elements: selected, clips };
|
|
216
|
+
}, [activeCompPath, previewIframeRef, showToast]);
|
|
103
217
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
218
|
+
// Two independent copy modes (timeline clip vs DOM element) in one handler.
|
|
219
|
+
// fallow-ignore-next-line complexity
|
|
220
|
+
const handleCopy = useCallback((): boolean => {
|
|
221
|
+
if (usePlayerStore.getState().selectedElementId) {
|
|
222
|
+
const result = collectSelectedClips();
|
|
223
|
+
if (!result) return false;
|
|
224
|
+
const targetPath = result.elements[0]?.sourceFile || activeCompPath || "index.html";
|
|
225
|
+
clipboardRef.current = { kind: "timeline-clip", clips: result.clips, sourceFile: targetPath };
|
|
226
|
+
showToast(
|
|
227
|
+
result.clips.length > 1 ? `Copied ${result.clips.length} clips` : "Copied clip",
|
|
228
|
+
"info",
|
|
229
|
+
);
|
|
107
230
|
return true;
|
|
108
231
|
}
|
|
109
232
|
|
|
@@ -116,21 +239,23 @@ export function useClipboard({
|
|
|
116
239
|
return false;
|
|
117
240
|
}
|
|
118
241
|
const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
|
|
119
|
-
|
|
242
|
+
clipboardRef.current = {
|
|
120
243
|
kind: "dom-element",
|
|
121
244
|
html,
|
|
122
245
|
sourceFile: targetPath,
|
|
123
246
|
originSelector: domSelection.selector,
|
|
124
247
|
originSelectorIndex: domSelection.selectorIndex,
|
|
125
248
|
};
|
|
126
|
-
clipboardRef.current = payload;
|
|
127
249
|
showToast("Copied element", "info");
|
|
128
250
|
return true;
|
|
129
251
|
}
|
|
130
252
|
|
|
253
|
+
showToast("Nothing selected to copy.", "info");
|
|
131
254
|
return false;
|
|
132
|
-
}, [activeCompPath, domEditSelectionRef, previewIframeRef, showToast]);
|
|
255
|
+
}, [activeCompPath, collectSelectedClips, domEditSelectionRef, previewIframeRef, showToast]);
|
|
133
256
|
|
|
257
|
+
// Two independent paste modes (timeline clip vs DOM element) behind one guarded save.
|
|
258
|
+
// fallow-ignore-next-line complexity
|
|
134
259
|
const handlePaste = useCallback(async () => {
|
|
135
260
|
const payload = clipboardRef.current;
|
|
136
261
|
if (!payload) {
|
|
@@ -143,24 +268,19 @@ export function useClipboard({
|
|
|
143
268
|
const targetPath = activeCompPath || "index.html";
|
|
144
269
|
try {
|
|
145
270
|
const originalContent = await readFileContent(pid, targetPath);
|
|
146
|
-
const existingIds = collectHtmlIds(originalContent);
|
|
147
|
-
const deduped = deduplicateIds(payload.html, existingIds);
|
|
148
|
-
|
|
149
271
|
let patchedContent: string;
|
|
272
|
+
let pastedIds: string[] = [];
|
|
273
|
+
|
|
150
274
|
if (payload.kind === "timeline-clip") {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
/data-start="[^"]*"/,
|
|
159
|
-
`data-start="${formatTimelineAttributeNumber(currentTime)}"`,
|
|
160
|
-
);
|
|
161
|
-
const withNewStart = patchedRootTag + deduped.slice(rootTagEnd + 1);
|
|
162
|
-
patchedContent = insertTimelineAssetIntoSource(originalContent, withNewStart);
|
|
275
|
+
const { currentTime, elements } = usePlayerStore.getState();
|
|
276
|
+
const pasted = pasteTimelineClips(originalContent, payload.clips, currentTime, elements);
|
|
277
|
+
// A clip pasted past the current composition end would exist in the
|
|
278
|
+
// file but never appear on the timeline or in playback/export (the
|
|
279
|
+
// root's data-duration is what actually bounds the render).
|
|
280
|
+
patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
281
|
+
pastedIds = pasted.ids;
|
|
163
282
|
} else {
|
|
283
|
+
const deduped = deduplicateIds(payload.html, collectHtmlIds(originalContent));
|
|
164
284
|
patchedContent = insertAsSibling(
|
|
165
285
|
originalContent,
|
|
166
286
|
deduped,
|
|
@@ -169,9 +289,14 @@ export function useClipboard({
|
|
|
169
289
|
);
|
|
170
290
|
}
|
|
171
291
|
|
|
292
|
+
const label =
|
|
293
|
+
payload.kind === "timeline-clip"
|
|
294
|
+
? clipLabel("Paste", payload.clips.length)
|
|
295
|
+
: "Paste element";
|
|
296
|
+
|
|
172
297
|
await saveProjectFilesWithHistory({
|
|
173
298
|
projectId: pid,
|
|
174
|
-
label
|
|
299
|
+
label,
|
|
175
300
|
kind: "timeline" as EditHistoryKind,
|
|
176
301
|
files: { [targetPath]: patchedContent },
|
|
177
302
|
readFile: async () => originalContent,
|
|
@@ -179,25 +304,92 @@ export function useClipboard({
|
|
|
179
304
|
recordEdit,
|
|
180
305
|
});
|
|
181
306
|
|
|
307
|
+
// CapCut: the pasted clip(s) become the selection; the playhead does not
|
|
308
|
+
// move. reloadPreview is a bare refresh-key bump with no selection
|
|
309
|
+
// snapshot of its own; calling setSelection before it is what makes the
|
|
310
|
+
// post-reload store state land on the pasted ids instead of stale ones.
|
|
311
|
+
if (pastedIds.length > 0) {
|
|
312
|
+
usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
|
|
313
|
+
}
|
|
182
314
|
reloadPreview();
|
|
183
|
-
showToast(
|
|
315
|
+
showToast(
|
|
316
|
+
payload.kind === "timeline-clip"
|
|
317
|
+
? clipToast("Pasted", payload.clips.length)
|
|
318
|
+
: "Pasted element",
|
|
319
|
+
"info",
|
|
320
|
+
);
|
|
184
321
|
} catch (error) {
|
|
185
322
|
const message = error instanceof Error ? error.message : "Failed to paste";
|
|
186
323
|
showToast(message);
|
|
187
324
|
}
|
|
188
325
|
}, [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile]);
|
|
189
326
|
|
|
327
|
+
// Duplicates the current selection in place, immediately after it, without
|
|
328
|
+
// touching the clipboard — a pending copy must survive a Cmd+D. Shares
|
|
329
|
+
// pasteTimelineClips with handlePaste; only the anchor and clip source
|
|
330
|
+
// differ (the selection's own end here, the playhead there).
|
|
331
|
+
const handleDuplicate = useCallback(async (): Promise<boolean> => {
|
|
332
|
+
const result = collectSelectedClips();
|
|
333
|
+
if (!result) return false;
|
|
334
|
+
const pid = projectIdRef.current;
|
|
335
|
+
if (!pid) return false;
|
|
336
|
+
|
|
337
|
+
const { elements, clips } = result;
|
|
338
|
+
const targetPath = elements[0]?.sourceFile || activeCompPath || "index.html";
|
|
339
|
+
const anchorTime = Math.max(...elements.map((el) => el.start + el.duration));
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const originalContent = await readFileContent(pid, targetPath);
|
|
343
|
+
const liveElements = usePlayerStore.getState().elements;
|
|
344
|
+
const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
|
|
345
|
+
const patchedContent = extendRootDurationInSource(pasted.content, pasted.requiredEnd);
|
|
346
|
+
const ids = pasted.ids;
|
|
347
|
+
|
|
348
|
+
await saveProjectFilesWithHistory({
|
|
349
|
+
projectId: pid,
|
|
350
|
+
label: clipLabel("Duplicate", clips.length),
|
|
351
|
+
kind: "timeline" as EditHistoryKind,
|
|
352
|
+
files: { [targetPath]: patchedContent },
|
|
353
|
+
readFile: async () => originalContent,
|
|
354
|
+
writeFile: writeProjectFile,
|
|
355
|
+
recordEdit,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// The duplicate becomes the selection, mirroring CapCut's own paste
|
|
359
|
+
// convention — there is no CapCut Duplicate to match directly (Cmd+D is
|
|
360
|
+
// a no-op there); this is our own choice for consistency with paste.
|
|
361
|
+
// Select before reloading, same reason as handlePaste above.
|
|
362
|
+
if (ids.length > 0) {
|
|
363
|
+
usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath)));
|
|
364
|
+
}
|
|
365
|
+
reloadPreview();
|
|
366
|
+
showToast(clipToast("Duplicated", clips.length), "info");
|
|
367
|
+
return true;
|
|
368
|
+
} catch (error) {
|
|
369
|
+
const message = error instanceof Error ? error.message : "Failed to duplicate";
|
|
370
|
+
showToast(message);
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}, [
|
|
374
|
+
activeCompPath,
|
|
375
|
+
collectSelectedClips,
|
|
376
|
+
recordEdit,
|
|
377
|
+
reloadPreview,
|
|
378
|
+
showToast,
|
|
379
|
+
writeProjectFile,
|
|
380
|
+
]);
|
|
381
|
+
|
|
190
382
|
const handleCut = useCallback(async (): Promise<boolean> => {
|
|
383
|
+
const selected = getSelectedElements();
|
|
191
384
|
const copied = handleCopy();
|
|
192
385
|
if (!copied) return false;
|
|
193
386
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
387
|
+
if (selected.length > 0) {
|
|
388
|
+
// One call for the whole selection, not one per element: the batched
|
|
389
|
+
// delete writes and records history once, so a multi-clip Cmd+X undoes
|
|
390
|
+
// in a single Cmd+Z instead of needing one per clip.
|
|
391
|
+
await handleTimelineElementsDelete(selected);
|
|
392
|
+
return true;
|
|
201
393
|
}
|
|
202
394
|
|
|
203
395
|
const domSelection = domEditSelectionRef.current;
|
|
@@ -206,7 +398,9 @@ export function useClipboard({
|
|
|
206
398
|
return true;
|
|
207
399
|
}
|
|
208
400
|
return true;
|
|
209
|
-
}, [handleCopy, domEditSelectionRef,
|
|
401
|
+
}, [handleCopy, domEditSelectionRef, handleTimelineElementsDelete, handleDomEditElementDelete]);
|
|
402
|
+
|
|
403
|
+
const canPaste = useCallback(() => clipboardRef.current !== null, []);
|
|
210
404
|
|
|
211
|
-
return { handleCopy, handlePaste, handleCut };
|
|
405
|
+
return { handleCopy, handlePaste, handleCut, handleDuplicate, canPaste };
|
|
212
406
|
}
|
|
@@ -24,7 +24,6 @@ const rootElement: TimelineElement = {
|
|
|
24
24
|
track: 0,
|
|
25
25
|
domId: "root-clip",
|
|
26
26
|
sourceFile: ROOT_FILE,
|
|
27
|
-
timingSource: "authored",
|
|
28
27
|
};
|
|
29
28
|
|
|
30
29
|
// An expanded sub-comp child: `start` is in MASTER coordinates (offset by the
|
|
@@ -38,7 +37,6 @@ const expandedChild: TimelineElement = {
|
|
|
38
37
|
track: 1,
|
|
39
38
|
domId: "child-clip",
|
|
40
39
|
sourceFile: SUBCOMP_FILE,
|
|
41
|
-
timingSource: "authored",
|
|
42
40
|
expandedParentStart: 2,
|
|
43
41
|
};
|
|
44
42
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useMemo,
|
|
1
|
+
import { useCallback, useMemo, type DragEvent } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
3
|
import type { StudioContextValue } from "../contexts/StudioContext";
|
|
4
4
|
import type { RightInspectorPanes } from "../utils/studioHelpers";
|
|
@@ -116,35 +116,21 @@ export function useInspectorState(
|
|
|
116
116
|
]);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
function
|
|
121
|
-
const [active, setActive] = useState(false);
|
|
122
|
-
const counterRef = useRef(0);
|
|
119
|
+
/** Lets an OS file drop reach `onDrop` anywhere in the shell; the timeline shows its own landing preview. */
|
|
120
|
+
function useFileDropTarget(onImportFiles: (files: FileList) => void) {
|
|
123
121
|
const onDragOver = useCallback((e: DragEvent) => {
|
|
124
122
|
if (!e.dataTransfer.types.includes("Files")) return;
|
|
125
123
|
e.preventDefault();
|
|
126
124
|
}, []);
|
|
127
|
-
const onDragEnter = useCallback((e: DragEvent) => {
|
|
128
|
-
if (!e.dataTransfer.types.includes("Files")) return;
|
|
129
|
-
e.preventDefault();
|
|
130
|
-
counterRef.current++;
|
|
131
|
-
setActive(true);
|
|
132
|
-
}, []);
|
|
133
|
-
const onDragLeave = useCallback(() => {
|
|
134
|
-
counterRef.current--;
|
|
135
|
-
if (counterRef.current === 0) setActive(false);
|
|
136
|
-
}, []);
|
|
137
125
|
const onDrop = useCallback(
|
|
138
126
|
(e: DragEvent) => {
|
|
139
|
-
counterRef.current = 0;
|
|
140
|
-
setActive(false);
|
|
141
127
|
if (e.defaultPrevented) return;
|
|
142
128
|
e.preventDefault();
|
|
143
129
|
if (e.dataTransfer.files.length) onImportFiles(e.dataTransfer.files);
|
|
144
130
|
},
|
|
145
131
|
[onImportFiles],
|
|
146
132
|
);
|
|
147
|
-
return {
|
|
133
|
+
return { onDragOver, onDrop };
|
|
148
134
|
}
|
|
149
135
|
|
|
150
136
|
/** Global OS file drop: imports and places at the playhead position. */
|
|
@@ -156,5 +142,5 @@ export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandle
|
|
|
156
142
|
},
|
|
157
143
|
[handleTimelineFileDrop],
|
|
158
144
|
);
|
|
159
|
-
return
|
|
145
|
+
return useFileDropTarget(onDrop);
|
|
160
146
|
}
|
|
@@ -4,12 +4,14 @@ import React, { act } from "react";
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
6
6
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
7
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
7
8
|
|
|
8
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
10
|
|
|
10
11
|
afterEach(() => {
|
|
11
12
|
document.body.innerHTML = "";
|
|
12
13
|
vi.unstubAllGlobals();
|
|
14
|
+
usePlayerStore.getState().reset();
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
type DropFn = ReturnType<typeof useTimelineAssetDropOps>["handleTimelineAssetDrop"];
|
|
@@ -69,4 +71,17 @@ describe("useTimelineAssetDropOps handleTimelineAssetDrop", () => {
|
|
|
69
71
|
const [, written] = writeProjectFile.mock.calls[0] as [string, string];
|
|
70
72
|
expect(written).toContain('data-duration="10"');
|
|
71
73
|
});
|
|
74
|
+
|
|
75
|
+
it("selects and reveals the newly dropped clip", async () => {
|
|
76
|
+
const source =
|
|
77
|
+
'<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>';
|
|
78
|
+
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
79
|
+
const getDrop = renderDropHook(source, writeProjectFile);
|
|
80
|
+
|
|
81
|
+
await act(async () => {
|
|
82
|
+
await getDrop()("clip.mp4", { start: 1, track: 0 }, 2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("index.html#clip");
|
|
86
|
+
});
|
|
72
87
|
});
|
|
@@ -21,7 +21,8 @@ import { formatTimelineAttributeNumber } from "./timelineEditingHelpers";
|
|
|
21
21
|
import { readFileContent } from "./timelineTimingSync";
|
|
22
22
|
import { commitTimelineCompositionInsertion } from "../utils/timelineCompositionInsert";
|
|
23
23
|
import { extendRootDurationInSource } from "../utils/rootDuration";
|
|
24
|
-
import {
|
|
24
|
+
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
25
|
+
import { selectAndRevealTimelineElement } from "../player/components/timelineDropReveal";
|
|
25
26
|
|
|
26
27
|
interface UseTimelineAssetDropOpsOptions {
|
|
27
28
|
projectIdRef: MutableRefObject<string | null>;
|
|
@@ -121,6 +122,7 @@ export function useTimelineAssetDropOps({
|
|
|
121
122
|
recordEdit,
|
|
122
123
|
});
|
|
123
124
|
|
|
125
|
+
selectAndRevealTimelineElement(deriveTimelineStoreKeyForDomId(newId, targetPath));
|
|
124
126
|
forceReloadSdkSession?.();
|
|
125
127
|
reloadPreview();
|
|
126
128
|
} catch (error) {
|
|
@@ -194,7 +196,7 @@ export function useTimelineAssetDropOps({
|
|
|
194
196
|
writeFile: writeProjectFile,
|
|
195
197
|
recordEdit,
|
|
196
198
|
observeVersion: observeProjectFileVersion,
|
|
197
|
-
selectHost:
|
|
199
|
+
selectHost: selectAndRevealTimelineElement,
|
|
198
200
|
resync: forceReloadSdkSession,
|
|
199
201
|
refresh: reloadPreview,
|
|
200
202
|
});
|