@hyperframes/studio 0.6.90 → 0.6.92

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 (58) hide show
  1. package/dist/assets/{index-DSLrl2tB.js → index-CDy8BuGq.js} +24 -24
  2. package/dist/assets/index-CmRIkCwI.js +251 -0
  3. package/dist/assets/index-rm9tn9nH.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +2 -0
  7. package/src/components/StudioPreviewArea.tsx +54 -13
  8. package/src/components/TimelineToolbar.tsx +52 -35
  9. package/src/components/editor/DomEditOverlay.tsx +79 -0
  10. package/src/components/editor/PropertyPanel.tsx +19 -10
  11. package/src/components/editor/gsapAnimatesProperty.ts +30 -0
  12. package/src/components/editor/manualEditingAvailability.test.ts +12 -0
  13. package/src/components/editor/manualEditingAvailability.ts +16 -0
  14. package/src/components/editor/manualEditsDom.ts +25 -5
  15. package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
  16. package/src/components/editor/manualEditsDomPatches.ts +17 -1
  17. package/src/components/editor/manualEditsSnapshot.ts +16 -0
  18. package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
  19. package/src/components/editor/useOffScreenIndicators.ts +197 -0
  20. package/src/components/nle/NLELayout.tsx +22 -32
  21. package/src/components/nle/TimelineEditorNotice.tsx +2 -25
  22. package/src/contexts/DomEditContext.tsx +4 -0
  23. package/src/hooks/gsapDragCommit.ts +119 -43
  24. package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
  25. package/src/hooks/gsapRuntimeBridge.ts +266 -41
  26. package/src/hooks/gsapRuntimeReaders.ts +16 -2
  27. package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
  28. package/src/hooks/useAppHotkeys.ts +48 -1
  29. package/src/hooks/useContextMenuDismiss.ts +29 -0
  30. package/src/hooks/useDomEditCommits.ts +7 -1
  31. package/src/hooks/useDomEditSession.ts +20 -4
  32. package/src/hooks/useEnableKeyframes.ts +3 -1
  33. package/src/hooks/useGestureCommit.ts +99 -13
  34. package/src/hooks/useGestureRecording.ts +18 -2
  35. package/src/hooks/useGsapScriptCommits.ts +24 -3
  36. package/src/hooks/useGsapSelectionHandlers.ts +19 -3
  37. package/src/hooks/useGsapTweenCache.ts +30 -10
  38. package/src/hooks/useRazorSplit.ts +298 -0
  39. package/src/hooks/useTimelineEditing.ts +15 -98
  40. package/src/player/components/ClipContextMenu.tsx +14 -25
  41. package/src/player/components/KeyframeDiamondContextMenu.tsx +16 -112
  42. package/src/player/components/PlayheadIndicator.tsx +43 -0
  43. package/src/player/components/Timeline.tsx +45 -38
  44. package/src/player/components/TimelineCanvas.tsx +29 -22
  45. package/src/player/components/TimelineClipDiamonds.tsx +3 -1
  46. package/src/player/components/timelineCallbacks.ts +44 -0
  47. package/src/player/components/timelineDragDrop.ts +2 -14
  48. package/src/player/components/useTimelineZoom.ts +18 -0
  49. package/src/player/store/playerStore.ts +20 -0
  50. package/src/utils/globalTimeCompiler.test.ts +2 -2
  51. package/src/utils/globalTimeCompiler.ts +2 -1
  52. package/src/utils/gsapSoftReload.test.ts +16 -0
  53. package/src/utils/gsapSoftReload.ts +43 -8
  54. package/src/utils/rdpSimplify.ts +3 -2
  55. package/src/utils/timelineElementSplit.test.ts +50 -0
  56. package/src/utils/timelineElementSplit.ts +32 -0
  57. package/dist/assets/index-BKuDHMYl.js +0 -146
  58. package/dist/assets/index-D2NkPomd.css +0 -1
@@ -0,0 +1,298 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { TimelineElement } from "../player";
3
+ import { usePlayerStore } from "../player";
4
+ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
5
+ import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers";
6
+ import {
7
+ canSplitElement,
8
+ buildPatchTarget,
9
+ readFileContent,
10
+ isSplitTimeWithinBounds,
11
+ } from "../utils/timelineElementSplit";
12
+ import type { RecordEditInput } from "./useTimelineEditing";
13
+
14
+ interface UseRazorSplitOptions {
15
+ projectId: string | null;
16
+ activeCompPath: string | null;
17
+ showToast: (message: string, tone?: "error" | "info") => void;
18
+ writeProjectFile: (path: string, content: string) => Promise<void>;
19
+ recordEdit: (input: RecordEditInput) => Promise<void>;
20
+ domEditSaveTimestampRef: React.MutableRefObject<number>;
21
+ reloadPreview: () => void;
22
+ isRecordingRef?: React.RefObject<boolean>;
23
+ }
24
+
25
+ function generateSplitId(existingIds: string[], baseId: string): string {
26
+ let newId = `${baseId}-split`;
27
+ let suffix = 2;
28
+ while (existingIds.includes(newId)) {
29
+ newId = `${baseId}-split-${suffix++}`;
30
+ }
31
+ return newId;
32
+ }
33
+
34
+ async function splitHtmlElement(
35
+ projectId: string,
36
+ targetPath: string,
37
+ patchTarget: NonNullable<ReturnType<typeof buildPatchTarget>>,
38
+ splitTime: number,
39
+ newId: string,
40
+ ): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
41
+ const response = await fetch(
42
+ `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
43
+ {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ target: patchTarget, splitTime, newId }),
47
+ },
48
+ );
49
+ if (!response.ok) throw new Error("Split request failed");
50
+ return (await response.json()) as { ok: boolean; changed?: boolean; content?: string };
51
+ }
52
+
53
+ async function splitGsapAnimations(
54
+ projectId: string,
55
+ targetPath: string,
56
+ originalId: string,
57
+ newId: string,
58
+ splitTime: number,
59
+ elementStart: number,
60
+ elementDuration: number,
61
+ ): Promise<{ content: string | null; skippedSelectors?: string[] }> {
62
+ const response = await fetch(
63
+ `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
64
+ {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify({
68
+ type: "split-animations",
69
+ originalId,
70
+ newId,
71
+ splitTime,
72
+ elementStart,
73
+ elementDuration,
74
+ }),
75
+ },
76
+ );
77
+ if (!response.ok) {
78
+ const errorBody = (await response.json().catch(() => null)) as { error?: string } | null;
79
+ if (errorBody?.error === "no GSAP script found in file") {
80
+ return { content: null };
81
+ }
82
+ throw new Error(errorBody?.error ?? `GSAP animation split failed (${response.status})`);
83
+ }
84
+ const data = (await response.json()) as {
85
+ ok?: boolean;
86
+ after?: string;
87
+ skippedSelectors?: string[];
88
+ };
89
+ return {
90
+ content: data.ok && data.after ? data.after : null,
91
+ skippedSelectors: data.skippedSelectors,
92
+ };
93
+ }
94
+
95
+ // fallow-ignore-next-line complexity
96
+ async function executeSplit(
97
+ pid: string,
98
+ element: TimelineElement,
99
+ splitTime: number,
100
+ activeCompPath: string | null,
101
+ writeProjectFile: (path: string, content: string) => Promise<void>,
102
+ ): Promise<{
103
+ targetPath: string;
104
+ originalContent: string;
105
+ patchedContent: string;
106
+ changed: boolean;
107
+ skippedSelectors?: string[];
108
+ }> {
109
+ const patchTarget = buildPatchTarget(element);
110
+ if (!patchTarget) throw new Error("Clip is missing a patchable target.");
111
+
112
+ const targetPath = element.sourceFile || activeCompPath || "index.html";
113
+ const originalContent = await readFileContent(pid, targetPath);
114
+ const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
115
+
116
+ const splitResult = await splitHtmlElement(pid, targetPath, patchTarget, splitTime, newId);
117
+ if (!splitResult.ok) throw new Error("Failed to split clip.");
118
+ if (!splitResult.changed) {
119
+ return { targetPath, originalContent, patchedContent: originalContent, changed: false };
120
+ }
121
+
122
+ let patchedContent =
123
+ typeof splitResult.content === "string" ? splitResult.content : originalContent;
124
+
125
+ let skippedSelectors: string[] | undefined;
126
+ if (element.domId) {
127
+ try {
128
+ const gsapResult = await splitGsapAnimations(
129
+ pid,
130
+ targetPath,
131
+ element.domId,
132
+ newId,
133
+ splitTime,
134
+ element.start,
135
+ element.duration,
136
+ );
137
+ if (gsapResult.content) patchedContent = gsapResult.content;
138
+ if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
139
+ } catch (gsapError) {
140
+ // GSAP mutation failed — the HTML split already wrote to disk.
141
+ // Restore the original content to avoid a corrupt half-split state.
142
+ await writeProjectFile(targetPath, originalContent);
143
+ throw gsapError;
144
+ }
145
+ }
146
+
147
+ return { targetPath, originalContent, patchedContent, changed: true, skippedSelectors };
148
+ }
149
+
150
+ export function useRazorSplit({
151
+ projectId,
152
+ activeCompPath,
153
+ showToast,
154
+ writeProjectFile,
155
+ recordEdit,
156
+ domEditSaveTimestampRef,
157
+ reloadPreview,
158
+ isRecordingRef,
159
+ }: UseRazorSplitOptions) {
160
+ const projectIdRef = useRef(projectId);
161
+ projectIdRef.current = projectId;
162
+
163
+ const handleRazorSplit = useCallback(
164
+ // fallow-ignore-next-line complexity
165
+ async (element: TimelineElement, splitTime: number) => {
166
+ if (isRecordingRef?.current) {
167
+ showToast("Cannot edit timeline while recording", "error");
168
+ return;
169
+ }
170
+
171
+ const pid = projectIdRef.current;
172
+ if (!pid || !canSplitElement(element)) return;
173
+
174
+ if (!isSplitTimeWithinBounds(splitTime, element.start, element.duration)) {
175
+ return;
176
+ }
177
+
178
+ try {
179
+ const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
180
+ await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
181
+
182
+ if (!changed) {
183
+ showToast("Failed to split clip — playhead may be outside the clip", "error");
184
+ return;
185
+ }
186
+
187
+ domEditSaveTimestampRef.current = Date.now();
188
+ await saveProjectFilesWithHistory({
189
+ projectId: pid,
190
+ label: "Split timeline clip",
191
+ kind: "timeline",
192
+ files: { [targetPath]: patchedContent },
193
+ readFile: async () => originalContent,
194
+ writeFile: writeProjectFile,
195
+ recordEdit,
196
+ });
197
+
198
+ reloadPreview();
199
+ showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
200
+ if (skippedSelectors?.length) {
201
+ showToast(
202
+ `Some animations use non-ID selectors (${skippedSelectors.join(", ")}) and were not retargeted`,
203
+ "info",
204
+ );
205
+ }
206
+ } catch (error) {
207
+ const message = error instanceof Error ? error.message : "Failed to split timeline clip";
208
+ showToast(message, "error");
209
+ }
210
+ },
211
+ [
212
+ activeCompPath,
213
+ recordEdit,
214
+ showToast,
215
+ writeProjectFile,
216
+ domEditSaveTimestampRef,
217
+ reloadPreview,
218
+ isRecordingRef,
219
+ ],
220
+ );
221
+
222
+ // fallow-ignore-next-line complexity
223
+ const handleRazorSplitAll = useCallback(
224
+ async (splitTime: number) => {
225
+ if (isRecordingRef?.current) {
226
+ showToast("Cannot edit timeline while recording", "error");
227
+ return;
228
+ }
229
+
230
+ const pid = projectIdRef.current;
231
+ if (!pid) return;
232
+ const { elements } = usePlayerStore.getState();
233
+ const splittable = elements.filter(
234
+ (el) => canSplitElement(el) && splitTime > el.start && splitTime < el.start + el.duration,
235
+ );
236
+ if (splittable.length === 0) return;
237
+
238
+ try {
239
+ const originals = new Map<string, string>();
240
+ for (const el of splittable) {
241
+ const path = el.sourceFile || activeCompPath || "index.html";
242
+ if (!originals.has(path)) {
243
+ originals.set(path, await readFileContent(pid, path));
244
+ }
245
+ }
246
+
247
+ let splitCount = 0;
248
+ const finalContent = new Map<string, string>();
249
+
250
+ for (const element of splittable) {
251
+ const result = await executeSplit(
252
+ pid,
253
+ element,
254
+ splitTime,
255
+ activeCompPath,
256
+ writeProjectFile,
257
+ );
258
+ if (result.changed) {
259
+ finalContent.set(result.targetPath, result.patchedContent);
260
+ await writeProjectFile(result.targetPath, result.patchedContent);
261
+ splitCount++;
262
+ }
263
+ }
264
+
265
+ if (splitCount === 0) return;
266
+
267
+ domEditSaveTimestampRef.current = Date.now();
268
+ await recordEdit({
269
+ label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
270
+ kind: "timeline",
271
+ files: Object.fromEntries(
272
+ [...finalContent].map(([path, after]) => [
273
+ path,
274
+ { before: originals.get(path) ?? "", after },
275
+ ]),
276
+ ),
277
+ });
278
+
279
+ reloadPreview();
280
+ showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
281
+ } catch (error) {
282
+ const message = error instanceof Error ? error.message : "Failed to split clips";
283
+ showToast(message, "error");
284
+ }
285
+ },
286
+ [
287
+ activeCompPath,
288
+ recordEdit,
289
+ showToast,
290
+ writeProjectFile,
291
+ domEditSaveTimestampRef,
292
+ reloadPreview,
293
+ isRecordingRef,
294
+ ],
295
+ );
296
+
297
+ return { handleRazorSplit, handleRazorSplitAll };
298
+ }
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { usePlayerStore } from "../player";
4
+ import { useRazorSplit } from "./useRazorSplit";
4
5
  import {
5
6
  buildTimelineAssetId,
6
7
  buildTimelineAssetInsertHtml,
@@ -30,7 +31,7 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
30
31
 
31
32
  // ── Types ──
32
33
 
33
- interface RecordEditInput {
34
+ export interface RecordEditInput {
34
35
  label: string;
35
36
  kind: EditHistoryKind;
36
37
  coalesceKey?: string;
@@ -386,108 +387,24 @@ export function useTimelineEditing({
386
387
  [showToast],
387
388
  );
388
389
 
389
- const handleTimelineElementSplit = useCallback(
390
- async (element: TimelineElement, splitTime: number) => {
391
- if (isRecordingRef?.current) {
392
- showToast("Cannot edit timeline while recording", "error");
393
- return;
394
- }
395
- const pid = projectIdRef.current;
396
- if (!pid) return;
397
-
398
- const splittableTags = new Set(["video", "audio", "img"]);
399
- if (
400
- element.timelineLocked ||
401
- element.timingSource === "implicit" ||
402
- element.compositionSrc ||
403
- !splittableTags.has(element.tag) ||
404
- !element.duration ||
405
- !Number.isFinite(element.duration)
406
- ) {
407
- return;
408
- }
409
-
410
- if (splitTime <= element.start || splitTime >= element.start + element.duration) {
411
- showToast("Playhead must be inside the clip to split.", "error");
412
- return;
413
- }
414
-
415
- const patchTarget = buildPatchTarget(element);
416
- if (!patchTarget) {
417
- showToast("Clip is missing a patchable target.", "error");
418
- return;
419
- }
420
-
421
- const targetPath = element.sourceFile || activeCompPath || "index.html";
422
- try {
423
- const originalContent = await readFileContent(pid, targetPath);
424
- const existingIds = collectHtmlIds(originalContent);
425
- const baseId = element.domId || "clip";
426
- let newId = `${baseId}-split`;
427
- let suffix = 2;
428
- while (existingIds.includes(newId)) {
429
- newId = `${baseId}-split-${suffix++}`;
430
- }
431
-
432
- const response = await fetch(
433
- `/api/projects/${pid}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
434
- {
435
- method: "POST",
436
- headers: { "Content-Type": "application/json" },
437
- body: JSON.stringify({ target: patchTarget, splitTime, newId }),
438
- },
439
- );
440
- if (!response.ok) {
441
- throw new Error("Split request failed");
442
- }
443
-
444
- const data = (await response.json()) as {
445
- ok?: boolean;
446
- changed?: boolean;
447
- content?: string;
448
- };
449
- if (!data.ok || !data.changed) {
450
- showToast("Failed to split clip — playhead may be outside the clip.", "error");
451
- return;
452
- }
453
-
454
- const patchedContent = typeof data.content === "string" ? data.content : originalContent;
455
-
456
- domEditSaveTimestampRef.current = Date.now();
457
- await saveProjectFilesWithHistory({
458
- projectId: pid,
459
- label: "Split timeline clip",
460
- kind: "timeline",
461
- files: { [targetPath]: patchedContent },
462
- readFile: async () => originalContent,
463
- writeFile: writeProjectFile,
464
- recordEdit,
465
- });
466
-
467
- reloadPreview();
468
- const label = getTimelineElementLabel(element);
469
- showToast(`Split ${label} at ${splitTime.toFixed(2)}s`, "info");
470
- } catch (error) {
471
- const message = error instanceof Error ? error.message : "Failed to split timeline clip";
472
- showToast(message, "error");
473
- }
474
- },
475
- [
476
- activeCompPath,
477
- recordEdit,
478
- showToast,
479
- writeProjectFile,
480
- domEditSaveTimestampRef,
481
- reloadPreview,
482
- isRecordingRef,
483
- ],
484
- );
390
+ const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({
391
+ projectId,
392
+ activeCompPath,
393
+ showToast,
394
+ writeProjectFile,
395
+ recordEdit,
396
+ domEditSaveTimestampRef,
397
+ reloadPreview,
398
+ isRecordingRef,
399
+ });
485
400
 
486
401
  return {
487
402
  handleTimelineElementMove,
488
403
  handleTimelineElementResize,
489
404
  handleTimelineElementDelete,
490
- handleTimelineElementSplit,
405
+ handleTimelineElementSplit: handleRazorSplit,
406
+ handleRazorSplit,
407
+ handleRazorSplitAll,
491
408
  handleTimelineAssetDrop,
492
409
  handleTimelineFileDrop,
493
410
  handleBlockedTimelineEdit,
@@ -1,5 +1,8 @@
1
- import { memo, useCallback, useEffect, useRef } from "react";
1
+ import { memo } from "react";
2
+ import { createPortal } from "react-dom";
2
3
  import type { TimelineElement } from "../store/playerStore";
4
+ import { canSplitElement } from "../../utils/timelineElementSplit";
5
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
3
6
 
4
7
  interface ClipContextMenuProps {
5
8
  x: number;
@@ -20,30 +23,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({
20
23
  onSplit,
21
24
  onDelete,
22
25
  }: ClipContextMenuProps) {
23
- const menuRef = useRef<HTMLDivElement>(null);
26
+ const menuRef = useContextMenuDismiss(onClose);
24
27
 
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);
28
+ const menuWidth = 200;
29
+ const menuHeight = 80;
30
+ const overflowY = y + menuHeight - window.innerHeight;
31
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
32
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
45
33
 
46
- const isSplittable = ["video", "audio", "img"].includes(element.tag);
34
+ const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
47
35
  const canSplit =
48
36
  isSplittable && currentTime > element.start && currentTime < element.start + element.duration;
49
37
 
@@ -53,7 +41,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
53
41
  ? `Split at ${currentTime.toFixed(2)}s`
54
42
  : "Split (move playhead inside clip)";
55
43
 
56
- return (
44
+ return createPortal(
57
45
  <div
58
46
  ref={menuRef}
59
47
  className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
@@ -94,6 +82,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
94
82
  <span>Delete</span>
95
83
  <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
96
84
  </button>
97
- </div>
85
+ </div>,
86
+ document.body,
98
87
  );
99
88
  });
@@ -1,11 +1,13 @@
1
- import { memo, useCallback, useEffect, useRef } from "react";
2
- import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants";
1
+ import { memo } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
3
4
 
4
5
  export interface KeyframeDiamondContextMenuState {
5
6
  x: number;
6
7
  y: number;
7
8
  elementId: string;
8
9
  percentage: number;
10
+ tweenPercentage?: number;
9
11
  currentEase?: string;
10
12
  }
11
13
 
@@ -14,123 +16,36 @@ interface KeyframeDiamondContextMenuProps {
14
16
  onClose: () => void;
15
17
  onDelete: (elementId: string, percentage: number) => void;
16
18
  onDeleteAll: (elementId: string) => void;
17
- onChangeEase: (elementId: string, percentage: number, ease: string) => void;
18
- onCopyProperties: (elementId: string, percentage: number) => void;
19
+ onChangeEase?: (elementId: string, percentage: number, ease: string) => void;
20
+ onCopyProperties?: (elementId: string, percentage: number) => void;
19
21
  }
20
22
 
21
- const EASE_PRESETS = [
22
- "none",
23
- "power1.out",
24
- "power2.out",
25
- "power3.out",
26
- "power1.in",
27
- "power2.in",
28
- "power1.inOut",
29
- "power2.inOut",
30
- "back.out",
31
- "elastic.out",
32
- "bounce.out",
33
- "expo.out",
34
- ] as const;
35
-
36
23
  export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({
37
24
  state,
38
25
  onClose,
39
26
  onDelete,
40
27
  onDeleteAll,
41
- onChangeEase,
42
- onCopyProperties,
43
28
  }: KeyframeDiamondContextMenuProps) {
44
- const menuRef = useRef<HTMLDivElement>(null);
45
- const easeSubmenuRef = useRef<HTMLDivElement>(null);
46
-
47
- const dismiss = useCallback(
48
- (e: MouseEvent | KeyboardEvent) => {
49
- if (e instanceof KeyboardEvent && e.key !== "Escape") return;
50
- if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
51
- onClose();
52
- },
53
- [onClose],
54
- );
55
-
56
- useEffect(() => {
57
- document.addEventListener("mousedown", dismiss);
58
- document.addEventListener("keydown", dismiss);
59
- return () => {
60
- document.removeEventListener("mousedown", dismiss);
61
- document.removeEventListener("keydown", dismiss);
62
- };
63
- }, [dismiss]);
29
+ const menuRef = useContextMenuDismiss(onClose);
64
30
 
65
- const adjustedX = Math.min(state.x, window.innerWidth - 200);
66
- const adjustedY = Math.min(state.y, window.innerHeight - 300);
31
+ const menuWidth = 200;
32
+ const menuHeight = 70;
33
+ const overflowY = state.y + menuHeight - window.innerHeight;
34
+ const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
35
+ const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
67
36
 
68
- const currentEaseLabel = state.currentEase
69
- ? (EASE_LABELS[state.currentEase] ?? state.currentEase)
70
- : "Default";
71
-
72
- return (
37
+ return createPortal(
73
38
  <div
74
39
  ref={menuRef}
75
40
  className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
76
41
  style={{ left: adjustedX, top: adjustedY }}
77
42
  >
78
- {/* Ease submenu */}
79
- <div className="relative group">
80
- <button
81
- type="button"
82
- className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
83
- >
84
- <span>
85
- Ease: <span className="text-neutral-500">{currentEaseLabel}</span>
86
- </span>
87
- <svg width="8" height="8" viewBox="0 0 8 8" className="text-neutral-500 ml-2">
88
- <path d="M3 1l4 3-4 3" fill="none" stroke="currentColor" strokeWidth="1.2" />
89
- </svg>
90
- </button>
91
- <div
92
- ref={easeSubmenuRef}
93
- className="absolute left-full top-0 ml-0.5 hidden group-hover:block bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px] max-h-[300px] overflow-y-auto"
94
- >
95
- {EASE_PRESETS.map((ease) => (
96
- <button
97
- key={ease}
98
- type="button"
99
- className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-neutral-800 cursor-pointer text-left ${
100
- ease === state.currentEase ? "text-white font-medium" : "text-neutral-300"
101
- }`}
102
- onClick={() => {
103
- onChangeEase(state.elementId, state.percentage, ease);
104
- onClose();
105
- }}
106
- >
107
- {ease === state.currentEase && (
108
- <svg
109
- width="8"
110
- height="8"
111
- viewBox="0 0 8 8"
112
- className="text-green-400 flex-shrink-0"
113
- >
114
- <path d="M1 4l2 2 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" />
115
- </svg>
116
- )}
117
- <span className={ease === state.currentEase ? "" : "ml-[16px]"}>
118
- {EASE_LABELS[ease] ?? ease}
119
- </span>
120
- </button>
121
- ))}
122
- </div>
123
- </div>
124
-
125
- {/* Separator */}
126
- <div className="my-1 border-t border-neutral-700/60" />
127
-
128
43
  {/* Delete */}
129
44
  <button
130
45
  type="button"
131
46
  className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
132
47
  onClick={() => {
133
- onDelete(state.elementId, state.percentage);
48
+ onDelete(state.elementId, state.tweenPercentage ?? state.percentage);
134
49
  onClose();
135
50
  }}
136
51
  >
@@ -147,18 +62,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
147
62
  >
148
63
  Delete All Keyframes
149
64
  </button>
150
-
151
- {/* Copy Properties */}
152
- <button
153
- type="button"
154
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
155
- onClick={() => {
156
- onCopyProperties(state.elementId, state.percentage);
157
- onClose();
158
- }}
159
- >
160
- Copy Properties
161
- </button>
162
- </div>
65
+ </div>,
66
+ document.body,
163
67
  );
164
68
  });