From f11ac4dcaa32039606963648d0f2f5b320db570f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 7 Jun 2026 18:43:59 -0400 Subject: [PATCH] feat(studio): razor/blade tool for GSAP-aware timeline clip splitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a razor/blade tool to Studio's timeline — the standard NLE workflow for splitting clips at arbitrary positions. - B enters razor mode (crosshair cursor + red vertical guide line) - Click any clip to split it at the click position - Shift+click splits all clips across every track at that time - V or Escape exits razor mode GSAP animations are correctly re-timed for both halves: animations before the split stay on the original, animations after are retargeted, and spanning animations are trimmed with a continuation on the new element. Keyframes animations are classified by total per-keyframe duration and retargeted when entirely after the split point. Extracted shared utilities (canSplitElement, PlayheadIndicator, useContextMenuDismiss, TimelineCallbacks) to reduce duplication across timeline components. --- lefthook.yml | 6 +- .../src/parsers/gsapParser.stress.test.ts | 135 ++++---- .../src/parsers/gsapParser.test-helpers.ts | 130 ++++++++ packages/core/src/parsers/gsapParser.test.ts | 289 ++++++++++++------ packages/core/src/parsers/gsapParser.ts | 220 ++++++++++--- .../src/studio-api/helpers/sourceMutation.ts | 5 + packages/core/src/studio-api/routes/files.ts | 47 +-- packages/studio/src/App.tsx | 2 + .../src/components/StudioPreviewArea.tsx | 6 + .../studio/src/components/TimelineToolbar.tsx | 93 ++++-- .../studio/src/components/nle/NLELayout.tsx | 24 +- .../components/nle/TimelineEditorNotice.tsx | 27 +- packages/studio/src/hooks/useAppHotkeys.ts | 47 ++- packages/studio/src/hooks/useClipboard.ts | 11 +- .../studio/src/hooks/useContextMenuDismiss.ts | 29 ++ packages/studio/src/hooks/useRazorSplit.ts | 183 +++++++++++ .../studio/src/hooks/useTimelineEditing.ts | 132 +------- .../src/player/components/ClipContextMenu.tsx | 26 +- .../components/KeyframeDiamondContextMenu.tsx | 23 +- .../player/components/PlayheadIndicator.tsx | 42 +++ .../studio/src/player/components/Timeline.tsx | 73 ++--- .../src/player/components/TimelineCanvas.tsx | 55 ++-- .../player/components/timelineCallbacks.ts | 42 +++ .../src/player/components/timelineDragDrop.ts | 16 +- .../src/player/components/useTimelineZoom.ts | 17 ++ .../studio/src/player/store/playerStore.ts | 8 + .../studio/src/utils/timelineElementSplit.ts | 39 +++ 27 files changed, 1181 insertions(+), 546 deletions(-) create mode 100644 packages/core/src/parsers/gsapParser.test-helpers.ts create mode 100644 packages/studio/src/hooks/useContextMenuDismiss.ts create mode 100644 packages/studio/src/hooks/useRazorSplit.ts create mode 100644 packages/studio/src/player/components/PlayheadIndicator.tsx create mode 100644 packages/studio/src/player/components/timelineCallbacks.ts create mode 100644 packages/studio/src/player/components/useTimelineZoom.ts create mode 100644 packages/studio/src/utils/timelineElementSplit.ts diff --git a/lefthook.yml b/lefthook.yml index e6d83e199..c01679928 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -19,7 +19,11 @@ pre-commit: # fails on issues introduced by the branch, not inherited findings. fallow: glob: "packages/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}" - run: bunx fallow audit --base origin/main --fail-on-issues + run: > + bunx fallow audit --base origin/main --fail-on-issues + --health-baseline .fallow/health-baseline.json + --dupes-baseline .fallow/dupes-baseline.json + --dead-code-baseline .fallow/dead-code-baseline.json filesize: # Scoped to packages/studio — the 600 LOC limit is a studio architecture # standard enforced as part of the App.tsx decomposition work. Player and diff --git a/packages/core/src/parsers/gsapParser.stress.test.ts b/packages/core/src/parsers/gsapParser.stress.test.ts index 191521df6..95c1ef047 100644 --- a/packages/core/src/parsers/gsapParser.stress.test.ts +++ b/packages/core/src/parsers/gsapParser.stress.test.ts @@ -7,6 +7,13 @@ import { removeAnimationFromScript, } from "./gsapParser.js"; import type { ParsedGsap } from "./gsapParser.js"; +import { + parseAndSerialize, + parseSingleAnimation, + expectStaggerRaw, + expectRawWithResolvable, + expectSingleAnimPosition, +} from "./gsapParser.test-helpers.js"; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -232,16 +239,15 @@ describe("3. Extreme values", () => { }); it("Infinity literal", () => { - const script = ` + expectRawWithResolvable( + ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: Infinity, y: 50, duration: 1 }, 0); - `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - // Infinity is an Identifier, not a NumericLiteral — should be __raw - const xVal = result.animations[0].properties.x; - expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true); - expect(result.animations[0].properties.y).toBe(50); + `, + "x", + "y", + 50, + ); }); }); @@ -298,16 +304,8 @@ describe("5. Deeply nested objects", () => { const tl = gsap.timeline({ paused: true }); tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0); `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - expect(result.animations[0].extras).toBeDefined(); - expect(result.animations[0].extras!.stagger).toBeDefined(); - // stagger should be __raw: containing the nested object source - const stagger = String(result.animations[0].extras!.stagger); - expect(stagger.startsWith("__raw:")).toBe(true); - expect(stagger).toContain("amount"); - expect(stagger).toContain("grid"); - expect(stagger).toContain("center"); + const anim = parseSingleAnimation(script); + expectStaggerRaw(anim, "amount", "grid", "center"); }); it("complex stagger survives round-trip serialization", () => { @@ -315,11 +313,7 @@ describe("5. Deeply nested objects", () => { const tl = gsap.timeline({ paused: true }); tl.to(".items", { opacity: 1, duration: 0.5, stagger: { amount: 1, grid: [3, 3], from: "center", axis: "x" } }, 0); `; - const parsed = parseGsapScript(script); - const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { - preamble: parsed.preamble, - postamble: parsed.postamble, - }); + const { serialized } = parseAndSerialize(script); expect(serialized).toContain("stagger:"); expect(serialized).toContain("amount"); expect(serialized).toContain("grid"); @@ -380,17 +374,16 @@ describe("7. Template literals in values", () => { }); it("template literal with expression becomes __raw", () => { - const script = ` + expectRawWithResolvable( + ` const val = 100; const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: \`\${val}px\`, y: 50, duration: 1 }, 0); - `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - // Template literal with expressions is not resolvable - const xVal = result.animations[0].properties.x; - expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true); - expect(result.animations[0].properties.y).toBe(50); + `, + "x", + "y", + 50, + ); }); }); @@ -472,17 +465,15 @@ describe("9. Comments everywhere", () => { describe("10. Arrow functions as values", () => { it("arrow function property becomes __raw", () => { - const script = ` + expectRawWithResolvable( + ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0); - `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - // Arrow function is not resolvable - const xVal = result.animations[0].properties.x; - expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true); - // Resolvable values still work - expect(result.animations[0].properties.opacity).toBe(1); + `, + "x", + "opacity", + 1, + ); }); it("arrow function in stagger becomes __raw extra", () => { @@ -490,10 +481,8 @@ describe("10. Arrow functions as values", () => { const tl = gsap.timeline({ paused: true }); tl.to(".items", { opacity: 1, duration: 0.5, stagger: (i) => i * 0.1 }, 0); `; - const result = parseGsapScript(script); - expect(result.animations[0].extras).toBeDefined(); - const stagger = String(result.animations[0].extras!.stagger); - expect(stagger.startsWith("__raw:")).toBe(true); + const anim = parseSingleAnimation(script); + expectStaggerRaw(anim); }); it("arrow function round-trips via serialization", () => { @@ -501,11 +490,7 @@ describe("10. Arrow functions as values", () => { const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: (i) => i * 50, opacity: 1, duration: 1 }, 0); `; - const parsed = parseGsapScript(script); - const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { - preamble: parsed.preamble, - postamble: parsed.postamble, - }); + const { serialized } = parseAndSerialize(script); // The raw arrow function should be emitted without quotes expect(serialized).toContain("(i) => i * 50"); expect(serialized).not.toContain('"(i) => i * 50"'); @@ -534,28 +519,26 @@ describe("11. Spread operator", () => { describe("12. Conditional expressions", () => { it("ternary expression becomes __raw", () => { - const script = ` + expectRawWithResolvable( + ` const condition = true; const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: condition ? 100 : 200, y: 50, duration: 1 }, 0); - `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - // ConditionalExpression is not handled by resolveNode - const xVal = result.animations[0].properties.x; - expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true); - expect(result.animations[0].properties.y).toBe(50); + `, + "x", + "y", + 50, + ); }); it("conditional in position argument defaults to 0", () => { - const script = ` + expectSingleAnimPosition( + ` const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: 100, duration: 1 }, someCondition ? 0 : 2); - `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - // Position can't be resolved — falls back to 0 - expect(result.animations[0].position).toBe(0); + `, + 0, + ); }); }); @@ -930,17 +913,16 @@ describe("Additional edge cases", () => { }); it("scope resolution: binary expression with one unresolvable side", () => { - const script = ` + expectRawWithResolvable( + ` const BASE = 100; const tl = gsap.timeline({ paused: true }); tl.to("#el", { x: BASE + unknownVar, y: BASE * 2, duration: 1 }, 0); - `; - const result = parseGsapScript(script); - // BASE + unknownVar: left is 100, right is undefined => result is undefined => __raw - const xVal = result.animations[0].properties.x; - expect(typeof xVal === "string" && xVal.startsWith("__raw:")).toBe(true); - // BASE * 2: both resolved => 200 - expect(result.animations[0].properties.y).toBe(200); + `, + "x", + "y", + 200, + ); }); it("negative position in ID generation", () => { @@ -954,13 +936,12 @@ describe("Additional edge cases", () => { }); it("fromTo with no position arg defaults to 0", () => { - const script = ` + expectSingleAnimPosition( + ` const tl = gsap.timeline({ paused: true }); tl.fromTo("#el", { opacity: 0 }, { opacity: 1, duration: 1 }); - `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - // For fromTo, position is args[3] which is undefined => defaults to 0 - expect(result.animations[0].position).toBe(0); + `, + 0, + ); }); }); diff --git a/packages/core/src/parsers/gsapParser.test-helpers.ts b/packages/core/src/parsers/gsapParser.test-helpers.ts new file mode 100644 index 000000000..1f44ea972 --- /dev/null +++ b/packages/core/src/parsers/gsapParser.test-helpers.ts @@ -0,0 +1,130 @@ +import { expect } from "vitest"; +import { + parseGsapScript, + serializeGsapAnimations, + convertToKeyframesInScript, +} from "./gsapParser.js"; +import type { GsapAnimation, GsapPercentageKeyframe } from "./gsapParser.js"; + +/** + * Parse a script and serialize the result, returning both the parsed output + * and the serialized string for assertion. Shared across gsapParser.test.ts + * and gsapParser.stress.test.ts. + */ +export function parseAndSerialize(script: string) { + const parsed = parseGsapScript(script); + const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { + preamble: parsed.preamble, + postamble: parsed.postamble, + }); + return { parsed, serialized }; +} + +/** + * Parse a script expecting exactly one animation, and return it directly. + */ +export function parseSingleAnimation(script: string): GsapAnimation { + const result = parseGsapScript(script); + expect(result.animations).toHaveLength(1); + return result.animations[0]!; +} + +/** + * Assert that a parsed animation's stagger extra exists and contains + * the expected substrings (as a __raw: prefixed string). + */ +export function expectStaggerRaw(anim: GsapAnimation, ...expectedSubstrings: string[]): void { + expect(anim.extras).toBeDefined(); + expect(anim.extras!.stagger).toBeDefined(); + const stagger = String(anim.extras!.stagger); + expect(stagger.startsWith("__raw:")).toBe(true); + for (const sub of expectedSubstrings) { + expect(stagger).toContain(sub); + } +} + +/** + * Assert a single keyframe's percentage, properties, and optional ease. + */ +export function expectKeyframe( + kf: GsapPercentageKeyframe, + percentage: number, + properties: Record, + ease?: string, +): void { + expect(kf.percentage).toBe(percentage); + for (const [key, value] of Object.entries(properties)) { + expect(kf.properties[key]).toBe(value); + } + if (ease !== undefined) { + expect(kf.ease).toBe(ease); + } +} + +/** + * Assert that an animation has a defined keyframes block with the expected format + * and count, and return the keyframes array for further assertions. + */ +export function expectKeyframesFormat( + anim: GsapAnimation, + format: string, + count: number, +): GsapPercentageKeyframe[] { + expect(anim.keyframes).toBeDefined(); + expect(anim.keyframes!.format).toBe(format); + expect(anim.keyframes!.keyframes).toHaveLength(count); + return anim.keyframes!.keyframes; +} + +/** + * Parse a script expecting one animation, assert that `rawProp` is a __raw: string + * and `resolvableProp` has the expected value. + */ +export function expectRawWithResolvable( + script: string, + rawProp: string, + resolvableProp: string, + resolvableValue: number | string, +): void { + const anim = parseSingleAnimation(script); + const val = anim.properties[rawProp]; + expect(typeof val === "string" && val.startsWith("__raw:")).toBe(true); + expect(anim.properties[resolvableProp]).toBe(resolvableValue); +} + +/** + * Parse a script expecting one animation, assert that `position` matches the expected value. + */ +export function expectSingleAnimPosition(script: string, position: number): void { + const anim = parseSingleAnimation(script); + expect(anim.position).toBe(position); +} + +/** + * Parse a script, get the first animation id, run convertToKeyframesInScript, + * reparse, and return the first animation for assertion. + */ +export function convertAndReparse( + script: string, + runtimeValues?: Record, +): GsapAnimation { + const id = parseSingleAnimation(script).id; + const updated = convertToKeyframesInScript(script, id, runtimeValues); + return parseSingleAnimation(updated); +} + +/** + * Parse a script, return the first animation and run a split-related reparse. + * Asserts the reparse result has exactly `expectedCount` animations and returns + * the selector of the first animation. + */ +export function parseSplitAndAssert( + script: string, + splitFn: (s: string) => string, + expectedCount: number, +): string[] { + const result = splitFn(script); + const parsed = parseGsapScript(result); + expect(parsed.animations).toHaveLength(expectedCount); + return parsed.animations.map((a) => a.targetSelector); +} diff --git a/packages/core/src/parsers/gsapParser.test.ts b/packages/core/src/parsers/gsapParser.test.ts index d4e0e7cd2..004449fc5 100644 --- a/packages/core/src/parsers/gsapParser.test.ts +++ b/packages/core/src/parsers/gsapParser.test.ts @@ -16,9 +16,18 @@ import { updateKeyframeInScript, convertToKeyframesInScript, removeAllKeyframesFromScript, + splitAnimationsInScript, } from "./gsapParser.js"; import type { GsapAnimation } from "./gsapParser.js"; import type { Keyframe } from "../core.types"; +import { + parseAndSerialize, + parseSingleAnimation, + expectKeyframe, + expectKeyframesFormat, + convertAndReparse, + parseSplitAndAssert, +} from "./gsapParser.test-helpers.js"; describe("parseGsapScript", () => { it("parses a basic timeline with .to()", () => { @@ -298,11 +307,7 @@ describe("stagger/yoyo/repeat round-trip", () => { const tl = gsap.timeline({ paused: true }); tl.to(".items", { opacity: 1, duration: 0.5, stagger: { each: 0.15, from: "start" } }, 0); `; - const parsed = parseGsapScript(script); - const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { - preamble: parsed.preamble, - postamble: parsed.postamble, - }); + const { serialized } = parseAndSerialize(script); expect(serialized).toContain("stagger: {"); expect(serialized).toContain("each: 0.15"); @@ -314,11 +319,7 @@ describe("stagger/yoyo/repeat round-trip", () => { const tl = gsap.timeline({ paused: true }); tl.to("#el1", { x: 100, duration: 1, yoyo: true, repeat: 3, repeatDelay: 0.2 }, 0); `; - const parsed = parseGsapScript(script); - const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { - preamble: parsed.preamble, - postamble: parsed.postamble, - }); + const { serialized } = parseAndSerialize(script); expect(serialized).toContain("yoyo: true"); expect(serialized).toContain("repeat: 3"); @@ -348,11 +349,7 @@ describe("unresolvable value round-trip", () => { const tl = gsap.timeline({ paused: true }); tl.to("#el1", { opacity: someFn(), x: 50, duration: 1 }, 0); `; - const parsed = parseGsapScript(script); - const serialized = serializeGsapAnimations(parsed.animations, parsed.timelineVar, { - preamble: parsed.preamble, - postamble: parsed.postamble, - }); + const { serialized } = parseAndSerialize(script); // The raw expression should survive — emitted without quotes expect(serialized).toContain("opacity: someFn()"); @@ -1100,9 +1097,8 @@ describe("gsap.utils.toArray targets", () => { const tl = gsap.timeline({ paused: true }); tl.to(gsap.utils.toArray(".item"), { opacity: 1, duration: 0.5, stagger: 0.1 }, 0); `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - expect(result.animations[0].targetSelector).toBe(".item"); + const anim = parseSingleAnimation(script); + expect(anim.targetSelector).toBe(".item"); }); it("resolves a toArray result stored in a variable", () => { @@ -1111,8 +1107,8 @@ describe("gsap.utils.toArray targets", () => { const tl = gsap.timeline({ paused: true }); tl.to(items, { opacity: 1, duration: 0.5 }, 0); `; - const result = parseGsapScript(script); - expect(result.animations[0].targetSelector).toBe(".item"); + const anim = parseSingleAnimation(script); + expect(anim.targetSelector).toBe(".item"); }); }); @@ -1146,9 +1142,8 @@ describe("forEach / map callback targets", () => { tl.to(el, { opacity: 1, duration: 0.4 }, 0); }); `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - expect(result.animations[0].targetSelector).toBe(".item"); + const anim = parseSingleAnimation(script); + expect(anim.targetSelector).toBe(".item"); }); it("resolves an inline querySelectorAll().forEach callback param", () => { @@ -1158,8 +1153,8 @@ describe("forEach / map callback targets", () => { tl.to(dot, { scale: 1, duration: 0.3 }, 0); }); `; - const result = parseGsapScript(script); - expect(result.animations[0].targetSelector).toBe(".dot"); + const anim = parseSingleAnimation(script); + expect(anim.targetSelector).toBe(".dot"); }); }); @@ -1204,23 +1199,12 @@ describe("native GSAP keyframes parsing", () => { duration: 5 }, 0); `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - const anim = result.animations[0]; - expect(anim.keyframes).toBeDefined(); - expect(anim.keyframes!.format).toBe("percentage"); - expect(anim.keyframes!.keyframes).toHaveLength(3); - - expect(anim.keyframes!.keyframes[0].percentage).toBe(0); - expect(anim.keyframes!.keyframes[0].properties.x).toBe(0); - expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1); - - expect(anim.keyframes!.keyframes[1].percentage).toBe(50); - expect(anim.keyframes!.keyframes[1].properties.x).toBe(100); - expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out"); + const anim = parseSingleAnimation(script); + const kfs = expectKeyframesFormat(anim, "percentage", 3); - expect(anim.keyframes!.keyframes[2].percentage).toBe(100); - expect(anim.keyframes!.keyframes[2].properties.x).toBe(200); + expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 }); + expectKeyframe(kfs[1], 50, { x: 100 }, "power2.out"); + expectKeyframe(kfs[2], 100, { x: 200 }); }); it("parses object array keyframes format", () => { @@ -1234,26 +1218,15 @@ describe("native GSAP keyframes parsing", () => { ] }, 0); `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - const anim = result.animations[0]; - expect(anim.keyframes).toBeDefined(); - expect(anim.keyframes!.format).toBe("object-array"); - expect(anim.keyframes!.keyframes).toHaveLength(3); + const anim = parseSingleAnimation(script); + const kfs = expectKeyframesFormat(anim, "object-array", 3); // Total duration = 0.5 + 1 + 0.8 = 2.3 - expect(anim.keyframes!.keyframes[0].percentage).toBe(0); - expect(anim.keyframes!.keyframes[0].properties.x).toBe(0); - expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(1); - + expectKeyframe(kfs[0], 0, { x: 0, opacity: 1 }); // Second: cumulative = 0.5, pct = round(0.5/2.3 * 100) = 22 - expect(anim.keyframes!.keyframes[1].percentage).toBe(22); - expect(anim.keyframes!.keyframes[1].properties.x).toBe(100); - expect(anim.keyframes!.keyframes[1].ease).toBe("power2.out"); - + expectKeyframe(kfs[1], 22, { x: 100 }, "power2.out"); // Third: cumulative = 1.5, pct = round(1.5/2.3 * 100) = 65 - expect(anim.keyframes!.keyframes[2].percentage).toBe(65); - expect(anim.keyframes!.keyframes[2].properties.x).toBe(200); + expectKeyframe(kfs[2], 65, { x: 200 }); }); it("parses simple array keyframes format", () => { @@ -1264,30 +1237,17 @@ describe("native GSAP keyframes parsing", () => { duration: 5 }, 0); `; - const result = parseGsapScript(script); - expect(result.animations).toHaveLength(1); - const anim = result.animations[0]; + const anim = parseSingleAnimation(script); expect(anim.keyframes).toBeDefined(); expect(anim.keyframes!.format).toBe("simple-array"); expect(anim.keyframes!.easeEach).toBe("power2.inOut"); expect(anim.keyframes!.keyframes).toHaveLength(4); // Evenly spaced: 0%, 33%, 67%, 100% - expect(anim.keyframes!.keyframes[0].percentage).toBe(0); - expect(anim.keyframes!.keyframes[0].properties.x).toBe(0); - expect(anim.keyframes!.keyframes[0].properties.opacity).toBe(0); - - expect(anim.keyframes!.keyframes[1].percentage).toBe(33); - expect(anim.keyframes!.keyframes[1].properties.x).toBe(100); - expect(anim.keyframes!.keyframes[1].properties.opacity).toBe(1); - - expect(anim.keyframes!.keyframes[2].percentage).toBe(67); - expect(anim.keyframes!.keyframes[2].properties.x).toBe(200); - expect(anim.keyframes!.keyframes[2].properties.opacity).toBe(1); - - expect(anim.keyframes!.keyframes[3].percentage).toBe(100); - expect(anim.keyframes!.keyframes[3].properties.x).toBe(0); - expect(anim.keyframes!.keyframes[3].properties.opacity).toBe(0); + expectKeyframe(anim.keyframes!.keyframes[0], 0, { x: 0, opacity: 0 }); + expectKeyframe(anim.keyframes!.keyframes[1], 33, { x: 100, opacity: 1 }); + expectKeyframe(anim.keyframes!.keyframes[2], 67, { x: 200, opacity: 1 }); + expectKeyframe(anim.keyframes!.keyframes[3], 100, { x: 0, opacity: 0 }); }); it("parses three-level easing", () => { @@ -1449,18 +1409,11 @@ describe("keyframe mutations", () => { const tl = gsap.timeline({ paused: true }); tl.from("#title", { x: -200, opacity: 0, duration: 0.8 }, 0.3); `; - const id = getAnimId(script); - const updated = convertToKeyframesInScript(script, id, { x: 0, opacity: 1 }); - const reparsed = parseGsapScript(updated); - const anim = reparsed.animations[0]; - + const anim = convertAndReparse(script, { x: 0, opacity: 1 }); expect(anim.method).toBe("to"); - expect(anim.keyframes).toBeDefined(); - const kfs = anim.keyframes!.keyframes; - expect(kfs[0].properties.x).toBe(-200); - expect(kfs[0].properties.opacity).toBe(0); - expect(kfs[1].properties.x).toBe(0); - expect(kfs[1].properties.opacity).toBe(1); + const kfs = expectKeyframesFormat(anim, "percentage", 2); + expectKeyframe(kfs[0]!, 0, { x: -200, opacity: 0 }); + expectKeyframe(kfs[1]!, 100, { x: 0, opacity: 1 }); }); it("convertToKeyframesInScript — converts fromTo() to to() + keyframes", () => { @@ -1468,16 +1421,11 @@ describe("keyframe mutations", () => { const tl = gsap.timeline({ paused: true }); tl.fromTo("#title", { x: -100 }, { x: 100, duration: 1 }, 0); `; - const id = getAnimId(script); - const updated = convertToKeyframesInScript(script, id); - const reparsed = parseGsapScript(updated); - const anim = reparsed.animations[0]; - + const anim = convertAndReparse(script); expect(anim.method).toBe("to"); - expect(anim.keyframes).toBeDefined(); - const kfs = anim.keyframes!.keyframes; - expect(kfs[0].properties.x).toBe(-100); - expect(kfs[1].properties.x).toBe(100); + const kfs = expectKeyframesFormat(anim, "percentage", 2); + expect(kfs[0]!.properties.x).toBe(-100); + expect(kfs[1]!.properties.x).toBe(100); }); it("convertToKeyframesInScript — skips if already has keyframes", () => { @@ -1504,3 +1452,154 @@ describe("keyframe mutations", () => { expect(anim.properties.opacity).toBe(1); }); }); + +describe("splitAnimationsInScript", () => { + const baseScript = `const tl = gsap.timeline({ paused: true });`; + const opts = { + originalId: "el1", + newId: "el1-split", + splitTime: 2, + elementStart: 0, + elementDuration: 4, + }; + + it("keeps animation entirely in first half and adds set for inherited state", () => { + const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 0);`; + const result = splitAnimationsInScript(script, opts); + const parsed = parseGsapScript(result); + const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1"); + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + expect(forOriginal).toHaveLength(1); + expect(forNew).toHaveLength(1); + expect(forNew[0]!.method).toBe("to"); + expect(forNew[0]!.duration).toBeCloseTo(0.001); + expect(forNew[0]!.properties.x).toBe(100); + expect(forNew[0]!.position).toBe(opts.splitTime); + }); + + it("retargets animation entirely in second half to new element", () => { + const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 3);`; + const selectors = parseSplitAndAssert(script, (s) => splitAnimationsInScript(s, opts), 1); + expect(selectors[0]).toBe("#el1-split"); + }); + + it("duplicates animation spanning the split point", () => { + const script = `${baseScript}\ntl.to("#el1", { opacity: 0, duration: 4 }, 0);`; + const result = splitAnimationsInScript(script, opts); + const parsed = parseGsapScript(result); + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + const first = parsed.animations.find((a) => a.targetSelector === "#el1"); + const continuation = forNew.find((a) => a.duration !== undefined && a.duration > 0.01); + const inherited = forNew.find((a) => a.duration !== undefined && a.duration < 0.01); + expect(first).toBeDefined(); + expect(continuation).toBeDefined(); + expect(first!.duration).toBe(2); + expect(continuation!.duration).toBe(2); + expect(continuation!.position).toBe(opts.splitTime); + expect(inherited).toBeDefined(); + expect(inherited!.properties.opacity).toBe(0); + }); + + it("retargets multiple animations at the same position both after split", () => { + const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 1 }, 3);\ntl.to("#el1", { y: 200, duration: 1 }, 3);`; + const result = splitAnimationsInScript(script, opts); + const parsed = parseGsapScript(result); + const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1"); + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + expect(forOriginal.length).toBe(0); + expect(forNew.length).toBe(2); + }); + + it("returns script unchanged when no matching animations", () => { + const script = `${baseScript}\ntl.to("#other", { x: 100, duration: 1 }, 0);`; + const result = splitAnimationsInScript(script, opts); + expect(result).toBe(script); + }); + + it("handles multiple animations independently", () => { + const script = `${baseScript} +tl.to("#el1", { x: 100, duration: 1 }, 0); +tl.to("#el1", { y: 200, duration: 1 }, 3);`; + const result = splitAnimationsInScript(script, opts); + const parsed = parseGsapScript(result); + const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1"); + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + expect(forOriginal).toHaveLength(1); + expect(forOriginal[0]!.properties.x).toBe(100); + expect(forNew).toHaveLength(2); + const retargeted = forNew.find((a) => a.method === "to"); + const inherited = forNew.find((a) => a.duration !== undefined && a.duration < 0.01); + expect(retargeted!.properties.y).toBe(200); + expect(inherited!.properties.x).toBe(100); + }); + + it("preserves fromTo properties on both halves", () => { + const script = `${baseScript}\ntl.fromTo("#el1", { opacity: 0 }, { opacity: 1, duration: 4 }, 0);`; + const result = splitAnimationsInScript(script, opts); + const parsed = parseGsapScript(result); + const forNew = parsed.animations.find((a) => a.targetSelector === "#el1-split"); + expect(forNew).toBeDefined(); + expect(forNew!.method).toBe("fromTo"); + }); + + it("round-trips correctly through parseGsapScript", () => { + const script = `${baseScript}\ntl.to("#el1", { x: 100, duration: 4 }, 0);`; + const result = splitAnimationsInScript(script, opts); + const parsed = parseGsapScript(result); + expect(parsed.animations.length).toBeGreaterThanOrEqual(2); + for (const anim of parsed.animations) { + expect(typeof anim.position).toBe("number"); + if (anim.method !== "set") expect(anim.duration).toBeGreaterThan(0); + } + }); + + it("keeps keyframes animation spanning split on original element", () => { + const script = `${baseScript}\ntl.to("#el1", { keyframes: [{ opacity: 1, duration: 1 }, { scale: 1.2, duration: 1 }, { x: 50, duration: 1 }] }, 1);`; + const splitOpts = { + originalId: "el1", + newId: "el1-split", + splitTime: 2.5, + elementStart: 0, + elementDuration: 5, + }; + const result = splitAnimationsInScript(script, splitOpts); + const parsed = parseGsapScript(result); + const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1"); + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + expect(forOriginal.length).toBe(1); + expect(forNew.length).toBe(0); + }); + + it("retargets keyframes animation entirely after split", () => { + const script = `${baseScript}\ntl.to("#el1", { keyframes: [{ opacity: 1, duration: 0.5 }, { scale: 1.2, duration: 0.5 }] }, 4);`; + const splitOpts = { + originalId: "el1", + newId: "el1-split", + splitTime: 3, + elementStart: 0, + elementDuration: 5, + }; + const result = splitAnimationsInScript(script, splitOpts); + const parsed = parseGsapScript(result); + const forOriginal = parsed.animations.filter((a) => a.targetSelector === "#el1"); + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + expect(forOriginal.length).toBe(0); + expect(forNew.length).toBe(1); + expect(forNew[0]!.keyframes).toBeDefined(); + }); + + it("keeps keyframes animation entirely before split", () => { + const script = `${baseScript}\ntl.to("#el1", { keyframes: [{ opacity: 1, duration: 0.5 }, { scale: 1.2, duration: 0.5 }] }, 0);`; + const splitOpts = { + originalId: "el1", + newId: "el1-split", + splitTime: 3, + elementStart: 0, + elementDuration: 5, + }; + const result = splitAnimationsInScript(script, splitOpts); + const parsed = parseGsapScript(result); + const forNew = parsed.animations.filter((a) => a.targetSelector === "#el1-split"); + expect(forNew.length).toBe(0); + }); +}); diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 478fbdbba..f872eca3e 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -576,6 +576,26 @@ function parsePercentageKeyframes(node: any, scope: ScopeBindings): GsapKeyframe }; } +function computeKeyframesTotalDuration(varsNode: any, scope: ScopeBindings): number | undefined { + const record = objectExpressionToRecord(varsNode, scope); + const kfVal = record.keyframes; + if (typeof kfVal !== "object" || kfVal === null) return undefined; + const kfNode = (varsNode.properties ?? []).find( + (p: any) => (p.key?.name ?? p.key?.value) === "keyframes", + )?.value; + if (!kfNode) return undefined; + if (kfNode.type === "ArrayExpression") { + let total = 0; + for (const el of kfNode.elements ?? []) { + if (!el || el.type !== "ObjectExpression") continue; + const r = objectExpressionToRecord(el, scope); + if (typeof r.duration === "number") total += r.duration; + } + return total > 0 ? total : undefined; + } + return undefined; +} + // fallow-ignore-next-line complexity function parseObjectArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData { const elements = node.elements ?? []; @@ -748,9 +768,13 @@ function tweenCallToAnimation( const posVal = call.positionArg ? extractLiteralValue(call.positionArg, scope) : 0; const position: number | string = typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0; - const duration = typeof vars.duration === "number" ? vars.duration : undefined; + let duration = typeof vars.duration === "number" ? vars.duration : undefined; const ease = typeof vars.ease === "string" ? vars.ease : undefined; + if (duration === undefined && keyframesData) { + duration = computeKeyframesTotalDuration(call.varsArg, scope); + } + const anim: Omit = { targetSelector: call.selector, method: call.method, @@ -912,23 +936,30 @@ function setVarsKey(varsArg: any, key: string, value: number | string): void { } /** - * Replace the editable-property keys on an ObjectExpression with `newProps`, - * leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys - * untouched. + * Filter an ObjectExpression's properties, keeping non-editable keys + * and delegating the keep/drop decision for editable keys to `shouldKeep`. */ -function reconcileEditableProperties( - varsArg: any, - newProps: Record, -): void { +function filterEditableKeys(varsArg: any, shouldKeep: (key: string) => boolean): void { if (varsArg?.type !== "ObjectExpression") return; - // Drop editable props no longer present. varsArg.properties = varsArg.properties.filter((p: any) => { if (!isObjectProperty(p)) return true; const key = propKeyName(p); if (typeof key !== "string") return true; if (!isEditablePropertyKey(key)) return true; - return key in newProps; + return shouldKeep(key); }); +} + +/** + * Replace the editable-property keys on an ObjectExpression with `newProps`, + * leaving `duration`, `ease`, `stagger`, callbacks and other non-editable keys + * untouched. + */ +function reconcileEditableProperties( + varsArg: any, + newProps: Record, +): void { + filterEditableKeys(varsArg, (key) => key in newProps); // Upsert each new prop, preserving the order keys first appeared. for (const [key, value] of Object.entries(newProps)) { setVarsKey(varsArg, key, value); @@ -1003,6 +1034,22 @@ export function updateAnimationInScript( return recast.print(parsed.ast).code; } +function updateAnimationSelector(script: string, animationId: string, newSelector: string): string { + let parsed: ParsedGsapAst; + try { + parsed = parseGsapAst(script); + } catch { + return script; + } + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + const selectorArg = target.call.path.node.arguments?.[0]; + if (selectorArg?.type === "StringLiteral") { + selectorArg.value = newSelector; + } + return recast.print(parsed.ast).code; +} + export function addAnimationToScript( script: string, animation: Omit, @@ -1098,8 +1145,103 @@ export function removeAnimationFromScript(script: string, animationId: string): return recast.print(parsed.ast).code; } +// ── Split Animation Functions ───────────────────────────────────────────── + +export interface SplitAnimationsOptions { + originalId: string; + newId: string; + splitTime: number; + elementStart: number; + elementDuration: number; +} + +export function splitAnimationsInScript(script: string, opts: SplitAnimationsOptions): string { + const parsed = parseGsapScript(script); + const originalSelector = `#${opts.originalId}`; + const newSelector = `#${opts.newId}`; + const matching = parsed.animations.filter((a) => a.targetSelector === originalSelector); + if (matching.length === 0) return script; + + let result = script; + const newElementStart = opts.splitTime; + const inheritedProps: Record = {}; + + for (let i = matching.length - 1; i >= 0; i--) { + const anim = matching[i]!; + const pos = typeof anim.position === "number" ? anim.position : 0; + const dur = anim.duration ?? 0; + const animEnd = pos + dur; + + if (animEnd <= opts.splitTime) { + for (const [k, v] of Object.entries(anim.properties)) { + inheritedProps[k] = v; + } + continue; + } + + if (anim.keyframes) { + if (pos >= opts.splitTime) { + result = updateAnimationSelector(result, anim.id, newSelector); + } + continue; + } + + if (pos >= opts.splitTime) { + result = updateAnimationSelector(result, anim.id, newSelector); + continue; + } + + // Spans the split — the end-state properties are inherited by the second half + for (const [k, v] of Object.entries(anim.properties)) { + inheritedProps[k] = v; + } + + const firstHalfDuration = opts.splitTime - pos; + result = updateAnimationInScript(result, anim.id, { + duration: firstHalfDuration, + }); + + const secondHalfDuration = animEnd - opts.splitTime; + const addResult = addAnimationToScript(result, { + targetSelector: newSelector, + method: anim.method, + position: newElementStart, + duration: secondHalfDuration, + properties: { ...anim.properties }, + fromProperties: anim.fromProperties ? { ...anim.fromProperties } : undefined, + ease: anim.ease, + extras: anim.extras, + }); + result = addResult.script; + } + + if (Object.keys(inheritedProps).length > 0) { + const setResult = addAnimationToScript(result, { + targetSelector: newSelector, + method: "to", + position: newElementStart, + duration: 0.001, + properties: inheritedProps, + ease: "none", + }); + result = setResult.script; + } + + return result; +} + // ── Keyframe Mutation Functions ──────────────────────────────────────────── +function sortedKeyframes( + kfs: Array<{ percentage: number; properties: Record; ease?: string }>, +) { + return kfs.slice().sort((a, b) => a.percentage - b.percentage); +} + +function keyframePropsToCode(kf: { properties: Record }): string[] { + return Object.entries(kf.properties).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`); +} + /** Remove a named property from an ObjectExpression's properties array. */ function removeVarsKey(varsArg: any, key: string): void { if (varsArg?.type !== "ObjectExpression") return; @@ -1165,6 +1307,19 @@ function collapseKeyframesToFlat(varsArg: any, record: Record): removeVarsKey(varsArg, "easeEach"); } +/** + * Locate an animation's keyframes ObjectExpression and build the percentage key. + * Shared preamble for addKeyframeToScript, removeKeyframeFromScript, and + * updateKeyframeInScript. + */ +function locateKeyframeCtx(script: string, animationId: string, percentage: number) { + const loc = locateAnimation(script, animationId); + if (!loc) return null; + const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return null; + return { loc, kfNode, pctKey: `${percentage}%` }; +} + /** * Insert a keyframe at the given percentage in an existing percentage-keyframes * object. If the percentage already exists, its value is replaced. @@ -1177,12 +1332,10 @@ export function addKeyframeToScript( ease?: string, backfillDefaults?: Record, ): string { - const loc = locateAnimation(script, animationId); - if (!loc) return script; - const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); - if (!kfNode) return script; + const ctx = locateKeyframeCtx(script, animationId, percentage); + if (!ctx) return script; + const { loc, kfNode, pctKey } = ctx; - const pctKey = `${percentage}%`; const newValueNode = buildKeyframeValueNode(properties, ease); // Replace if this percentage already exists @@ -1246,12 +1399,10 @@ export function removeKeyframeFromScript( animationId: string, percentage: number, ): string { - const loc = locateAnimation(script, animationId); - if (!loc) return script; - const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); - if (!kfNode) return script; + const ctx = locateKeyframeCtx(script, animationId, percentage); + if (!ctx) return script; + const { loc, kfNode, pctKey } = ctx; - const pctKey = `${percentage}%`; const removeIdx = kfNode.properties.findIndex( (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, ); @@ -1281,12 +1432,10 @@ export function updateKeyframeInScript( properties: Record, ease?: string, ): string { - const loc = locateAnimation(script, animationId); - if (!loc) return script; - const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); - if (!kfNode) return script; + const ctx = locateKeyframeCtx(script, animationId, percentage); + if (!ctx) return script; + const { loc, kfNode, pctKey } = ctx; - const pctKey = `${percentage}%`; const existing = kfNode.properties.find( (p: any) => isObjectProperty(p) && propKeyName(p) === pctKey, ); @@ -1339,14 +1488,15 @@ function resolveConversionProps( /** Strip editable properties and ease/keyframes keys from a varsArg. */ function stripEditableAndEase(varsArg: any): void { + // ease is a BUILTIN_VAR_KEY (not editable), so filterEditableKeys won't remove it — + // drop it explicitly before filtering, along with keyframes. if (varsArg?.type !== "ObjectExpression") return; varsArg.properties = varsArg.properties.filter((p: any) => { if (!isObjectProperty(p)) return true; const key = propKeyName(p); - if (typeof key !== "string") return true; - if (key === "ease" || key === "keyframes") return false; - return !isEditablePropertyKey(key); + return key !== "ease" && key !== "keyframes"; }); + filterEditableKeys(varsArg, () => false); } /** Build and prepend a keyframes property node onto varsArg. */ @@ -1453,11 +1603,8 @@ export function materializeKeyframesInScript( } const entries: string[] = []; - const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage); - for (const kf of sorted) { - const propEntries = Object.entries(kf.properties).map( - ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, - ); + for (const kf of sortedKeyframes(keyframes)) { + const propEntries = keyframePropsToCode(kf); if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`); entries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`); } @@ -1543,11 +1690,8 @@ export function unrollDynamicAnimations( const calls: string[] = []; for (const el of elements) { const kfEntries: string[] = []; - const sorted = el.keyframes.slice().sort((a, b) => a.percentage - b.percentage); - for (const kf of sorted) { - const propEntries = Object.entries(kf.properties).map( - ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, - ); + for (const kf of sortedKeyframes(el.keyframes)) { + const propEntries = keyframePropsToCode(kf); kfEntries.push(`${JSON.stringify(kf.percentage + "%")}: { ${propEntries.join(", ")} }`); } if (el.easeEach) { diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 8cdc5ae05..a23572214 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -243,6 +243,11 @@ export function splitElementInHtml( clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000)); clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000)); + // Remove the "clip" class from the clone — it forces opacity:0 for entrance + // animations, but the split element continues mid-stream and doesn't need one. + // The runtime manages visibility via visibility:hidden/visible based on timing. + clone.classList.remove("clip"); + // Adjust media trim offset for the second half const playbackStartAttr = el.hasAttribute("data-playback-start") ? "data-playback-start" diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index fddd4321d..a69c74c4e 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -651,6 +651,14 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { keyframes: Array<{ percentage: number; properties: Record }>; easeEach?: string; }>; + } + | { + type: "split-animations"; + originalId: string; + newId: string; + splitTime: number; + elementStart: number; + elementDuration: number; }; api.post("/projects/:id/gsap-mutations/*", async (c) => { @@ -702,19 +710,23 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { // fallow-ignore-next-line complexity switch (body.type) { - case "update-property": { + case "update-property": + case "add-property": { const r = requireAnimation(block.scriptText, body.animationId); if ("err" in r) return r.err; + const val = body.type === "update-property" ? body.value : body.defaultValue; newScript = updateAnimationInScript(block.scriptText, body.animationId, { - properties: { ...r.anim.properties, [body.property]: body.value }, + properties: { ...r.anim.properties, [body.property]: val }, }); break; } - case "update-from-property": { + case "update-from-property": + case "add-from-property": { const r = requireFromToAnimation(block.scriptText, body.animationId); if ("err" in r) return r.err; + const val = body.type === "update-from-property" ? body.value : body.defaultValue; newScript = updateAnimationInScript(block.scriptText, body.animationId, { - fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.value }, + fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: val }, }); break; } @@ -742,22 +754,6 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { newScript = removeAnimationFromScript(block.scriptText, body.animationId); break; } - case "add-property": { - const r = requireAnimation(block.scriptText, body.animationId); - if ("err" in r) return r.err; - newScript = updateAnimationInScript(block.scriptText, body.animationId, { - properties: { ...r.anim.properties, [body.property]: body.defaultValue }, - }); - break; - } - case "add-from-property": { - const r = requireFromToAnimation(block.scriptText, body.animationId); - if ("err" in r) return r.err; - newScript = updateAnimationInScript(block.scriptText, body.animationId, { - fromProperties: { ...(r.anim.fromProperties ?? {}), [body.property]: body.defaultValue }, - }); - break; - } case "remove-property": { const r = requireAnimation(block.scriptText, body.animationId); if ("err" in r) return r.err; @@ -835,6 +831,17 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { } break; } + case "split-animations": { + const { splitAnimationsInScript } = await loadGsapParser(); + newScript = splitAnimationsInScript(block.scriptText, { + originalId: body.originalId, + newId: body.newId, + splitTime: body.splitTime, + elementStart: body.elementStart, + elementDuration: body.elementDuration, + }); + break; + } default: return c.json({ error: `unknown mutation type: ${(body as { type: string }).type}` }, 400); } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 76f4048c1..b9673ea2e 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -536,6 +536,8 @@ export function StudioApp() { handleTimelineElementResize={timelineEditing.handleTimelineElementResize} handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit} handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit} + handleRazorSplit={timelineEditing.handleRazorSplit} + handleRazorSplitAll={timelineEditing.handleRazorSplitAll} setCompIdToSrc={setCompIdToSrc} setCompositionLoading={setCompositionLoading} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 5399d3f42..b5e0d1924 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -52,6 +52,8 @@ export interface StudioPreviewAreaProps { ) => Promise | void; handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise | void; + handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise | void; + handleRazorSplitAll: (splitTime: number) => Promise | void; setCompIdToSrc: (map: Map) => void; setCompositionLoading: (loading: boolean) => void; shouldShowSelectedDomBounds: boolean; @@ -71,6 +73,8 @@ export function StudioPreviewArea({ handleTimelineElementResize, handleBlockedTimelineEdit, handleTimelineElementSplit, + handleRazorSplit, + handleRazorSplitAll, setCompIdToSrc, setCompositionLoading, shouldShowSelectedDomBounds, @@ -142,6 +146,8 @@ export function StudioPreviewArea({ onResizeElement={handleTimelineElementResize} onBlockedEditAttempt={handleBlockedTimelineEdit} onSplitElement={handleTimelineElementSplit} + onRazorSplit={handleRazorSplit} + onRazorSplitAll={handleRazorSplitAll} onSelectTimelineElement={handleTimelineElementSelect} onDeleteAllKeyframes={(_elId) => { const anim = diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 4754c1295..ac77926be 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -2,6 +2,7 @@ import { getNextTimelineZoomPercent, getTimelineZoomPercent, } from "../player/components/timelineZoom"; +import { useTimelineZoom } from "../player/components/useTimelineZoom"; import { getTimelineToggleTitle } from "../utils/timelineDiscovery"; import { usePlayerStore, type TimelineElement } from "../player"; import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability"; @@ -9,18 +10,26 @@ import { Tooltip } from "./ui"; import { Scissors } from "../icons/SystemIcons"; import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "./editor/domEditingTypes"; +import { canSplitElement } from "../utils/timelineElementSplit"; +/** Collect all property names that have numeric values across a set of keyframes. */ +function collectNumericKeyframeProps(keyframes: GsapPercentageKeyframe[]): Set { + const props = new Set(); + for (const kf of keyframes) { + for (const p of Object.keys(kf.properties)) { + if (typeof kf.properties[p] === "number") props.add(p); + } + } + return props; +} + +// fallow-ignore-next-line complexity function interpolateKeyframeProperties( keyframes: GsapPercentageKeyframe[], pct: number, ): Record { const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage); - const allProps = new Set(); - for (const kf of sorted) { - for (const p of Object.keys(kf.properties)) { - if (typeof kf.properties[p] === "number") allProps.add(p); - } - } + const allProps = collectNumericKeyframeProps(sorted); const result: Record = {}; for (const prop of allProps) { let prev: { pct: number; val: number } | null = null; @@ -43,6 +52,7 @@ function interpolateKeyframeProperties( return result; } +// fallow-ignore-next-line complexity function readRuntimeKeyframeValues( iframe: HTMLIFrameElement | null, sel: DomEditSelection, @@ -66,12 +76,7 @@ function readRuntimeKeyframeValues( } const element = doc?.querySelector(selector); if (!element) return {}; - const allProps = new Set(); - for (const kf of keyframes) { - for (const p of Object.keys(kf.properties)) { - if (typeof kf.properties[p] === "number") allProps.add(p); - } - } + const allProps = collectNumericKeyframeProps(keyframes); const result: Record = {}; for (const prop of allProps) { const val = Number(gsap.getProperty(element, prop)); @@ -107,14 +112,17 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { const kfAnim = anims.find((a) => a.keyframes); const flatAnim = anims.find((a) => !a.keyframes); + const computePct = (time: number) => { + const elStart = Number.parseFloat(sel?.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel?.dataAttributes?.duration ?? "1") || 1; + return elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((time - elStart) / elDuration) * 1000) / 10)) + : 0; + }; + let state: "active" | "inactive" | "none" = "none"; if (kfAnim?.keyframes && sel) { - const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; - const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; - const pct = - elDuration > 0 - ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10)) - : 0; + const pct = computePct(currentTime); state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1) ? "active" : "inactive"; @@ -128,12 +136,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { if (kfAnim.hasUnresolvedKeyframes) { await session.handleGsapMaterializeKeyframes?.(kfAnim.id); } - const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; - const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; - const pct = - elDuration > 0 - ? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10)) - : 0; + const pct = computePct(t); const existing = kfAnim.keyframes.keyframes.find( (k) => Math.abs(k.percentage - pct) <= 1, ); @@ -164,15 +167,15 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { return { state, onToggle }; } +// fallow-ignore-next-line complexity export function TimelineToolbar({ toggleTimelineVisibility, domEditSession, onSplitElement, }: TimelineToolbarProps) { - const zoomMode = usePlayerStore((s) => s.zoomMode); - const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); - const setZoomMode = usePlayerStore((s) => s.setZoomMode); - const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent); + const activeTool = usePlayerStore((s) => s.activeTool); + const setActiveTool = usePlayerStore((s) => s.setActiveTool); + const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent); const { state: keyframeState, onToggle: onToggleKeyframe } = useKeyframeToggle(domEditSession); @@ -183,6 +186,36 @@ export function TimelineToolbar({
Timeline
+
+ + + + + + +
{STUDIO_KEYFRAMES_ENABLED && onToggleKeyframe && ( (e.key ?? e.id) === selectedElementId) : null; - const splittable = - el && !el.compositionSrc && ["video", "audio", "img"].includes(el.tag); - if (!splittable) return null; + if (!el || !canSplitElement(el)) return null; const canSplit = currentTime > el.start && currentTime < el.start + el.duration; return ( diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index e49fb730e..00e94b1d6 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -10,7 +10,7 @@ import { import { useMountEffect } from "../../hooks/useMountEffect"; import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player"; import type { TimelineElement } from "../../player"; -import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; +import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks"; import { NLEPreview } from "./NLEPreview"; import { CompositionBreadcrumb } from "./CompositionBreadcrumb"; import { usePreviewBlockDrop } from "./usePreviewBlockDrop"; @@ -20,7 +20,7 @@ import { getTimelineToggleTitle, } from "../../utils/timelineDiscovery"; -interface NLELayoutProps { +interface NLELayoutProps extends TimelineEditCallbacks { projectId: string; portrait?: boolean; /** Slot for overlays rendered on top of the preview (cursors, highlights, etc.) */ @@ -59,23 +59,7 @@ interface NLELayoutProps { blockName: string, position: { left: number; top: number }, ) => Promise | void; - /** Persist timeline move actions back into source HTML */ - onMoveElement?: ( - element: TimelineElement, - updates: Pick, - ) => Promise | void; - onResizeElement?: ( - element: TimelineElement, - updates: Pick, - ) => Promise | void; - onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; - onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void; onSelectTimelineElement?: (element: TimelineElement | null) => void; - onDeleteKeyframe?: (elementId: string, percentage: number) => void; - onDeleteAllKeyframes?: (elementId: string) => void; - onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; - onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; - onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => void; /** Whether the timeline panel is visible (default: true) */ @@ -124,6 +108,8 @@ export const NLELayout = memo(function NLELayout({ onResizeElement, onBlockedEditAttempt, onSplitElement, + onRazorSplit, + onRazorSplitAll, onSelectTimelineElement, onDeleteKeyframe, onDeleteAllKeyframes, @@ -460,6 +446,8 @@ export const NLELayout = memo(function NLELayout({ onResizeElement={onResizeElement} onBlockedEditAttempt={onBlockedEditAttempt} onSplitElement={onSplitElement} + onRazorSplit={onRazorSplit} + onRazorSplitAll={onRazorSplitAll} onSelectElement={onSelectTimelineElement} onDeleteKeyframe={onDeleteKeyframe} onDeleteAllKeyframes={onDeleteAllKeyframes} diff --git a/packages/studio/src/components/nle/TimelineEditorNotice.tsx b/packages/studio/src/components/nle/TimelineEditorNotice.tsx index b65da3f81..e8213325e 100644 --- a/packages/studio/src/components/nle/TimelineEditorNotice.tsx +++ b/packages/studio/src/components/nle/TimelineEditorNotice.tsx @@ -1,4 +1,5 @@ import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery"; +import { PlayheadIndicator } from "../../player/components/PlayheadIndicator"; interface TimelineEditorNoticeProps { onDismiss: () => void; @@ -76,31 +77,7 @@ export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) { "hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite", }} > -
-
-
-
+
diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 5140afc31..64741b23c 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -6,6 +6,7 @@ import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar"; import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery"; import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers"; +import { canSplitElement } from "../utils/timelineElementSplit"; /** Safely resolves contentWindow for a potentially cross-origin iframe. */ function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null { @@ -323,7 +324,7 @@ export function useAppHotkeys({ const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); if ( element && - ["video", "audio", "img"].includes(element.tag) && + canSplitElement(element) && currentTime > element.start && currentTime < element.start + element.duration ) { @@ -334,6 +335,50 @@ export function useAppHotkeys({ } } + // B — toggle razor tool + if ( + event.key.toLowerCase() === "b" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + !isEditableTarget(event.target) + ) { + event.preventDefault(); + const { activeTool, setActiveTool } = usePlayerStore.getState(); + setActiveTool(activeTool === "razor" ? "select" : "razor"); + return; + } + + // V — return to selection tool + if ( + event.key.toLowerCase() === "v" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + !isEditableTarget(event.target) + ) { + event.preventDefault(); + usePlayerStore.getState().setActiveTool("select"); + return; + } + + // Escape — exit razor mode (only when no selection to deselect first) + if (event.key === "Escape" && !isEditableTarget(event.target)) { + const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } = + usePlayerStore.getState(); + if (activeTool === "razor") { + if (selectedElementId) { + setSelectedElementId(null); + } else { + setActiveTool("select"); + } + event.preventDefault(); + return; + } + } + // Delete / Backspace — remove selected keyframes > reset keyframes > remove element if ( (event.key === "Delete" || event.key === "Backspace") && diff --git a/packages/studio/src/hooks/useClipboard.ts b/packages/studio/src/hooks/useClipboard.ts index 623f4748c..f6af5cba4 100644 --- a/packages/studio/src/hooks/useClipboard.ts +++ b/packages/studio/src/hooks/useClipboard.ts @@ -8,6 +8,7 @@ import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop"; import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import type { EditHistoryKind } from "../utils/editHistory"; import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; +import { readFileContent } from "../utils/timelineElementSplit"; interface RecordEditInput { label: string; @@ -30,16 +31,6 @@ interface UseClipboardOptions { previewIframeRef: React.MutableRefObject; } -async function readFileContent(projectId: string, targetPath: string): Promise { - const response = await fetch( - `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`, - ); - if (!response.ok) throw new Error(`Failed to read ${targetPath}`); - const data = (await response.json()) as { content?: string }; - if (typeof data.content !== "string") throw new Error(`Missing file contents for ${targetPath}`); - return data.content; -} - function getElementOuterHtml( iframeRef: React.MutableRefObject, selection: DomEditSelection, diff --git a/packages/studio/src/hooks/useContextMenuDismiss.ts b/packages/studio/src/hooks/useContextMenuDismiss.ts new file mode 100644 index 000000000..c6d04579a --- /dev/null +++ b/packages/studio/src/hooks/useContextMenuDismiss.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useRef, type RefObject } from "react"; + +/** + * Shared dismiss logic for context menus: closes on outside click or Escape. + * Returns a ref to attach to the menu container element. + */ +export function useContextMenuDismiss(onClose: () => void): RefObject { + const menuRef = useRef(null); + + const dismiss = useCallback( + (e: MouseEvent | KeyboardEvent) => { + if (e instanceof KeyboardEvent && e.key !== "Escape") return; + if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return; + onClose(); + }, + [onClose], + ); + + useEffect(() => { + document.addEventListener("mousedown", dismiss); + document.addEventListener("keydown", dismiss); + return () => { + document.removeEventListener("mousedown", dismiss); + document.removeEventListener("keydown", dismiss); + }; + }, [dismiss]); + + return menuRef; +} diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts new file mode 100644 index 000000000..f3f6a797e --- /dev/null +++ b/packages/studio/src/hooks/useRazorSplit.ts @@ -0,0 +1,183 @@ +import { useCallback, useRef } from "react"; +import type { TimelineElement } from "../player"; +import { usePlayerStore } from "../player"; +import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; +import { getTimelineElementLabel, collectHtmlIds } from "../utils/studioHelpers"; +import { canSplitElement, buildPatchTarget, readFileContent } from "../utils/timelineElementSplit"; +import type { RecordEditInput } from "./useTimelineEditing"; + +interface UseRazorSplitOptions { + projectId: string | null; + activeCompPath: string | null; + showToast: (message: string, tone?: "error" | "info") => void; + writeProjectFile: (path: string, content: string) => Promise; + recordEdit: (input: RecordEditInput) => Promise; + domEditSaveTimestampRef: React.MutableRefObject; + reloadPreview: () => void; +} + +function generateSplitId(existingIds: string[], baseId: string): string { + let newId = `${baseId}-split`; + let suffix = 2; + while (existingIds.includes(newId)) { + newId = `${baseId}-split-${suffix++}`; + } + return newId; +} + +async function splitHtmlElement( + projectId: string, + targetPath: string, + patchTarget: NonNullable>, + splitTime: number, + newId: string, +): Promise<{ ok: boolean; content?: string }> { + const response = await fetch( + `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target: patchTarget, splitTime, newId }), + }, + ); + if (!response.ok) throw new Error("Split request failed"); + return (await response.json()) as { ok: boolean; changed?: boolean; content?: string }; +} + +async function splitGsapAnimations( + projectId: string, + targetPath: string, + originalId: string, + newId: string, + splitTime: number, + elementStart: number, + elementDuration: number, +): Promise { + const response = await fetch( + `/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "split-animations", + originalId, + newId, + splitTime, + elementStart, + elementDuration, + }), + }, + ); + if (!response.ok) return null; + const data = (await response.json()) as { ok?: boolean; after?: string }; + return data.ok && data.after ? data.after : null; +} + +// fallow-ignore-next-line complexity +async function executeSplit( + pid: string, + element: TimelineElement, + splitTime: number, + activeCompPath: string | null, +): Promise<{ targetPath: string; originalContent: string; patchedContent: string }> { + const patchTarget = buildPatchTarget(element); + if (!patchTarget) throw new Error("Clip is missing a patchable target."); + + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const originalContent = await readFileContent(pid, targetPath); + const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip"); + + const splitResult = await splitHtmlElement(pid, targetPath, patchTarget, splitTime, newId); + if (!splitResult.ok) throw new Error("Failed to split clip."); + + let patchedContent = + typeof splitResult.content === "string" ? splitResult.content : originalContent; + + if (element.domId) { + const gsapContent = await splitGsapAnimations( + pid, + targetPath, + element.domId, + newId, + splitTime, + element.start, + element.duration, + ); + if (gsapContent) patchedContent = gsapContent; + } + + return { targetPath, originalContent, patchedContent }; +} + +export function useRazorSplit({ + projectId, + activeCompPath, + showToast, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + reloadPreview, +}: UseRazorSplitOptions) { + const projectIdRef = useRef(projectId); + projectIdRef.current = projectId; + + const handleRazorSplit = useCallback( + // fallow-ignore-next-line complexity + async (element: TimelineElement, splitTime: number) => { + const pid = projectIdRef.current; + if (!pid || !canSplitElement(element)) return; + if (splitTime <= element.start || splitTime >= element.start + element.duration) return; + + try { + const { targetPath, originalContent, patchedContent } = await executeSplit( + pid, + element, + splitTime, + activeCompPath, + ); + + domEditSaveTimestampRef.current = Date.now(); + await saveProjectFilesWithHistory({ + projectId: pid, + label: "Split timeline clip", + kind: "timeline", + files: { [targetPath]: patchedContent }, + readFile: async () => originalContent, + writeFile: writeProjectFile, + recordEdit, + }); + + reloadPreview(); + showToast(`Split ${getTimelineElementLabel(element)} at ${splitTime.toFixed(2)}s`, "info"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to split timeline clip"; + showToast(message, "error"); + } + }, + [ + activeCompPath, + recordEdit, + showToast, + writeProjectFile, + domEditSaveTimestampRef, + reloadPreview, + ], + ); + + const handleRazorSplitAll = useCallback( + async (splitTime: number) => { + const pid = projectIdRef.current; + if (!pid) return; + const { elements } = usePlayerStore.getState(); + const splittable = elements.filter( + (el) => canSplitElement(el) && splitTime > el.start && splitTime < el.start + el.duration, + ); + for (const element of splittable) { + await handleRazorSplit(element, splitTime); + } + }, + [handleRazorSplit], + ); + + return { handleRazorSplit, handleRazorSplitAll }; +} diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index f2b7c032a..033dc1f52 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -2,6 +2,8 @@ import { useCallback, useRef } from "react"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { applyPatchByTarget, readAttributeByTarget } from "../utils/sourcePatcher"; +import { useRazorSplit } from "./useRazorSplit"; +import { buildPatchTarget, readFileContent } from "../utils/timelineElementSplit"; import { formatTimelineAttributeNumber } from "../player/components/timelineEditing"; import { buildTimelineAssetId, @@ -22,7 +24,7 @@ import type { EditHistoryKind } from "../utils/editHistory"; // ── Types ── -interface RecordEditInput { +export interface RecordEditInput { label: string; kind: EditHistoryKind; coalesceKey?: string; @@ -45,16 +47,6 @@ interface UseTimelineEditingOptions { // ── Helpers ── -function buildPatchTarget(element: { domId?: string; selector?: string; selectorIndex?: number }) { - if (element.domId) { - return { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }; - } - if (element.selector) { - return { selector: element.selector, selectorIndex: element.selectorIndex }; - } - return null; -} - // The runtime re-reads data-start/data-duration from the DOM on each sync tick // (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are // picked up automatically on the next frame without a rebind call. @@ -146,20 +138,6 @@ async function persistTimelineEdit(input: PersistTimelineEditInput): Promise { - const response = await fetch( - `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`, - ); - if (!response.ok) { - throw new Error(`Failed to read ${targetPath}`); - } - const data = (await response.json()) as { content?: string }; - if (typeof data.content !== "string") { - throw new Error(`Missing file contents for ${targetPath}`); - } - return data.content; -} - // ── Hook ── export function useTimelineEditing({ @@ -466,103 +444,23 @@ export function useTimelineEditing({ [showToast], ); - const handleTimelineElementSplit = useCallback( - async (element: TimelineElement, splitTime: number) => { - const pid = projectIdRef.current; - if (!pid) return; - - const splittableTags = new Set(["video", "audio", "img"]); - if ( - element.timelineLocked || - element.timingSource === "implicit" || - element.compositionSrc || - !splittableTags.has(element.tag) || - !element.duration || - !Number.isFinite(element.duration) - ) { - return; - } - - if (splitTime <= element.start || splitTime >= element.start + element.duration) { - showToast("Playhead must be inside the clip to split.", "error"); - return; - } - - const patchTarget = buildPatchTarget(element); - if (!patchTarget) { - showToast("Clip is missing a patchable target.", "error"); - return; - } - - const targetPath = element.sourceFile || activeCompPath || "index.html"; - try { - const originalContent = await readFileContent(pid, targetPath); - const existingIds = collectHtmlIds(originalContent); - const baseId = element.domId || "clip"; - let newId = `${baseId}-split`; - let suffix = 2; - while (existingIds.includes(newId)) { - newId = `${baseId}-split-${suffix++}`; - } - - const response = await fetch( - `/api/projects/${pid}/file-mutations/split-element/${encodeURIComponent(targetPath)}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target: patchTarget, splitTime, newId }), - }, - ); - if (!response.ok) { - throw new Error("Split request failed"); - } - - const data = (await response.json()) as { - ok?: boolean; - changed?: boolean; - content?: string; - }; - if (!data.ok || !data.changed) { - showToast("Failed to split clip — playhead may be outside the clip.", "error"); - return; - } - - const patchedContent = typeof data.content === "string" ? data.content : originalContent; - - domEditSaveTimestampRef.current = Date.now(); - await saveProjectFilesWithHistory({ - projectId: pid, - label: "Split timeline clip", - kind: "timeline", - files: { [targetPath]: patchedContent }, - readFile: async () => originalContent, - writeFile: writeProjectFile, - recordEdit, - }); - - reloadPreview(); - const label = getTimelineElementLabel(element); - showToast(`Split ${label} at ${splitTime.toFixed(2)}s`, "info"); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to split timeline clip"; - showToast(message, "error"); - } - }, - [ - activeCompPath, - recordEdit, - showToast, - writeProjectFile, - domEditSaveTimestampRef, - reloadPreview, - ], - ); + const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({ + projectId, + activeCompPath, + showToast, + writeProjectFile, + recordEdit, + domEditSaveTimestampRef, + reloadPreview, + }); return { handleTimelineElementMove, handleTimelineElementResize, handleTimelineElementDelete, - handleTimelineElementSplit, + handleTimelineElementSplit: handleRazorSplit, + handleRazorSplit, + handleRazorSplitAll, handleTimelineAssetDrop, handleTimelineFileDrop, handleBlockedTimelineEdit, diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index b9ae70e13..93122b1d9 100644 --- a/packages/studio/src/player/components/ClipContextMenu.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -1,5 +1,7 @@ -import { memo, useCallback, useEffect, useRef } from "react"; +import { memo } from "react"; import type { TimelineElement } from "../store/playerStore"; +import { canSplitElement } from "../../utils/timelineElementSplit"; +import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss"; interface ClipContextMenuProps { x: number; @@ -20,30 +22,12 @@ export const ClipContextMenu = memo(function ClipContextMenu({ onSplit, onDelete, }: ClipContextMenuProps) { - const menuRef = useRef(null); - - const dismiss = useCallback( - (e: MouseEvent | KeyboardEvent) => { - if (e instanceof KeyboardEvent && e.key !== "Escape") return; - if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return; - onClose(); - }, - [onClose], - ); - - useEffect(() => { - document.addEventListener("mousedown", dismiss); - document.addEventListener("keydown", dismiss); - return () => { - document.removeEventListener("mousedown", dismiss); - document.removeEventListener("keydown", dismiss); - }; - }, [dismiss]); + const menuRef = useContextMenuDismiss(onClose); const adjustedX = Math.min(x, window.innerWidth - 200); const adjustedY = Math.min(y, window.innerHeight - 200); - const isSplittable = ["video", "audio", "img"].includes(element.tag); + const isSplittable = canSplitElement(element); const canSplit = isSplittable && currentTime > element.start && currentTime < element.start + element.duration; diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index 593f27f61..8f16cceec 100644 --- a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -1,5 +1,6 @@ -import { memo, useCallback, useEffect, useRef } from "react"; +import { memo, useRef } from "react"; import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants"; +import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss"; export interface KeyframeDiamondContextMenuState { x: number; @@ -41,27 +42,9 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe onChangeEase, onCopyProperties, }: KeyframeDiamondContextMenuProps) { - const menuRef = useRef(null); + const menuRef = useContextMenuDismiss(onClose); const easeSubmenuRef = useRef(null); - const dismiss = useCallback( - (e: MouseEvent | KeyboardEvent) => { - if (e instanceof KeyboardEvent && e.key !== "Escape") return; - if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return; - onClose(); - }, - [onClose], - ); - - useEffect(() => { - document.addEventListener("mousedown", dismiss); - document.addEventListener("keydown", dismiss); - return () => { - document.removeEventListener("mousedown", dismiss); - document.removeEventListener("keydown", dismiss); - }; - }, [dismiss]); - const adjustedX = Math.min(state.x, window.innerWidth - 200); const adjustedY = Math.min(state.y, window.innerHeight - 300); diff --git a/packages/studio/src/player/components/PlayheadIndicator.tsx b/packages/studio/src/player/components/PlayheadIndicator.tsx new file mode 100644 index 000000000..abc03d093 --- /dev/null +++ b/packages/studio/src/player/components/PlayheadIndicator.tsx @@ -0,0 +1,42 @@ +/** + * Shared playhead visual used by TimelineCanvas (real playhead) and + * TimelineEditorNotice (animated illustration). + */ +interface PlayheadIndicatorProps { + /** CSS color, defaults to the HF accent variable */ + color?: string; + /** Glow shadow color, defaults to translucent accent */ + glowColor?: string; +} + +export function PlayheadIndicator({ + color = "var(--hf-accent, #3CE6AC)", + glowColor = "rgba(60,230,172,0.5)", +}: PlayheadIndicatorProps) { + return ( + <> +
+
+
+
+ + ); +} diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index e82386ee7..6df0d41bd 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -2,12 +2,12 @@ import { useRef, useMemo, useCallback, useState, useEffect, memo, type ReactNode import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { useMountEffect } from "../../hooks/useMountEffect"; import { EditPopover } from "./EditModal"; -import { type BlockedTimelineEditIntent } from "./timelineEditing"; import { defaultTimelineTheme, type TimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons"; import { getTimelinePixelsPerSecond } from "./timelineZoom"; +import { useTimelineZoom } from "./useTimelineZoom"; import { useTimelineAssetDrop } from "./timelineDragDrop"; import { TimelineEmptyState } from "./TimelineEmptyState"; import { TimelineCanvas } from "./TimelineCanvas"; @@ -23,6 +23,7 @@ import { getTimelineCanvasHeight, shouldShowTimelineShortcutHint, } from "./timelineLayout"; +import type { TimelineEditCallbacks, TimelineDropCallbacks } from "./timelineCallbacks"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { @@ -39,7 +40,7 @@ export { getDefaultDroppedTrack, } from "./timelineLayout"; -interface TimelineProps { +interface TimelineProps extends TimelineEditCallbacks, TimelineDropCallbacks { onSeek?: (time: number) => void; onDrillDown?: (element: TimelineElement) => void; renderClipContent?: ( @@ -47,35 +48,8 @@ interface TimelineProps { style: { clip: string; label: string }, ) => ReactNode; renderClipOverlay?: (element: TimelineElement) => ReactNode; - onFileDrop?: ( - files: File[], - placement?: { start: number; track: number }, - ) => Promise | void; - onAssetDrop?: ( - assetPath: string, - placement: { start: number; track: number }, - ) => Promise | void; - onBlockDrop?: ( - blockName: string, - placement: { start: number; track: number }, - ) => Promise | void; onDeleteElement?: (element: TimelineElement) => Promise | void; - onMoveElement?: ( - element: TimelineElement, - updates: Pick, - ) => Promise | void; - onResizeElement?: ( - element: TimelineElement, - updates: Pick, - ) => Promise | void; - onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; - onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void; onSelectElement?: (element: TimelineElement | null) => void; - onDeleteKeyframe?: (elementId: string, percentage: number) => void; - onDeleteAllKeyframes?: (elementId: string) => void; - onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; - onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; - onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; theme?: Partial; } @@ -92,6 +66,8 @@ export const Timeline = memo(function Timeline({ onResizeElement, onBlockedEditAttempt, onSplitElement, + onRazorSplit, + onRazorSplitAll, onSelectElement, onDeleteKeyframe, onDeleteAllKeyframes, @@ -107,17 +83,16 @@ export const Timeline = memo(function Timeline({ const selectedElementId = usePlayerStore((s) => s.selectedElementId); const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); const currentTime = usePlayerStore((s) => s.currentTime); - const zoomMode = usePlayerStore((s) => s.zoomMode); - const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); - const setZoomMode = usePlayerStore((s) => s.setZoomMode); - const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent); + const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); const playheadRef = useRef(null); const containerRef = useRef(null); const scrollRef = useRef(null); + const activeTool = usePlayerStore((s) => s.activeTool); const [hoveredClip, setHoveredClip] = useState(null); const isDragging = useRef(false); const [shiftHeld, setShiftHeld] = useState(false); + const [razorGuideX, setRazorGuideX] = useState(null); useMountEffect(() => { const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true); @@ -388,7 +363,14 @@ export const Timeline = memo(function Timeline({
{ + if (activeTool === "razor" && scrollRef.current) { + const rect = scrollRef.current.getBoundingClientRect(); + setRazorGuideX(e.clientX - rect.left + scrollRef.current.scrollLeft); + } + }} + onMouseLeave={() => setRazorGuideX(null)} style={{ touchAction: "pan-x pan-y", background: theme.shellBackground, @@ -402,7 +384,16 @@ export const Timeline = memo(function Timeline({ onDragOver={handleAssetDragOver} onDragLeave={() => setIsDragOver(false)} onDrop={handleAssetDrop} - onPointerDown={handlePointerDown} + onPointerDown={(e) => { + if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) { + const rect = scrollRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER; + const splitTime = Math.max(0, x / pps); + onRazorSplitAll?.(splitTime); + return; + } + handlePointerDown(e); + }} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onLostPointerCapture={handlePointerUp} @@ -488,7 +479,19 @@ export const Timeline = memo(function Timeline({ onSelectElement?.(el); setClipContextMenu({ x: e.clientX, y: e.clientY, element: el }); }} + onRazorSplit={onRazorSplit} + onRazorSplitAll={onRazorSplitAll} /> + {activeTool === "razor" && razorGuideX !== null && ( +
+ )}
{showShortcutHint && !showPopover && !rangeSelection && ( diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 215c55b97..dfb6a7875 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -2,6 +2,7 @@ import { memo, type ReactNode } from "react"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelineRuler } from "./TimelineRuler"; +import { PlayheadIndicator } from "./PlayheadIndicator"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent, @@ -9,7 +10,11 @@ import { } from "./timelineEditing"; import { getRenderedTimelineElement, type TimelineTheme } from "./timelineTheme"; import { GUTTER, TRACK_H, RULER_H, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; -import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore"; +import { + usePlayerStore, + type TimelineElement, + type KeyframeCacheEntry, +} from "../store/playerStore"; import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag"; import type { TrackVisualStyle } from "./timelineIcons"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; @@ -69,6 +74,8 @@ interface TimelineCanvasProps { onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; + onRazorSplit?: (element: TimelineElement, splitTime: number) => void; + onRazorSplitAll?: (splitTime: number) => void; } export const TimelineCanvas = memo(function TimelineCanvas({ @@ -119,6 +126,8 @@ export const TimelineCanvas = memo(function TimelineCanvas({ onContextMenuKeyframe, onContextMenuClip, onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead, + onRazorSplit, + onRazorSplitAll, }: TimelineCanvasProps) { const draggedElement = draggedClip?.element ?? null; const activeDraggedElement = @@ -288,6 +297,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ }} onPointerDown={(e) => { if (e.button !== 0) return; + if (usePlayerStore.getState().activeTool === "razor") return; if (e.shiftKey) { shiftClickClipRef.current = { element: el, @@ -341,6 +351,26 @@ export const TimelineCanvas = memo(function TimelineCanvas({ onClick={(e) => { e.stopPropagation(); if (suppressClickRef.current) return; + const { activeTool } = usePlayerStore.getState(); + if (activeTool === "razor" && onRazorSplit) { + const clipRect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const clickOffsetX = e.clientX - clipRect.left; + const splitTime = previewElement.start + clickOffsetX / pps; + const epsilon = 0.03; + const clampedTime = Math.max( + previewElement.start + epsilon, + Math.min( + previewElement.start + previewElement.duration - epsilon, + splitTime, + ), + ); + if (e.shiftKey && onRazorSplitAll) { + onRazorSplitAll(clampedTime); + } else { + onRazorSplit(el, clampedTime); + } + return; + } const nextElement = isSelected ? null : el; setSelectedElementId(nextElement ? elementKey : null); onSelectElement?.(nextElement); @@ -440,28 +470,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ className="absolute top-0 bottom-0 pointer-events-none" style={{ left: `${GUTTER}px`, zIndex: 100 }} > -
-
-
-
+
); diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts new file mode 100644 index 000000000..53e90d366 --- /dev/null +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -0,0 +1,42 @@ +import type { TimelineElement } from "../store/playerStore"; +import type { BlockedTimelineEditIntent } from "./timelineEditing"; + +/** + * Shared callback signatures for timeline editing operations. + * Used by NLELayout, Timeline, and any component that passes through + * the standard set of timeline mutation handlers. + */ +export interface TimelineDropCallbacks { + onFileDrop?: ( + files: File[], + placement?: { start: number; track: number }, + ) => Promise | void; + onAssetDrop?: ( + assetPath: string, + placement: { start: number; track: number }, + ) => Promise | void; + onBlockDrop?: ( + blockName: string, + placement: { start: number; track: number }, + ) => Promise | void; +} + +export interface TimelineEditCallbacks { + onMoveElement?: ( + element: TimelineElement, + updates: Pick, + ) => Promise | void; + onResizeElement?: ( + element: TimelineElement, + updates: Pick, + ) => Promise | void; + onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void; + onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise | void; + onRazorSplitAll?: (splitTime: number) => Promise | void; + onDeleteKeyframe?: (elementId: string, percentage: number) => void; + onDeleteAllKeyframes?: (elementId: string) => void; + onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; + onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; + onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; +} diff --git a/packages/studio/src/player/components/timelineDragDrop.ts b/packages/studio/src/player/components/timelineDragDrop.ts index 9cd55cc43..58ec3d709 100644 --- a/packages/studio/src/player/components/timelineDragDrop.ts +++ b/packages/studio/src/player/components/timelineDragDrop.ts @@ -1,25 +1,13 @@ -// fallow-ignore-file clone-families import { useCallback, useState, type RefObject } from "react"; import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop"; import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout"; +import type { TimelineDropCallbacks } from "./timelineCallbacks"; -interface UseTimelineAssetDropOptions { +interface UseTimelineAssetDropOptions extends TimelineDropCallbacks { scrollRef: RefObject; ppsRef: RefObject; durationRef: RefObject; trackOrderRef: RefObject; - onFileDrop?: ( - files: File[], - placement?: { start: number; track: number }, - ) => Promise | void; - onAssetDrop?: ( - assetPath: string, - placement: { start: number; track: number }, - ) => Promise | void; - onBlockDrop?: ( - blockName: string, - placement: { start: number; track: number }, - ) => Promise | void; } export function useTimelineAssetDrop({ diff --git a/packages/studio/src/player/components/useTimelineZoom.ts b/packages/studio/src/player/components/useTimelineZoom.ts new file mode 100644 index 000000000..778781b5c --- /dev/null +++ b/packages/studio/src/player/components/useTimelineZoom.ts @@ -0,0 +1,17 @@ +import { usePlayerStore, type ZoomMode } from "../store/playerStore"; + +export interface TimelineZoomState { + zoomMode: ZoomMode; + manualZoomPercent: number; + setZoomMode: (mode: ZoomMode) => void; + setManualZoomPercent: (percent: number) => void; +} + +/** Shared zoom-related store selectors used by Timeline and TimelineToolbar. */ +export function useTimelineZoom(): TimelineZoomState { + const zoomMode = usePlayerStore((s) => s.zoomMode); + const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); + const setZoomMode = usePlayerStore((s) => s.setZoomMode); + const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent); + return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent }; +} diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index d67aad396..01bdfe555 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -43,6 +43,7 @@ export interface TimelineElement { } export type ZoomMode = "fit" | "manual"; +type TimelineTool = "select" | "razor"; interface PlayerState { isPlaying: boolean; @@ -63,6 +64,9 @@ interface PlayerState { /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */ outPoint: number | null; + activeTool: TimelineTool; + setActiveTool: (tool: TimelineTool) => void; + /** Set of selected keyframe keys in format `${elementId}:${percentage}`. */ selectedKeyframes: Set; toggleSelectedKeyframe: (key: string) => void; @@ -128,6 +132,9 @@ export const usePlayerStore = create((set) => ({ inPoint: null, outPoint: null, + activeTool: "select", + setActiveTool: (tool) => set({ activeTool: tool }), + selectedKeyframes: new Set(), toggleSelectedKeyframe: (key) => set((s) => { @@ -209,6 +216,7 @@ export const usePlayerStore = create((set) => ({ selectedElementId: null, inPoint: null, outPoint: null, + activeTool: "select", selectedKeyframes: new Set(), keyframeCache: new Map(), }), diff --git a/packages/studio/src/utils/timelineElementSplit.ts b/packages/studio/src/utils/timelineElementSplit.ts new file mode 100644 index 000000000..a413b9414 --- /dev/null +++ b/packages/studio/src/utils/timelineElementSplit.ts @@ -0,0 +1,39 @@ +import type { TimelineElement } from "../player/store/playerStore"; + +export function canSplitElement(el: TimelineElement): boolean { + return ( + !el.timelineLocked && + el.timingSource !== "implicit" && + !el.compositionSrc && + !!el.duration && + Number.isFinite(el.duration) + ); +} + +export function buildPatchTarget(element: { + domId?: string; + selector?: string; + selectorIndex?: number; +}) { + if (element.domId) { + return { + id: element.domId, + selector: element.selector, + selectorIndex: element.selectorIndex, + }; + } + if (element.selector) { + return { selector: element.selector, selectorIndex: element.selectorIndex }; + } + return null; +} + +export async function readFileContent(projectId: string, targetPath: string): Promise { + const response = await fetch( + `/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`, + ); + if (!response.ok) throw new Error(`Failed to read ${targetPath}`); + const data = (await response.json()) as { content?: string }; + if (typeof data.content !== "string") throw new Error(`Missing file contents for ${targetPath}`); + return data.content; +}