@hyperframes/studio 0.6.90 → 0.6.91

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.
@@ -0,0 +1,303 @@
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
+ SPLIT_BOUNDARY_EPSILON_S,
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
+ const clipStart = element.start;
175
+ const clipEnd = element.start + element.duration;
176
+ if (
177
+ splitTime <= clipStart + SPLIT_BOUNDARY_EPSILON_S ||
178
+ splitTime >= clipEnd - SPLIT_BOUNDARY_EPSILON_S
179
+ ) {
180
+ return;
181
+ }
182
+
183
+ try {
184
+ const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
185
+ await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
186
+
187
+ if (!changed) {
188
+ showToast("Failed to split clip — playhead may be outside the clip", "error");
189
+ return;
190
+ }
191
+
192
+ domEditSaveTimestampRef.current = Date.now();
193
+ await saveProjectFilesWithHistory({
194
+ projectId: pid,
195
+ label: "Split timeline clip",
196
+ kind: "timeline",
197
+ files: { [targetPath]: patchedContent },
198
+ readFile: async () => originalContent,
199
+ writeFile: writeProjectFile,
200
+ recordEdit,
201
+ });
202
+
203
+ reloadPreview();
204
+ showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info");
205
+ if (skippedSelectors?.length) {
206
+ showToast(
207
+ `Some animations use non-ID selectors (${skippedSelectors.join(", ")}) and were not retargeted`,
208
+ "info",
209
+ );
210
+ }
211
+ } catch (error) {
212
+ const message = error instanceof Error ? error.message : "Failed to split timeline clip";
213
+ showToast(message, "error");
214
+ }
215
+ },
216
+ [
217
+ activeCompPath,
218
+ recordEdit,
219
+ showToast,
220
+ writeProjectFile,
221
+ domEditSaveTimestampRef,
222
+ reloadPreview,
223
+ isRecordingRef,
224
+ ],
225
+ );
226
+
227
+ // fallow-ignore-next-line complexity
228
+ const handleRazorSplitAll = useCallback(
229
+ async (splitTime: number) => {
230
+ if (isRecordingRef?.current) {
231
+ showToast("Cannot edit timeline while recording", "error");
232
+ return;
233
+ }
234
+
235
+ const pid = projectIdRef.current;
236
+ if (!pid) return;
237
+ const { elements } = usePlayerStore.getState();
238
+ const splittable = elements.filter(
239
+ (el) => canSplitElement(el) && splitTime > el.start && splitTime < el.start + el.duration,
240
+ );
241
+ if (splittable.length === 0) return;
242
+
243
+ try {
244
+ const originals = new Map<string, string>();
245
+ for (const el of splittable) {
246
+ const path = el.sourceFile || activeCompPath || "index.html";
247
+ if (!originals.has(path)) {
248
+ originals.set(path, await readFileContent(pid, path));
249
+ }
250
+ }
251
+
252
+ let splitCount = 0;
253
+ const finalContent = new Map<string, string>();
254
+
255
+ for (const element of splittable) {
256
+ const result = await executeSplit(
257
+ pid,
258
+ element,
259
+ splitTime,
260
+ activeCompPath,
261
+ writeProjectFile,
262
+ );
263
+ if (result.changed) {
264
+ finalContent.set(result.targetPath, result.patchedContent);
265
+ await writeProjectFile(result.targetPath, result.patchedContent);
266
+ splitCount++;
267
+ }
268
+ }
269
+
270
+ if (splitCount === 0) return;
271
+
272
+ domEditSaveTimestampRef.current = Date.now();
273
+ await recordEdit({
274
+ label: `Split ${splitCount} clips at ${splitTime.toFixed(2)}s`,
275
+ kind: "timeline",
276
+ files: Object.fromEntries(
277
+ [...finalContent].map(([path, after]) => [
278
+ path,
279
+ { before: originals.get(path) ?? "", after },
280
+ ]),
281
+ ),
282
+ });
283
+
284
+ reloadPreview();
285
+ showToast(`Split ${splitCount} clips at ${splitTime.toFixed(2)}s`, "info");
286
+ } catch (error) {
287
+ const message = error instanceof Error ? error.message : "Failed to split clips";
288
+ showToast(message, "error");
289
+ }
290
+ },
291
+ [
292
+ activeCompPath,
293
+ recordEdit,
294
+ showToast,
295
+ writeProjectFile,
296
+ domEditSaveTimestampRef,
297
+ reloadPreview,
298
+ isRecordingRef,
299
+ ],
300
+ );
301
+
302
+ return { handleRazorSplit, handleRazorSplitAll };
303
+ }
@@ -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,7 @@
1
- import { memo, useCallback, useEffect, useRef } from "react";
1
+ import { memo } from "react";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
+ import { canSplitElement } from "../../utils/timelineElementSplit";
4
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
3
5
 
4
6
  interface ClipContextMenuProps {
5
7
  x: number;
@@ -20,30 +22,12 @@ export const ClipContextMenu = memo(function ClipContextMenu({
20
22
  onSplit,
21
23
  onDelete,
22
24
  }: 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]);
25
+ const menuRef = useContextMenuDismiss(onClose);
42
26
 
43
27
  const adjustedX = Math.min(x, window.innerWidth - 200);
44
28
  const adjustedY = Math.min(y, window.innerHeight - 200);
45
29
 
46
- const isSplittable = ["video", "audio", "img"].includes(element.tag);
30
+ const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
47
31
  const canSplit =
48
32
  isSplittable && currentTime > element.start && currentTime < element.start + element.duration;
49
33
 
@@ -1,5 +1,6 @@
1
- import { memo, useCallback, useEffect, useRef } from "react";
1
+ import { memo, useRef } from "react";
2
2
  import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants";
3
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
3
4
 
4
5
  export interface KeyframeDiamondContextMenuState {
5
6
  x: number;
@@ -41,27 +42,9 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
41
42
  onChangeEase,
42
43
  onCopyProperties,
43
44
  }: KeyframeDiamondContextMenuProps) {
44
- const menuRef = useRef<HTMLDivElement>(null);
45
+ const menuRef = useContextMenuDismiss(onClose);
45
46
  const easeSubmenuRef = useRef<HTMLDivElement>(null);
46
47
 
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]);
64
-
65
48
  const adjustedX = Math.min(state.x, window.innerWidth - 200);
66
49
  const adjustedY = Math.min(state.y, window.innerHeight - 300);
67
50
 
@@ -0,0 +1,43 @@
1
+ // fallow-ignore-file dead-code
2
+ /**
3
+ * Shared playhead visual used by TimelineCanvas (real playhead) and
4
+ * TimelineEditorNotice (animated illustration).
5
+ */
6
+ interface PlayheadIndicatorProps {
7
+ /** CSS color, defaults to the HF accent variable */
8
+ color?: string;
9
+ /** Glow shadow color, defaults to translucent accent */
10
+ glowColor?: string;
11
+ }
12
+
13
+ export function PlayheadIndicator({
14
+ color = "var(--hf-accent, #3CE6AC)",
15
+ glowColor = "rgba(60,230,172,0.5)",
16
+ }: PlayheadIndicatorProps) {
17
+ return (
18
+ <>
19
+ <div
20
+ className="absolute top-0 bottom-0"
21
+ style={{
22
+ left: "50%",
23
+ width: 2,
24
+ marginLeft: -1,
25
+ background: color,
26
+ boxShadow: `0 0 8px ${glowColor}`,
27
+ }}
28
+ />
29
+ <div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
30
+ <div
31
+ style={{
32
+ width: 0,
33
+ height: 0,
34
+ borderLeft: "6px solid transparent",
35
+ borderRight: "6px solid transparent",
36
+ borderTop: `8px solid ${color}`,
37
+ filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
38
+ }}
39
+ />
40
+ </div>
41
+ </>
42
+ );
43
+ }