@hyperframes/studio 0.8.49 → 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.
Files changed (95) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-CxxDxHTW.js} +1 -1
  2. package/dist/assets/{index-mIgSsKqs.js → index-DUAIWxkI.js} +1 -1
  3. package/dist/assets/{index-BGovDksE.js → index-Dt3qBI-9.js} +1 -1
  4. package/dist/assets/{index-Bgmx2h7W.js → index-n2ml4tUn.js} +218 -217
  5. package/dist/assets/index-pO6p0H27.css +1 -0
  6. package/dist/index.d.ts +12 -4
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5899 -5754
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +10 -8
  12. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  13. package/src/components/EditorShell.tsx +24 -0
  14. package/src/components/StudioOverlays.tsx +1 -5
  15. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  16. package/src/components/editor/PreviewGuides.tsx +125 -0
  17. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  18. package/src/components/editor/SnapToolbar.tsx +25 -1
  19. package/src/components/editor/domEditingAgentPrompt.ts +5 -0
  20. package/src/components/editor/previewGuidesStore.ts +26 -0
  21. package/src/components/nle/NLEPreview.test.ts +13 -0
  22. package/src/components/nle/NLEPreview.tsx +9 -4
  23. package/src/components/nle/PreviewPane.test.tsx +52 -0
  24. package/src/components/nle/PreviewPane.tsx +2 -0
  25. package/src/components/nle/TimelinePane.tsx +14 -0
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  28. package/src/hooks/appHotkeysDispatch.ts +11 -0
  29. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  30. package/src/hooks/timelineTrackVisibility.ts +8 -11
  31. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  32. package/src/hooks/useAppHotkeys.ts +3 -0
  33. package/src/hooks/useAskAgentModal.ts +2 -0
  34. package/src/hooks/useClipboard.test.ts +234 -0
  35. package/src/hooks/useClipboard.ts +264 -70
  36. package/src/hooks/useGroupCommits.test.ts +0 -1
  37. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  38. package/src/hooks/useRazorSplit.test.ts +0 -2
  39. package/src/hooks/useRazorSplit.test.tsx +0 -1
  40. package/src/hooks/useStudioContextValue.ts +5 -19
  41. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  42. package/src/player/components/ClipContextMenu.tsx +87 -7
  43. package/src/player/components/Timeline.tsx +14 -14
  44. package/src/player/components/TimelineCanvas.tsx +30 -0
  45. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  46. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  47. package/src/player/components/TimelineLanes.test.tsx +22 -1
  48. package/src/player/components/TimelineOverlays.tsx +12 -0
  49. package/src/player/components/TimelineTypes.ts +4 -0
  50. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  51. package/src/player/components/timelineClipChildren.test.ts +2 -2
  52. package/src/player/components/timelineClipChildren.tsx +2 -1
  53. package/src/player/components/timelineClipDragCommit.ts +2 -2
  54. package/src/player/components/timelineDragDrop.test.tsx +31 -0
  55. package/src/player/components/timelineDragDrop.ts +30 -23
  56. package/src/player/components/timelineEditCapabilities.ts +1 -2
  57. package/src/player/components/timelineEditing.test.ts +0 -16
  58. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  59. package/src/player/components/timelineGroupEditing.ts +0 -1
  60. package/src/player/components/timelineTrackDisplay.ts +1 -1
  61. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  62. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  63. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  64. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  65. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  66. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  67. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  68. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  69. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  70. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  71. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  72. package/src/player/lib/describeClips.test.ts +95 -0
  73. package/src/player/lib/describeClips.ts +103 -0
  74. package/src/player/lib/timelineDOM.test.ts +0 -80
  75. package/src/player/lib/timelineDOM.ts +1 -61
  76. package/src/player/lib/timelineElementHelpers.ts +2 -53
  77. package/src/player/store/playerStore.ts +5 -0
  78. package/src/player/store/timelineElement.ts +1 -3
  79. package/src/shims/canvasBrowserStub.js +50 -0
  80. package/src/utils/clipboardPayload.test.ts +42 -2
  81. package/src/utils/clipboardPayload.ts +57 -32
  82. package/src/utils/previewSafeMargins.test.ts +34 -0
  83. package/src/utils/previewSafeMargins.ts +10 -0
  84. package/src/utils/razorSplitTransaction.test.ts +0 -1
  85. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  86. package/src/utils/studioUiPreferences.test.ts +43 -0
  87. package/src/utils/studioUiPreferences.ts +27 -6
  88. package/src/utils/timelineElementSplit.test.ts +1 -2
  89. package/src/utils/timelineElementSplit.ts +0 -1
  90. package/src/webmcp/tools/lookTools.test.ts +40 -0
  91. package/src/webmcp/tools/lookTools.ts +32 -1
  92. package/dist/assets/index-B9LfWNyM.css +0 -1
  93. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  94. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  95. 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 { type ClipboardPayload, deduplicateIds, insertAsSibling } from "../utils/clipboardPayload";
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
- handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
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
- handleTimelineElementDelete,
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
- // The copy-mode branches predate this change; this diff only replaces its
67
- // duplicated DOM lookup with the canonical composition-aware resolver.
68
- // fallow-ignore-next-line complexity
69
- const handleCopy = useCallback((): boolean => {
70
- const { selectedElementId, elements } = usePlayerStore.getState();
71
-
72
- // Timeline clip copy
73
- if (selectedElementId) {
74
- const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
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("Unable to copy this element.", "info");
101
- return false;
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
- const payload: ClipboardPayload = { kind: "timeline-clip", html, sourceFile: targetPath };
105
- clipboardRef.current = payload;
106
- showToast("Copied clip", "info");
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
- const payload: ClipboardPayload = {
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
- // Only rewrite data-start on the outermost opening tag. The non-global
152
- // regex matches the first occurrence, which is always in the root tag
153
- // since outerHTML starts with it. Nested clips keep their own timing.
154
- const { currentTime } = usePlayerStore.getState();
155
- const rootTagEnd = deduped.indexOf(">");
156
- const rootTag = rootTagEnd >= 0 ? deduped.slice(0, rootTagEnd + 1) : deduped;
157
- const patchedRootTag = rootTag.replace(
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: payload.kind === "timeline-clip" ? "Paste clip" : "Paste element",
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(payload.kind === "timeline-clip" ? "Pasted clip" : "Pasted element", "info");
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
- const { selectedElementId, elements } = usePlayerStore.getState();
195
- if (selectedElementId) {
196
- const element = elements.find((el) => (el.key ?? el.id) === selectedElementId);
197
- if (element) {
198
- await handleTimelineElementDelete(element);
199
- return true;
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, handleTimelineElementDelete, handleDomEditElementDelete]);
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
  }
@@ -11,7 +11,6 @@ const timelineElement = (over: Partial<TimelineElement> = {}): TimelineElement =
11
11
  start: 0,
12
12
  duration: 4,
13
13
  track: 0,
14
- timingSource: "authored",
15
14
  sourceFile: "index.html",
16
15
  ...over,
17
16
  });
@@ -29,7 +29,6 @@ const element: TimelineElement = {
29
29
  track: 0,
30
30
  domId: "clip1",
31
31
  sourceFile: "index.html",
32
- timingSource: "authored",
33
32
  };
34
33
 
35
34
  type Split = (element: TimelineElement, splitTime: number) => Promise<void>;
@@ -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
 
@@ -82,7 +82,6 @@ describe("useRazorSplit mutation versions", () => {
82
82
  start: 0,
83
83
  duration: 4,
84
84
  track: 0,
85
- timingSource: "authored",
86
85
  },
87
86
  2,
88
87
  );
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
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
- // fallow-ignore-next-line complexity
120
- function useDragOverlay(onImportFiles: (files: FileList) => void) {
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 { active, onDragOver, onDragEnter, onDragLeave, onDrop };
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 useDragOverlay(onDrop);
145
+ return useFileDropTarget(onDrop);
160
146
  }
@@ -86,7 +86,6 @@ function timelineElement(input: {
86
86
  parentCompositionId: null,
87
87
  compositionAncestors: ["root"],
88
88
  sourceFile: input.sourceFile ?? "index.html",
89
- timingSource: "authored",
90
89
  };
91
90
  }
92
91