@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
@@ -1,7 +1,37 @@
1
+ // GSAP's CSSPlugin takes ownership of the element's entire transform stack
2
+ // when it tweens ANY of these — it bakes the CSS `translate` longhand into
3
+ // style.transform at init and writes `translate: none` every tick. Position
4
+ // reapply/strip logic must therefore stand down for all of them, not just x/y.
5
+ const GSAP_TRANSFORM_PROPS = [
6
+ "x",
7
+ "y",
8
+ "xPercent",
9
+ "yPercent",
10
+ "scale",
11
+ "scaleX",
12
+ "scaleY",
13
+ "rotation",
14
+ "rotate",
15
+ "rotationX",
16
+ "rotationY",
17
+ "skewX",
18
+ "skewY",
19
+ "transform",
20
+ ];
21
+
22
+ /**
23
+ * True when GSAP animates any transform-affecting property on the element,
24
+ * meaning GSAP owns `style.transform` and has neutralized CSS `translate`.
25
+ */
26
+ export function gsapAnimatesTransform(el: HTMLElement): boolean {
27
+ return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS);
28
+ }
29
+
1
30
  /**
2
31
  * Checks whether GSAP actively animates one or more CSS/GSAP properties on
3
32
  * the given element by inspecting all registered `__timelines`.
4
33
  */
34
+ // fallow-ignore-next-line complexity
5
35
  export function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean {
6
36
  const win = el.ownerDocument.defaultView as
7
37
  | (Window & {
@@ -25,6 +25,18 @@ describe("manual editing availability", () => {
25
25
  expect(availability.STUDIO_MOTION_PANEL_ENABLED).toBe(false);
26
26
  });
27
27
 
28
+ it("disables GSAP drag intercept by default", async () => {
29
+ const availability = await loadAvailabilityWithEnv({});
30
+ expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(false);
31
+ });
32
+
33
+ it("enables GSAP drag intercept when env var is set", async () => {
34
+ const availability = await loadAvailabilityWithEnv({
35
+ VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT: "true",
36
+ });
37
+ expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(true);
38
+ });
39
+
28
40
  it("disables preview selection when the inspector panel flag is explicitly off", async () => {
29
41
  const availability = await loadAvailabilityWithEnv({
30
42
  VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0",
@@ -77,6 +77,22 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
77
77
  true,
78
78
  );
79
79
 
80
+ export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
81
+ env,
82
+ ["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"],
83
+ false,
84
+ );
85
+
86
+ // When disabled (the default), drag/resize/rotate commits always take the CSS
87
+ // persist path instead of being intercepted into GSAP script keyframe
88
+ // mutations. The keyframe intercept rewrites timeline tweens from drag
89
+ // gestures and is opt-in until its recording path is hardened.
90
+ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
91
+ env,
92
+ ["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"],
93
+ true,
94
+ );
95
+
80
96
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
81
97
 
82
98
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -277,14 +277,34 @@ export function applyStudioPathOffsetDraft(
277
277
 
278
278
  const isGsapAnimated = gsapAnimatesProperty(element, "x", "y");
279
279
  if (isGsapAnimated) {
280
- // For GSAP-animated elements: use gsap.set for positioning (the timeline
281
- // is paused during drag). Set translate:none explicitly to prevent
282
- // double-counting with the transform.
283
280
  element.style.setProperty("translate", "none");
284
281
  const win = element.ownerDocument.defaultView as
285
- | (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
282
+ | (Window & {
283
+ gsap?: {
284
+ set: (el: Element, vars: Record<string, unknown>) => void;
285
+ getProperty: (el: Element, prop: string) => number;
286
+ };
287
+ })
286
288
  | null;
287
- win?.gsap?.set(element, { x: offset.x, y: offset.y });
289
+ if (win?.gsap) {
290
+ const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? "");
291
+ const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? "");
292
+ const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? "");
293
+ const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? "");
294
+ const gsapBaseX = Number.isFinite(baseX)
295
+ ? baseX
296
+ : (win.gsap.getProperty(element, "x") as number);
297
+ const gsapBaseY = Number.isFinite(baseY)
298
+ ? baseY
299
+ : (win.gsap.getProperty(element, "y") as number);
300
+ if (!Number.isFinite(baseX))
301
+ element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX));
302
+ if (!Number.isFinite(baseY))
303
+ element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY));
304
+ const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0);
305
+ const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0);
306
+ win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY });
307
+ }
288
308
  } else {
289
309
  // Non-GSAP elements: use CSS translate as before.
290
310
  element.style.setProperty(
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  // @vitest-environment happy-dom
2
3
 
3
4
  import { describe, it, expect } from "vitest";
@@ -72,7 +72,23 @@ function appendTransformDisplayOps(element: HTMLElement, ops: PatchOperation[]):
72
72
 
73
73
  export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] {
74
74
  const ops: PatchOperation[] = [];
75
- collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, "translate"], ops);
75
+ collectInlineStyleOps(element, [STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP], ops);
76
+ // When GSAP owns the element's transform, the live inline translate is kept
77
+ // at "none" (the offset lives in GSAP's cache — see applyStudioPathOffset).
78
+ // Persist the var() expression in that case, so a reload re-folds the offset.
79
+ const inlineTranslate = element.style.getPropertyValue("translate");
80
+ const hasOffsetVars =
81
+ element.style.getPropertyValue(STUDIO_OFFSET_X_PROP) ||
82
+ element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
83
+ const translateValue =
84
+ inlineTranslate && inlineTranslate !== "none"
85
+ ? inlineTranslate
86
+ : hasOffsetVars
87
+ ? `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`
88
+ : null;
89
+ if (translateValue) {
90
+ ops.push({ type: "inline-style", property: "translate", value: translateValue });
91
+ }
76
92
  ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" });
77
93
  collectAttributeOps(
78
94
  element,
@@ -183,6 +183,22 @@ export function restoreStudioPathOffset(
183
183
  STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR,
184
184
  previous.originalInlineTranslate,
185
185
  );
186
+
187
+ // Restore GSAP x/y if a draft was applied via gsap.set during drag
188
+ const baseX = element.getAttribute("data-hf-drag-gsap-base-x");
189
+ const baseY = element.getAttribute("data-hf-drag-gsap-base-y");
190
+ if (baseX != null || baseY != null) {
191
+ const win = element.ownerDocument.defaultView as
192
+ | (Window & { gsap?: { set: (el: Element, vars: Record<string, unknown>) => void } })
193
+ | null;
194
+ if (win?.gsap) {
195
+ const x = Number.parseFloat(baseX ?? "0") || 0;
196
+ const y = Number.parseFloat(baseY ?? "0") || 0;
197
+ win.gsap.set(element, { x, y });
198
+ }
199
+ element.removeAttribute("data-hf-drag-gsap-base-x");
200
+ element.removeAttribute("data-hf-drag-gsap-base-y");
201
+ }
186
202
  }
187
203
 
188
204
  /* ── Clear functions ──────────────────────────────────────────────── */
@@ -13,6 +13,7 @@ type KeyframeEntry = Array<{
13
13
  interface PropertyPanel3dTransformProps {
14
14
  gsapRuntimeValues: Record<string, number>;
15
15
  gsapAnimId: string | null;
16
+ resolveAnimIdForProp?: (prop: string) => string | null;
16
17
  gsapKeyframes: KeyframeEntry;
17
18
  currentPct: number;
18
19
  elStart: number;
@@ -31,6 +32,7 @@ interface PropertyPanel3dTransformProps {
31
32
  export function PropertyPanel3dTransform({
32
33
  gsapRuntimeValues,
33
34
  gsapAnimId,
35
+ resolveAnimIdForProp,
34
36
  gsapKeyframes,
35
37
  currentPct,
36
38
  elStart,
@@ -41,6 +43,7 @@ export function PropertyPanel3dTransform({
41
43
  onRemoveKeyframe,
42
44
  onConvertToKeyframes,
43
45
  }: PropertyPanel3dTransformProps) {
46
+ const idFor = (prop: string) => resolveAnimIdForProp?.(prop) ?? gsapAnimId;
44
47
  return (
45
48
  <div className="mt-3 border-t border-neutral-800/40 pt-3">
46
49
  <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-neutral-600">
@@ -72,8 +75,14 @@ export function PropertyPanel3dTransform({
72
75
  void onCommitAnimatedProperty(element, "z", gsapRuntimeValues?.z ?? 0);
73
76
  }
74
77
  }}
75
- onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
76
- onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
78
+ onRemoveKeyframe={(pct) => {
79
+ const id = idFor("z");
80
+ if (id) onRemoveKeyframe?.(id, pct);
81
+ }}
82
+ onConvertToKeyframes={() => {
83
+ const id = idFor("z");
84
+ if (id) onConvertToKeyframes?.(id);
85
+ }}
77
86
  />
78
87
  )}
79
88
  </div>
@@ -102,8 +111,14 @@ export function PropertyPanel3dTransform({
102
111
  void onCommitAnimatedProperty(element, "scale", gsapRuntimeValues?.scale ?? 1);
103
112
  }
104
113
  }}
105
- onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
106
- onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
114
+ onRemoveKeyframe={(pct) => {
115
+ const id = idFor("scale");
116
+ if (id) onRemoveKeyframe?.(id, pct);
117
+ }}
118
+ onConvertToKeyframes={() => {
119
+ const id = idFor("scale");
120
+ if (id) onConvertToKeyframes?.(id);
121
+ }}
107
122
  />
108
123
  )}
109
124
  </div>
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Detects GSAP-animated elements whose center is outside the visible composition
3
+ * area and returns edge-clamped indicator positions for each.
4
+ */
5
+ import { useRef, useState, type RefObject } from "react";
6
+ import { useMountEffect } from "../../hooks/useMountEffect";
7
+
8
+ export interface OffScreenIndicator {
9
+ key: string;
10
+ elementId: string;
11
+ left: number;
12
+ top: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ interface CompRect {
18
+ left: number;
19
+ top: number;
20
+ width: number;
21
+ height: number;
22
+ scaleX: number;
23
+ scaleY: number;
24
+ }
25
+
26
+ type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
27
+
28
+ function isHtmlElement(node: unknown): node is HTMLElement {
29
+ return (
30
+ typeof node === "object" &&
31
+ node !== null &&
32
+ typeof (node as HTMLElement).getBoundingClientRect === "function" &&
33
+ typeof (node as HTMLElement).tagName === "string"
34
+ );
35
+ }
36
+
37
+ function collectGsapTargetElements(iframe: HTMLIFrameElement): HTMLElement[] {
38
+ const win = iframe.contentWindow as
39
+ | (Window & { __timelines?: Record<string, TimelineLike> })
40
+ | null;
41
+ if (!win) return [];
42
+
43
+ let timelines: Record<string, TimelineLike> | undefined;
44
+ try {
45
+ timelines = win.__timelines;
46
+ } catch {
47
+ return [];
48
+ }
49
+ if (!timelines) return [];
50
+
51
+ const seen = new Set<HTMLElement>();
52
+ for (const tl of Object.values(timelines)) {
53
+ if (!tl?.getChildren) continue;
54
+ try {
55
+ for (const child of tl.getChildren(true)) {
56
+ if (!child.targets) continue;
57
+ for (const t of child.targets()) {
58
+ if (isHtmlElement(t)) seen.add(t);
59
+ }
60
+ }
61
+ } catch {
62
+ // cross-origin or detached timeline — skip
63
+ }
64
+ }
65
+ return Array.from(seen);
66
+ }
67
+
68
+ function indicatorsEqual(a: OffScreenIndicator[], b: OffScreenIndicator[]): boolean {
69
+ if (a.length !== b.length) return false;
70
+ for (let i = 0; i < a.length; i++) {
71
+ const ai = a[i]!;
72
+ const bi = b[i]!;
73
+ if (
74
+ ai.key !== bi.key ||
75
+ Math.abs(ai.left - bi.left) > 0.5 ||
76
+ Math.abs(ai.top - bi.top) > 0.5 ||
77
+ Math.abs(ai.width - bi.width) > 0.5 ||
78
+ Math.abs(ai.height - bi.height) > 0.5
79
+ )
80
+ return false;
81
+ }
82
+ return true;
83
+ }
84
+
85
+ export function useOffScreenIndicators({
86
+ iframeRef,
87
+ overlayRef,
88
+ compRect,
89
+ }: {
90
+ iframeRef: RefObject<HTMLIFrameElement | null>;
91
+ overlayRef: RefObject<HTMLDivElement | null>;
92
+ compRect: CompRect;
93
+ }): OffScreenIndicator[] {
94
+ const [indicators, setIndicators] = useState<OffScreenIndicator[]>([]);
95
+ const prevRef = useRef<OffScreenIndicator[]>([]);
96
+ const compRectRef = useRef(compRect);
97
+ compRectRef.current = compRect;
98
+
99
+ useMountEffect(() => {
100
+ let frame = 0;
101
+
102
+ const update = () => {
103
+ frame = requestAnimationFrame(update);
104
+
105
+ const iframe = iframeRef.current;
106
+ const overlayEl = overlayRef.current;
107
+ const cr = compRectRef.current;
108
+ if (!iframe || !overlayEl || cr.width <= 0 || cr.height <= 0) {
109
+ if (prevRef.current.length > 0) {
110
+ prevRef.current = [];
111
+ setIndicators([]);
112
+ }
113
+ return;
114
+ }
115
+
116
+ const iframeRect = iframe.getBoundingClientRect();
117
+ const overlayRect = overlayEl.getBoundingClientRect();
118
+
119
+ const doc = iframe.contentDocument;
120
+ const root =
121
+ doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement ?? null;
122
+ if (!root) return;
123
+
124
+ const declaredWidth =
125
+ Number.parseFloat(root.getAttribute("data-width") ?? "") || iframeRect.width;
126
+ const declaredHeight =
127
+ Number.parseFloat(root.getAttribute("data-height") ?? "") || iframeRect.height;
128
+ const rootScaleX = iframeRect.width / declaredWidth;
129
+ const rootScaleY = iframeRect.height / declaredHeight;
130
+
131
+ const targets = collectGsapTargetElements(iframe);
132
+ if (targets.length === 0) {
133
+ if (prevRef.current.length > 0) {
134
+ prevRef.current = [];
135
+ setIndicators([]);
136
+ }
137
+ return;
138
+ }
139
+
140
+ // Composition bounds in overlay coordinates
141
+ const compLeft = cr.left;
142
+ const compTop = cr.top;
143
+ const compRight = compLeft + cr.width;
144
+ const compBottom = compTop + cr.height;
145
+
146
+ const next: OffScreenIndicator[] = [];
147
+ const keyCounts = new Map<string, number>();
148
+
149
+ for (const el of targets) {
150
+ if (!el.isConnected) continue;
151
+
152
+ const elRect = el.getBoundingClientRect();
153
+ if (elRect.width <= 0 && elRect.height <= 0) continue;
154
+
155
+ // Element rect in overlay coordinates
156
+ const elLeft = iframeRect.left - overlayRect.left + elRect.left * rootScaleX;
157
+ const elTop = iframeRect.top - overlayRect.top + elRect.top * rootScaleY;
158
+ const elW = elRect.width * rootScaleX;
159
+ const elH = elRect.height * rootScaleY;
160
+
161
+ // Check if the element is fully inside the composition
162
+ if (
163
+ elLeft >= compLeft &&
164
+ elTop >= compTop &&
165
+ elLeft + elW <= compRight &&
166
+ elTop + elH <= compBottom
167
+ ) {
168
+ continue;
169
+ }
170
+
171
+ // Only elements with a real id attribute can be selected via getElementById
172
+ if (!el.id) continue;
173
+ const count = keyCounts.get(el.id) ?? 0;
174
+ keyCounts.set(el.id, count + 1);
175
+ const key = count > 0 ? `${el.id}:${count}` : el.id;
176
+ next.push({
177
+ key,
178
+ elementId: el.id,
179
+ left: elLeft,
180
+ top: elTop,
181
+ width: elW,
182
+ height: elH,
183
+ });
184
+ }
185
+
186
+ if (!indicatorsEqual(prevRef.current, next)) {
187
+ prevRef.current = next;
188
+ setIndicators(next);
189
+ }
190
+ };
191
+
192
+ frame = requestAnimationFrame(update);
193
+ return () => cancelAnimationFrame(frame);
194
+ });
195
+
196
+ return indicators;
197
+ }
@@ -10,7 +10,7 @@ import {
10
10
  import { useMountEffect } from "../../hooks/useMountEffect";
11
11
  import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
12
12
  import type { TimelineElement } from "../../player";
13
- import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
13
+ import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks";
14
14
  import { NLEPreview } from "./NLEPreview";
15
15
  import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
16
16
  import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
@@ -20,7 +20,7 @@ import {
20
20
  getTimelineToggleTitle,
21
21
  } from "../../utils/timelineDiscovery";
22
22
 
23
- interface NLELayoutProps {
23
+ interface NLELayoutProps extends TimelineEditCallbacks {
24
24
  projectId: string;
25
25
  portrait?: boolean;
26
26
  /** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */
@@ -59,23 +59,7 @@ interface NLELayoutProps {
59
59
  blockName: string,
60
60
  position: { left: number; top: number },
61
61
  ) => Promise<void> | void;
62
- /** Persist timeline move actions back into source HTML */
63
- onMoveElement?: (
64
- element: TimelineElement,
65
- updates: Pick<TimelineElement, "start" | "track">,
66
- ) => Promise<void> | void;
67
- onResizeElement?: (
68
- element: TimelineElement,
69
- updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
70
- ) => Promise<void> | void;
71
- onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
72
- onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
73
62
  onSelectTimelineElement?: (element: TimelineElement | null) => void;
74
- onDeleteKeyframe?: (elementId: string, percentage: number) => void;
75
- onDeleteAllKeyframes?: (elementId: string) => void;
76
- onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
77
- onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
78
- onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
79
63
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
80
64
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
81
65
  /** Whether the timeline panel is visible (default: true) */
@@ -124,6 +108,8 @@ export const NLELayout = memo(function NLELayout({
124
108
  onResizeElement,
125
109
  onBlockedEditAttempt,
126
110
  onSplitElement,
111
+ onRazorSplit,
112
+ onRazorSplitAll,
127
113
  onSelectTimelineElement,
128
114
  onDeleteKeyframe,
129
115
  onDeleteAllKeyframes,
@@ -380,25 +366,27 @@ export const NLELayout = memo(function NLELayout({
380
366
  {/* Preview + player controls */}
381
367
  <div className="flex-1 min-h-0 flex flex-col">
382
368
  <div
383
- className="flex-1 min-h-0 relative overflow-hidden"
369
+ className="flex-1 min-h-0 relative"
384
370
  data-preview-pan-surface="true"
385
371
  onDragOver={handlePreviewDragOver}
386
372
  onDragLeave={handlePreviewDragLeave}
387
373
  onDrop={handlePreviewDrop}
388
374
  >
389
- <NLEPreview
390
- projectId={projectId}
391
- iframeRef={iframeRef}
392
- onIframeLoad={onIframeLoad}
393
- onCompositionLoadingChange={setCompositionLoading}
394
- portrait={portrait}
395
- directUrl={directUrl}
396
- suppressLoadingOverlay={hasLoadedOnceRef.current}
397
- onStageRef={handleStageRef}
398
- />
399
- {previewDragOver && (
400
- <div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
401
- )}
375
+ <div className="absolute inset-0 overflow-hidden">
376
+ <NLEPreview
377
+ projectId={projectId}
378
+ iframeRef={iframeRef}
379
+ onIframeLoad={onIframeLoad}
380
+ onCompositionLoadingChange={setCompositionLoading}
381
+ portrait={portrait}
382
+ directUrl={directUrl}
383
+ suppressLoadingOverlay={hasLoadedOnceRef.current}
384
+ onStageRef={handleStageRef}
385
+ />
386
+ {previewDragOver && (
387
+ <div className="absolute inset-2 z-40 rounded-lg border-2 border-dashed border-studio-accent/50 bg-studio-accent/[0.04] pointer-events-none" />
388
+ )}
389
+ </div>
402
390
  {!isFullscreen && previewOverlay}
403
391
  </div>
404
392
  <div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
@@ -460,6 +448,8 @@ export const NLELayout = memo(function NLELayout({
460
448
  onResizeElement={onResizeElement}
461
449
  onBlockedEditAttempt={onBlockedEditAttempt}
462
450
  onSplitElement={onSplitElement}
451
+ onRazorSplit={onRazorSplit}
452
+ onRazorSplitAll={onRazorSplitAll}
463
453
  onSelectElement={onSelectTimelineElement}
464
454
  onDeleteKeyframe={onDeleteKeyframe}
465
455
  onDeleteAllKeyframes={onDeleteAllKeyframes}
@@ -1,4 +1,5 @@
1
1
  import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
2
+ import { PlayheadIndicator } from "../../player/components/PlayheadIndicator";
2
3
 
3
4
  interface TimelineEditorNoticeProps {
4
5
  onDismiss: () => void;
@@ -76,31 +77,7 @@ export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
76
77
  "hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
77
78
  }}
78
79
  >
79
- <div
80
- className="absolute top-0 bottom-0"
81
- style={{
82
- left: "50%",
83
- width: 2,
84
- marginLeft: -1,
85
- background: "var(--hf-accent, #3CE6AC)",
86
- boxShadow: "0 0 8px rgba(60,230,172,0.5)",
87
- }}
88
- />
89
- <div
90
- className="absolute"
91
- style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
92
- >
93
- <div
94
- style={{
95
- width: 0,
96
- height: 0,
97
- borderLeft: "6px solid transparent",
98
- borderRight: "6px solid transparent",
99
- borderTop: "8px solid var(--hf-accent, #3CE6AC)",
100
- filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
101
- }}
102
- />
103
- </div>
80
+ <PlayheadIndicator />
104
81
  </div>
105
82
 
106
83
  <div className="flex flex-col gap-1.5">
@@ -1,3 +1,4 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { createContext, useContext, useMemo, type ReactNode } from "react";
2
3
  import type { useDomEditSession } from "../hooks/useDomEditSession";
3
4
 
@@ -60,6 +61,7 @@ export function DomEditProvider({
60
61
  handleGsapUpdateProperty,
61
62
  handleGsapUpdateMeta,
62
63
  handleGsapDeleteAnimation,
64
+ handleGsapDeleteAllForElement,
63
65
  handleGsapAddAnimation,
64
66
  handleGsapAddProperty,
65
67
  handleGsapRemoveProperty,
@@ -133,6 +135,7 @@ export function DomEditProvider({
133
135
  handleGsapUpdateProperty,
134
136
  handleGsapUpdateMeta,
135
137
  handleGsapDeleteAnimation,
138
+ handleGsapDeleteAllForElement,
136
139
  handleGsapAddAnimation,
137
140
  handleGsapAddProperty,
138
141
  handleGsapRemoveProperty,
@@ -200,6 +203,7 @@ export function DomEditProvider({
200
203
  handleGsapUpdateProperty,
201
204
  handleGsapUpdateMeta,
202
205
  handleGsapDeleteAnimation,
206
+ handleGsapDeleteAllForElement,
203
207
  handleGsapAddAnimation,
204
208
  handleGsapAddProperty,
205
209
  handleGsapRemoveProperty,