@hyperframes/studio 0.6.73 → 0.6.75

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 (63) hide show
  1. package/dist/assets/index-DcyZuBcU.css +1 -0
  2. package/dist/assets/index-uB_W2GDl.js +140 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +30 -24
  6. package/src/components/StudioPreviewArea.tsx +101 -26
  7. package/src/components/StudioRightPanel.tsx +3 -0
  8. package/src/components/StudioToast.tsx +18 -0
  9. package/src/components/TimelineToolbar.tsx +230 -4
  10. package/src/components/editor/AnimationCard.tsx +68 -4
  11. package/src/components/editor/DomEditOverlay.tsx +70 -1
  12. package/src/components/editor/GridOverlay.tsx +50 -0
  13. package/src/components/editor/KeyframeDiamond.tsx +49 -0
  14. package/src/components/editor/KeyframeNavigation.tsx +139 -0
  15. package/src/components/editor/LayersPanel.test.ts +135 -0
  16. package/src/components/editor/LayersPanel.tsx +151 -15
  17. package/src/components/editor/PropertyPanel.tsx +293 -140
  18. package/src/components/editor/SnapGuideOverlay.tsx +166 -0
  19. package/src/components/editor/SnapToolbar.tsx +163 -0
  20. package/src/components/editor/SpringEaseEditor.tsx +256 -0
  21. package/src/components/editor/domEditOverlayGestures.ts +7 -0
  22. package/src/components/editor/domEditOverlayStartGesture.ts +28 -0
  23. package/src/components/editor/gsapAnimationConstants.ts +42 -0
  24. package/src/components/editor/gsapAnimationHelpers.ts +2 -1
  25. package/src/components/editor/manualEditingAvailability.ts +6 -0
  26. package/src/components/editor/manualEditsDom.ts +56 -2
  27. package/src/components/editor/manualOffsetDrag.ts +19 -3
  28. package/src/components/editor/propertyPanelHelpers.ts +90 -0
  29. package/src/components/editor/propertyPanelTimingSection.tsx +64 -0
  30. package/src/components/editor/snapEngine.test.ts +657 -0
  31. package/src/components/editor/snapEngine.ts +575 -0
  32. package/src/components/editor/snapTargetCollection.ts +147 -0
  33. package/src/components/editor/useDomEditOverlayGestures.ts +137 -10
  34. package/src/components/editor/useLayerDrag.ts +213 -0
  35. package/src/components/nle/NLELayout.tsx +18 -0
  36. package/src/contexts/DomEditContext.tsx +27 -0
  37. package/src/hooks/gsapRuntimeBridge.ts +585 -0
  38. package/src/hooks/gsapRuntimeKeyframes.ts +170 -0
  39. package/src/hooks/useAnimatedPropertyCommit.ts +131 -0
  40. package/src/hooks/useAppHotkeys.ts +63 -1
  41. package/src/hooks/useDomEditCommits.ts +88 -4
  42. package/src/hooks/useDomEditSession.ts +179 -65
  43. package/src/hooks/useGsapScriptCommits.ts +144 -7
  44. package/src/hooks/useGsapSelectionHandlers.ts +202 -0
  45. package/src/hooks/useGsapTweenCache.ts +174 -3
  46. package/src/hooks/useTimelineEditing.ts +93 -0
  47. package/src/icons/SystemIcons.tsx +2 -0
  48. package/src/player/components/ClipContextMenu.tsx +99 -0
  49. package/src/player/components/KeyframeDiamondContextMenu.tsx +164 -0
  50. package/src/player/components/Timeline.test.ts +2 -1
  51. package/src/player/components/Timeline.tsx +108 -68
  52. package/src/player/components/TimelineCanvas.tsx +47 -1
  53. package/src/player/components/TimelineClip.tsx +8 -3
  54. package/src/player/components/TimelineClipDiamonds.tsx +174 -0
  55. package/src/player/components/timelineDragDrop.ts +103 -0
  56. package/src/player/components/timelineLayout.ts +1 -1
  57. package/src/player/store/playerStore.ts +42 -0
  58. package/src/utils/editHistory.ts +1 -1
  59. package/src/utils/optimisticUpdate.test.ts +53 -0
  60. package/src/utils/optimisticUpdate.ts +18 -0
  61. package/src/utils/studioUiPreferences.ts +17 -0
  62. package/dist/assets/index-CrxThtSJ.css +0 -1
  63. package/dist/assets/index-Dc2HfqON.js +0 -140
@@ -0,0 +1,202 @@
1
+ import { useCallback } from "react";
2
+ import type { DomEditSelection } from "../components/editor/domEditing";
3
+
4
+ /**
5
+ * Thin useCallback wrappers that guard on `domEditSelection` before
6
+ * delegating to the underlying GSAP script-commit functions. Extracted
7
+ * from useDomEditSession to keep that file under the 600-line limit.
8
+ */
9
+ // fallow-ignore-next-line complexity
10
+ export function useGsapSelectionHandlers({
11
+ domEditSelection,
12
+ updateGsapProperty,
13
+ updateGsapMeta,
14
+ deleteGsapAnimation,
15
+ addGsapAnimation,
16
+ addGsapProperty,
17
+ removeGsapProperty,
18
+ updateGsapFromProperty,
19
+ addGsapFromProperty,
20
+ removeGsapFromProperty,
21
+ addKeyframe,
22
+ removeKeyframe,
23
+ convertToKeyframes,
24
+ removeAllKeyframes,
25
+ currentTime,
26
+ handleDomManualEditsReset,
27
+ selectedGsapAnimations,
28
+ }: {
29
+ domEditSelection: DomEditSelection | null;
30
+ updateGsapProperty: (
31
+ sel: DomEditSelection,
32
+ animId: string,
33
+ prop: string,
34
+ value: number | string,
35
+ ) => void;
36
+ updateGsapMeta: (
37
+ sel: DomEditSelection,
38
+ animId: string,
39
+ updates: { duration?: number; ease?: string; position?: number },
40
+ ) => void;
41
+ deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
42
+ addGsapAnimation: (
43
+ sel: DomEditSelection,
44
+ method: "to" | "from" | "set" | "fromTo",
45
+ time: number,
46
+ ) => void;
47
+ addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
48
+ removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
49
+ updateGsapFromProperty: (
50
+ sel: DomEditSelection,
51
+ animId: string,
52
+ prop: string,
53
+ value: number | string,
54
+ ) => void;
55
+ addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
56
+ removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
57
+ addKeyframe: (
58
+ sel: DomEditSelection,
59
+ animId: string,
60
+ percentage: number,
61
+ property: string,
62
+ value: number | string,
63
+ ) => void;
64
+ removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
65
+ convertToKeyframes: (sel: DomEditSelection, animId: string) => void;
66
+ removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
67
+ currentTime: number;
68
+ handleDomManualEditsReset: (sel: DomEditSelection) => void;
69
+ selectedGsapAnimations: { id: string; keyframes?: unknown }[];
70
+ }) {
71
+ const handleGsapUpdateProperty = useCallback(
72
+ (animId: string, prop: string, value: number | string) => {
73
+ if (!domEditSelection) return;
74
+ updateGsapProperty(domEditSelection, animId, prop, value);
75
+ },
76
+ [domEditSelection, updateGsapProperty],
77
+ );
78
+
79
+ const handleGsapUpdateMeta = useCallback(
80
+ (animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
81
+ if (!domEditSelection) return;
82
+ updateGsapMeta(domEditSelection, animId, updates);
83
+ },
84
+ [domEditSelection, updateGsapMeta],
85
+ );
86
+
87
+ const handleGsapDeleteAnimation = useCallback(
88
+ (animId: string) => {
89
+ if (!domEditSelection) return;
90
+ deleteGsapAnimation(domEditSelection, animId);
91
+ },
92
+ [domEditSelection, deleteGsapAnimation],
93
+ );
94
+
95
+ const handleGsapAddAnimation = useCallback(
96
+ (method: "to" | "from" | "set" | "fromTo") => {
97
+ if (!domEditSelection) return;
98
+ addGsapAnimation(domEditSelection, method, currentTime);
99
+ if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
100
+ handleDomManualEditsReset(domEditSelection);
101
+ }
102
+ },
103
+ [domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset],
104
+ );
105
+
106
+ const handleGsapAddProperty = useCallback(
107
+ (animId: string, prop: string) => {
108
+ if (!domEditSelection) return;
109
+ addGsapProperty(domEditSelection, animId, prop);
110
+ },
111
+ [domEditSelection, addGsapProperty],
112
+ );
113
+
114
+ const handleGsapRemoveProperty = useCallback(
115
+ (animId: string, prop: string) => {
116
+ if (!domEditSelection) return;
117
+ removeGsapProperty(domEditSelection, animId, prop);
118
+ },
119
+ [domEditSelection, removeGsapProperty],
120
+ );
121
+
122
+ const handleGsapUpdateFromProperty = useCallback(
123
+ (animId: string, prop: string, value: number | string) => {
124
+ if (!domEditSelection) return;
125
+ updateGsapFromProperty(domEditSelection, animId, prop, value);
126
+ },
127
+ [domEditSelection, updateGsapFromProperty],
128
+ );
129
+
130
+ const handleGsapAddFromProperty = useCallback(
131
+ (animId: string, prop: string) => {
132
+ if (!domEditSelection) return;
133
+ addGsapFromProperty(domEditSelection, animId, prop);
134
+ },
135
+ [domEditSelection, addGsapFromProperty],
136
+ );
137
+
138
+ const handleGsapRemoveFromProperty = useCallback(
139
+ (animId: string, prop: string) => {
140
+ if (!domEditSelection) return;
141
+ removeGsapFromProperty(domEditSelection, animId, prop);
142
+ },
143
+ [domEditSelection, removeGsapFromProperty],
144
+ );
145
+
146
+ const handleGsapAddKeyframe = useCallback(
147
+ (animId: string, percentage: number, property: string, value: number | string) => {
148
+ if (!domEditSelection) return;
149
+ addKeyframe(domEditSelection, animId, percentage, property, value);
150
+ },
151
+ [domEditSelection, addKeyframe],
152
+ );
153
+
154
+ const handleGsapRemoveKeyframe = useCallback(
155
+ (animId: string, percentage: number) => {
156
+ if (!domEditSelection) return;
157
+ removeKeyframe(domEditSelection, animId, percentage);
158
+ },
159
+ [domEditSelection, removeKeyframe],
160
+ );
161
+
162
+ const handleGsapConvertToKeyframes = useCallback(
163
+ (animId: string) => {
164
+ if (!domEditSelection) return;
165
+ convertToKeyframes(domEditSelection, animId);
166
+ },
167
+ [domEditSelection, convertToKeyframes],
168
+ );
169
+
170
+ const handleGsapRemoveAllKeyframes = useCallback(
171
+ (animId: string) => {
172
+ if (!domEditSelection) return;
173
+ removeAllKeyframes(domEditSelection, animId);
174
+ },
175
+ [domEditSelection, removeAllKeyframes],
176
+ );
177
+
178
+ const handleResetSelectedElementKeyframes = useCallback((): boolean => {
179
+ if (!domEditSelection) return false;
180
+ const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
181
+ if (!withKeyframes) return false;
182
+ removeAllKeyframes(domEditSelection, withKeyframes.id);
183
+ return true;
184
+ }, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
185
+
186
+ return {
187
+ handleGsapUpdateProperty,
188
+ handleGsapUpdateMeta,
189
+ handleGsapDeleteAnimation,
190
+ handleGsapAddAnimation,
191
+ handleGsapAddProperty,
192
+ handleGsapRemoveProperty,
193
+ handleGsapUpdateFromProperty,
194
+ handleGsapAddFromProperty,
195
+ handleGsapRemoveFromProperty,
196
+ handleGsapAddKeyframe,
197
+ handleGsapRemoveKeyframe,
198
+ handleGsapConvertToKeyframes,
199
+ handleGsapRemoveAllKeyframes,
200
+ handleResetSelectedElementKeyframes,
201
+ };
202
+ }
@@ -1,5 +1,12 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState, useCallback } from "react";
2
2
  import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
3
+ import { usePlayerStore } from "../player/store/playerStore";
4
+ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
5
+
6
+ function extractIdFromSelector(selector: string): string | null {
7
+ const match = selector.match(/^#([\w-]+)/);
8
+ return match ? match[1] : null;
9
+ }
3
10
 
4
11
  /** The selected element's identity for matching tweens to it. */
5
12
  export interface GsapElementTarget {
@@ -28,7 +35,7 @@ export function getAnimationsForElement(
28
35
  );
29
36
  }
30
37
 
31
- async function fetchParsedAnimations(
38
+ export async function fetchParsedAnimations(
32
39
  projectId: string,
33
40
  sourceFile: string,
34
41
  ): Promise<ParsedGsap | null> {
@@ -47,6 +54,7 @@ export function useGsapAnimationsForElement(
47
54
  sourceFile: string,
48
55
  target: GsapElementTarget | null,
49
56
  version: number,
57
+ iframeRef?: React.RefObject<HTMLIFrameElement | null>,
50
58
  ): {
51
59
  animations: GsapAnimation[];
52
60
  multipleTimelines: boolean;
@@ -88,9 +96,23 @@ export function useGsapAnimationsForElement(
88
96
  };
89
97
  }, [projectId, sourceFile, version]);
90
98
 
99
+ // Retry fetch if we have a target but no animations — handles cold-load race
100
+ // where the initial fetch runs before the drilled-down sourceFile is resolved
101
+ useEffect(() => {
102
+ if (!projectId || !target || allAnimations.length > 0) return;
103
+ const timer = setTimeout(() => {
104
+ fetchParsedAnimations(projectId, sourceFile).then((parsed) => {
105
+ if (parsed && parsed.animations.length > 0) {
106
+ setAllAnimations(parsed.animations);
107
+ }
108
+ });
109
+ }, 800);
110
+ return () => clearTimeout(timer);
111
+ }, [projectId, sourceFile, target, allAnimations.length]);
112
+
91
113
  const targetId = target?.id ?? null;
92
114
  const targetSelector = target?.selector ?? null;
93
- const animations = useMemo(
115
+ const rawAnimations = useMemo(
94
116
  () =>
95
117
  targetId || targetSelector
96
118
  ? getAnimationsForElement(allAnimations, { id: targetId, selector: targetSelector })
@@ -98,6 +120,76 @@ export function useGsapAnimationsForElement(
98
120
  [allAnimations, targetId, targetSelector],
99
121
  );
100
122
 
123
+ const animations = useMemo(() => {
124
+ const iframe = iframeRef?.current;
125
+ let result = rawAnimations;
126
+
127
+ // Enrich animations with unresolved keyframes from runtime
128
+ if (iframe) {
129
+ result = result.map((anim) => {
130
+ if (!anim.hasUnresolvedKeyframes || anim.keyframes) return anim;
131
+ const runtime = readRuntimeKeyframes(iframe, anim.targetSelector);
132
+ if (!runtime) return anim;
133
+ return {
134
+ ...anim,
135
+ keyframes: {
136
+ format: "percentage" as const,
137
+ keyframes: runtime.keyframes,
138
+ ...(runtime.easeEach ? { easeEach: runtime.easeEach } : {}),
139
+ },
140
+ };
141
+ });
142
+ }
143
+
144
+ // Match unresolved-selector animations from the parser to runtime tweens
145
+ // targeting this element. This handles fully dynamic code (loop with variable selector).
146
+ if (iframe && targetId && result.length === 0) {
147
+ const unresolvedAnims = allAnimations.filter((a) => a.hasUnresolvedSelector);
148
+ if (unresolvedAnims.length > 0) {
149
+ const runtimeData = readRuntimeKeyframes(iframe, `#${targetId}`);
150
+ if (runtimeData) {
151
+ const scanned = scanAllRuntimeKeyframes(iframe);
152
+ const runtimeEntry = scanned.get(targetId);
153
+ if (runtimeEntry) {
154
+ // Find which unresolved animation index matches this element
155
+ // by correlating parser order with runtime tween order
156
+ const runtimeIds = Array.from(scanned.keys());
157
+ const runtimeIndex = runtimeIds.indexOf(targetId);
158
+ const matchedAnim =
159
+ runtimeIndex >= 0 && runtimeIndex < unresolvedAnims.length
160
+ ? unresolvedAnims[runtimeIndex]
161
+ : unresolvedAnims[0];
162
+ if (matchedAnim) {
163
+ result = [
164
+ {
165
+ ...matchedAnim,
166
+ targetSelector: `#${targetId}`,
167
+ keyframes: {
168
+ format: "percentage" as const,
169
+ keyframes: runtimeEntry.keyframes,
170
+ ...(runtimeEntry.easeEach ? { easeEach: runtimeEntry.easeEach } : {}),
171
+ },
172
+ },
173
+ ];
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ return result;
181
+ }, [rawAnimations, allAnimations, iframeRef, targetId]);
182
+
183
+ // Populate keyframe cache for the selected element.
184
+ // Key format must match timeline element keys: "sourceFile#domId".
185
+ const elementId = target?.id ?? null;
186
+ useEffect(() => {
187
+ if (!elementId) return;
188
+ const { setKeyframeCache } = usePlayerStore.getState();
189
+ const withKeyframes = animations.find((a) => a.keyframes);
190
+ setKeyframeCache(`${sourceFile}#${elementId}`, withKeyframes?.keyframes ?? undefined);
191
+ }, [elementId, sourceFile, animations]);
192
+
101
193
  return { animations, multipleTimelines, unsupportedTimelinePattern };
102
194
  }
103
195
 
@@ -106,3 +198,82 @@ export function useGsapCacheVersion() {
106
198
  const bump = useCallback(() => setVersion((v) => v + 1), []);
107
199
  return { version, bump };
108
200
  }
201
+
202
+ /**
203
+ * Fetch GSAP animations for a file and populate the keyframe cache for all
204
+ * elements. Called from the Timeline component so diamonds show without
205
+ * requiring a selection.
206
+ */
207
+ export function usePopulateKeyframeCacheForFile(
208
+ projectId: string | null,
209
+ sourceFile: string,
210
+ version: number,
211
+ iframeRef?: React.RefObject<HTMLIFrameElement | null>,
212
+ ): void {
213
+ const lastFetchKeyRef = useRef("");
214
+
215
+ const runtimeScanDoneRef = useRef("");
216
+
217
+ useEffect(() => {
218
+ const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}`;
219
+ if (fetchKey === lastFetchKeyRef.current) return;
220
+ lastFetchKeyRef.current = fetchKey;
221
+ runtimeScanDoneRef.current = "";
222
+ if (!projectId) return;
223
+
224
+ const sf = sourceFile;
225
+ fetchParsedAnimations(projectId, sf).then((parsed) => {
226
+ if (!parsed) return;
227
+ const { setKeyframeCache } = usePlayerStore.getState();
228
+ for (const anim of parsed.animations) {
229
+ const id = extractIdFromSelector(anim.targetSelector);
230
+ if (!id || !anim.keyframes) continue;
231
+ setKeyframeCache(`${sf}#${id}`, anim.keyframes);
232
+ if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
233
+ }
234
+ runtimeScanDoneRef.current = fetchKey;
235
+ });
236
+ }, [projectId, sourceFile, version]);
237
+
238
+ // Separate effect for runtime keyframe discovery — polls until the iframe
239
+ // has loaded GSAP timelines, independent of the AST fetch lifecycle.
240
+ useEffect(() => {
241
+ if (!projectId) return;
242
+ const sf = sourceFile;
243
+
244
+ let attempts = 0;
245
+ const maxAttempts = 10;
246
+
247
+ const tryRuntimeScan = () => {
248
+ if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
249
+ const iframe = iframeRef?.current;
250
+ if (!iframe) return false;
251
+ const scanned = scanAllRuntimeKeyframes(iframe);
252
+ if (scanned.size === 0) return false;
253
+ const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
254
+ for (const [id, data] of scanned) {
255
+ const cacheKey = `${sf}#${id}`;
256
+ const fallbackKey = `index.html#${id}`;
257
+ if (keyframeCache.has(cacheKey) || keyframeCache.has(fallbackKey)) continue;
258
+ const entry = {
259
+ format: "percentage" as const,
260
+ keyframes: data.keyframes,
261
+ ...(data.easeEach ? { easeEach: data.easeEach } : {}),
262
+ };
263
+ setKeyframeCache(cacheKey, entry);
264
+ if (sf !== "index.html") setKeyframeCache(fallbackKey, entry);
265
+ }
266
+ runtimeScanDoneRef.current = `kf-cache:${projectId}:${sf}:${version}`;
267
+ return true;
268
+ };
269
+
270
+ if (tryRuntimeScan()) return;
271
+
272
+ const interval = setInterval(() => {
273
+ attempts++;
274
+ if (tryRuntimeScan() || attempts >= maxAttempts) clearInterval(interval);
275
+ }, 500);
276
+
277
+ return () => clearInterval(interval);
278
+ }, [projectId, sourceFile, version, iframeRef]);
279
+ }
@@ -466,10 +466,103 @@ export function useTimelineEditing({
466
466
  [showToast],
467
467
  );
468
468
 
469
+ const handleTimelineElementSplit = useCallback(
470
+ async (element: TimelineElement, splitTime: number) => {
471
+ const pid = projectIdRef.current;
472
+ if (!pid) return;
473
+
474
+ const splittableTags = new Set(["video", "audio", "img"]);
475
+ if (
476
+ element.timelineLocked ||
477
+ element.timingSource === "implicit" ||
478
+ element.compositionSrc ||
479
+ !splittableTags.has(element.tag) ||
480
+ !element.duration ||
481
+ !Number.isFinite(element.duration)
482
+ ) {
483
+ return;
484
+ }
485
+
486
+ if (splitTime <= element.start || splitTime >= element.start + element.duration) {
487
+ showToast("Playhead must be inside the clip to split.", "error");
488
+ return;
489
+ }
490
+
491
+ const patchTarget = buildPatchTarget(element);
492
+ if (!patchTarget) {
493
+ showToast("Clip is missing a patchable target.", "error");
494
+ return;
495
+ }
496
+
497
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
498
+ try {
499
+ const originalContent = await readFileContent(pid, targetPath);
500
+ const existingIds = collectHtmlIds(originalContent);
501
+ const baseId = element.domId || "clip";
502
+ let newId = `${baseId}-split`;
503
+ let suffix = 2;
504
+ while (existingIds.includes(newId)) {
505
+ newId = `${baseId}-split-${suffix++}`;
506
+ }
507
+
508
+ const response = await fetch(
509
+ `/api/projects/${pid}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
510
+ {
511
+ method: "POST",
512
+ headers: { "Content-Type": "application/json" },
513
+ body: JSON.stringify({ target: patchTarget, splitTime, newId }),
514
+ },
515
+ );
516
+ if (!response.ok) {
517
+ throw new Error("Split request failed");
518
+ }
519
+
520
+ const data = (await response.json()) as {
521
+ ok?: boolean;
522
+ changed?: boolean;
523
+ content?: string;
524
+ };
525
+ if (!data.ok || !data.changed) {
526
+ showToast("Failed to split clip — playhead may be outside the clip.", "error");
527
+ return;
528
+ }
529
+
530
+ const patchedContent = typeof data.content === "string" ? data.content : originalContent;
531
+
532
+ domEditSaveTimestampRef.current = Date.now();
533
+ await saveProjectFilesWithHistory({
534
+ projectId: pid,
535
+ label: "Split timeline clip",
536
+ kind: "timeline",
537
+ files: { [targetPath]: patchedContent },
538
+ readFile: async () => originalContent,
539
+ writeFile: writeProjectFile,
540
+ recordEdit,
541
+ });
542
+
543
+ reloadPreview();
544
+ const label = getTimelineElementLabel(element);
545
+ showToast(`Split ${label} at ${splitTime.toFixed(2)}s`, "info");
546
+ } catch (error) {
547
+ const message = error instanceof Error ? error.message : "Failed to split timeline clip";
548
+ showToast(message, "error");
549
+ }
550
+ },
551
+ [
552
+ activeCompPath,
553
+ recordEdit,
554
+ showToast,
555
+ writeProjectFile,
556
+ domEditSaveTimestampRef,
557
+ reloadPreview,
558
+ ],
559
+ );
560
+
469
561
  return {
470
562
  handleTimelineElementMove,
471
563
  handleTimelineElementResize,
472
564
  handleTimelineElementDelete,
565
+ handleTimelineElementSplit,
473
566
  handleTimelineAssetDrop,
474
567
  handleTimelineFileDrop,
475
568
  handleBlockedTimelineEdit,
@@ -20,6 +20,7 @@ import {
20
20
  Camera as PhCamera,
21
21
  ArrowClockwise,
22
22
  Gear,
23
+ Scissors as PhScissors,
23
24
  } from "@phosphor-icons/react";
24
25
  import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
25
26
 
@@ -55,3 +56,4 @@ export const RotateCcw = makeIcon(ArrowCounterClockwise);
55
56
  export const Camera = makeIcon(PhCamera);
56
57
  export const RotateCw = makeIcon(ArrowClockwise);
57
58
  export const Settings = makeIcon(Gear);
59
+ export const Scissors = makeIcon(PhScissors);
@@ -0,0 +1,99 @@
1
+ import { memo, useCallback, useEffect, useRef } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+
4
+ interface ClipContextMenuProps {
5
+ x: number;
6
+ y: number;
7
+ element: TimelineElement;
8
+ currentTime: number;
9
+ onClose: () => void;
10
+ onSplit: (element: TimelineElement, splitTime: number) => void;
11
+ onDelete: (element: TimelineElement) => void;
12
+ }
13
+
14
+ export const ClipContextMenu = memo(function ClipContextMenu({
15
+ x,
16
+ y,
17
+ element,
18
+ currentTime,
19
+ onClose,
20
+ onSplit,
21
+ onDelete,
22
+ }: ClipContextMenuProps) {
23
+ const menuRef = useRef<HTMLDivElement>(null);
24
+
25
+ const dismiss = useCallback(
26
+ (e: MouseEvent | KeyboardEvent) => {
27
+ if (e instanceof KeyboardEvent && e.key !== "Escape") return;
28
+ if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
29
+ onClose();
30
+ },
31
+ [onClose],
32
+ );
33
+
34
+ useEffect(() => {
35
+ document.addEventListener("mousedown", dismiss);
36
+ document.addEventListener("keydown", dismiss);
37
+ return () => {
38
+ document.removeEventListener("mousedown", dismiss);
39
+ document.removeEventListener("keydown", dismiss);
40
+ };
41
+ }, [dismiss]);
42
+
43
+ const adjustedX = Math.min(x, window.innerWidth - 200);
44
+ const adjustedY = Math.min(y, window.innerHeight - 200);
45
+
46
+ const isSplittable = ["video", "audio", "img"].includes(element.tag);
47
+ const canSplit =
48
+ isSplittable && currentTime > element.start && currentTime < element.start + element.duration;
49
+
50
+ const splitLabel = !isSplittable
51
+ ? null
52
+ : canSplit
53
+ ? `Split at ${currentTime.toFixed(2)}s`
54
+ : "Split (move playhead inside clip)";
55
+
56
+ return (
57
+ <div
58
+ ref={menuRef}
59
+ className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
60
+ style={{ left: adjustedX, top: adjustedY }}
61
+ >
62
+ {splitLabel && (
63
+ <>
64
+ <button
65
+ type="button"
66
+ className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
67
+ canSplit
68
+ ? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
69
+ : "text-neutral-600 cursor-not-allowed"
70
+ }`}
71
+ disabled={!canSplit}
72
+ onClick={() => {
73
+ if (canSplit) {
74
+ onSplit(element, currentTime);
75
+ onClose();
76
+ }
77
+ }}
78
+ >
79
+ <span>{splitLabel}</span>
80
+ <span className="text-neutral-500 text-[10px] ml-3">S</span>
81
+ </button>
82
+ <div className="my-1 border-t border-neutral-700/60" />
83
+ </>
84
+ )}
85
+
86
+ <button
87
+ type="button"
88
+ className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
89
+ onClick={() => {
90
+ onDelete(element);
91
+ onClose();
92
+ }}
93
+ >
94
+ <span>Delete</span>
95
+ <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
96
+ </button>
97
+ </div>
98
+ );
99
+ });