diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 34abacd29f..5b413ae95c 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -31,6 +31,10 @@ // Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel. "packages/studio/src/components/editor/KeyframeDiamond.tsx", "packages/studio/src/components/editor/SpringEaseEditor.tsx", + // NLE notice — rendered conditionally via NLELayout when timeline is first shown. + "packages/studio/src/components/nle/TimelineEditorNotice.tsx", + // Zoom hook extracted for downstream razor-blade PRs (#1330, #1331). + "packages/studio/src/player/components/useTimelineZoom.ts", ], "ignorePatterns": [ "docs/**", @@ -113,6 +117,27 @@ "createFailedCaptureCalibrationEstimate", ], }, + // Shared test helpers consumed by gsapParser.test.ts (same file, + // fallow doesn't trace intra-file test consumption). + { + "file": "packages/core/src/parsers/gsapParser.test-helpers.ts", + "exports": [ + "expectKeyframe", + "expectKeyframesFormat", + "convertAndReparse", + "parseSplitAndAssert", + ], + }, + // Shared timeline components extracted for downstream PRs in the + // razor-blade stack (#1330, #1331). Consumers live on those branches. + { + "file": "packages/studio/src/player/components/timelineCallbacks.ts", + "exports": ["*"], + }, + { + "file": "packages/studio/src/utils/timelineElementSplit.ts", + "exports": ["buildPatchTarget", "readFileContent"], + }, ], "ignoreDependencies": [ // Runtime/dynamic deps not visible to static analysis: tsup `external`, diff --git a/packages/core/src/parsers/gsapParser.stress.test.ts b/packages/core/src/parsers/gsapParser.stress.test.ts index 191521df64..95c1ef0476 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 0000000000..da11015604 --- /dev/null +++ b/packages/core/src/parsers/gsapParser.test-helpers.ts @@ -0,0 +1,131 @@ +// fallow-ignore-file dead-code +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/studio/src/components/nle/TimelineEditorNotice.tsx b/packages/studio/src/components/nle/TimelineEditorNotice.tsx index b65da3f81c..e8213325e6 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/useContextMenuDismiss.ts b/packages/studio/src/hooks/useContextMenuDismiss.ts new file mode 100644 index 0000000000..c6d04579a6 --- /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/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index b9ae70e133..56d2833953 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) && ["video", "audio", "img"].includes(element.tag); 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 593f27f61b..8f16cceec2 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 0000000000..25b5cc58d5 --- /dev/null +++ b/packages/studio/src/player/components/PlayheadIndicator.tsx @@ -0,0 +1,43 @@ +// fallow-ignore-file dead-code +/** + * 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/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts new file mode 100644 index 0000000000..9202acc304 --- /dev/null +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -0,0 +1,44 @@ +// fallow-ignore-file code-duplication +// fallow-ignore-file dead-code +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 9cd55cc431..58ec3d709e 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 0000000000..f4b3e74399 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineZoom.ts @@ -0,0 +1,18 @@ +// fallow-ignore-file dead-code +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/utils/timelineElementSplit.ts b/packages/studio/src/utils/timelineElementSplit.ts new file mode 100644 index 0000000000..1ebb91037a --- /dev/null +++ b/packages/studio/src/utils/timelineElementSplit.ts @@ -0,0 +1,13 @@ +import type { TimelineElement } from "../player/store/playerStore"; + +export { buildPatchTarget, readFileContent } from "../hooks/timelineEditingHelpers"; + +export function canSplitElement(el: TimelineElement): boolean { + return ( + !el.timelineLocked && + el.timingSource !== "implicit" && + !el.compositionSrc && + !!el.duration && + Number.isFinite(el.duration) + ); +}