diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index f2db23a3d..ffd9f61a8 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -1830,6 +1830,47 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0); expect(result.after).not.toContain("data-hf-studio-rotation"); }); + it("replace-with-keyframes preserves per-segment easing for exact temporal keyframes", async () => { + const projectDir = createProjectDir(); + const PATH_COMP = ` +
+ +`; + writeHtml(projectDir, "path.html", PATH_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "path.html"); + const res = await app.request("http://localhost/projects/demo/gsap-mutations/path.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: "#box", + position: 12.17, + duration: 16.055, + keyframes: [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 23.2, properties: { x: 25, y: 30 } }, + { percentage: 100, properties: { x: 100, y: 100 } }, + ], + ease: "none", + }), + }); + const result = (await res.json()) as { ok: boolean; after: string }; + + expect(res.status).toBe(200); + expect(result.ok).toBe(true); + expect(result.after).toContain('"23.2%"'); + expect(result.after).toContain('easeEach: "power1.inOut"'); + expect(result.after).toContain('ease: "none"'); + expect(result.after).not.toContain("motionPath"); + }); + it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => { const projectDir = createProjectDir(); writeComp(projectDir, "scene.html", TEMPLATE_COMP); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 01e7c3f84..3ec64bbff 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -997,6 +997,7 @@ export type GsapMutationRequest = auto?: boolean; }>; ease?: string; + easeEach?: string; } | { type: "split-animations"; @@ -1052,6 +1053,18 @@ export type GsapMutationRequest = type GsapMutationResult = string | { script: string; skippedSelectors: string[] }; +function resolveReplacementEaseEach( + scriptText: string, + request: { animationId: string; easeEach?: string }, +): string | undefined { + if (request.easeEach !== undefined) return request.easeEach; + const original = parseGsapScriptAcorn(scriptText).animations.find( + (animation) => animation.id === request.animationId, + ); + if (!original?.arcPath?.enabled) return undefined; + return original?.keyframes?.easeEach ?? original?.ease; +} + // Mutations that can change a position tween's first keyframe (value/existence/timing) // and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards. // `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts @@ -1507,6 +1520,7 @@ function executeGsapMutationAcorn( body.duration, body.keyframes, body.ease, + resolveReplacementEaseEach(block.scriptText, body), ); return added.script; } @@ -1877,6 +1891,7 @@ async function executeGsapMutationRecast( body.duration, body.keyframes, body.ease, + resolveReplacementEaseEach(block.scriptText, body), ); return added.script; } diff --git a/packages/studio/src/components/TimelineToolbar.test.tsx b/packages/studio/src/components/TimelineToolbar.test.tsx index 0c21e5e04..0050dcfc9 100644 --- a/packages/studio/src/components/TimelineToolbar.test.tsx +++ b/packages/studio/src/components/TimelineToolbar.test.tsx @@ -2,8 +2,10 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { usePlayerStore } from "../player/store/playerStore"; +import { makeSelection } from "../hooks/domSelectionTestHarness"; import { TimelineToolbar } from "./TimelineToolbar"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -13,12 +15,14 @@ afterEach(() => { usePlayerStore.setState({ autoKeyframeEnabled: true }); }); -function renderToolbar() { +function renderToolbar( + domEditSession?: React.ComponentProps["domEditSession"], +) { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { - root.render(); + root.render(); }); return { host, root }; } @@ -54,3 +58,44 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => { act(() => root.unmount()); }); }); +describe("TimelineToolbar — motion path endpoints", () => { + it("does not advertise a destructive keyframe toggle for a required endpoint", () => { + usePlayerStore.setState({ currentTime: 10 }); + const animation: GsapAnimation = { + id: "#el-to-0-position", + targetSelector: "#el", + method: "to", + position: 0, + duration: 10, + properties: {}, + keyframes: { + format: "object-array", + keyframes: [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 100, properties: { x: 100, y: 0 } }, + ], + }, + arcPath: { + enabled: true, + autoRotate: false, + segments: [{ curviness: 1 }], + }, + }; + const element = document.createElement("div"); + element.id = "el"; + const session = { + domEditSelection: makeSelection("Element", element), + selectedGsapAnimations: [animation], + handleGsapAddAnimation: vi.fn(), + handleGsapConvertToKeyframes: vi.fn(), + handleGsapRemoveKeyframe: vi.fn(), + } satisfies NonNullable["domEditSession"]>; + + const { host, root } = renderToolbar(session); + const button = host.querySelector( + 'button[aria-label="Motion path endpoint"]', + ); + expect(button?.disabled).toBe(true); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 1e314ed4e..43416414d 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -36,6 +36,60 @@ interface TimelineToolbarProps { onSplitElement?: (element: TimelineElement, splitTime: number) => void; } +interface KeyframeToggleState { + state: "active" | "inactive" | "none"; + isMotionPath: boolean; + pathEndpoint: boolean; + willExtend: boolean; +} + +const NO_KEYFRAME_TOGGLE: KeyframeToggleState = { + state: "none", + isMotionPath: false, + pathEndpoint: false, + willExtend: false, +}; + +function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean { + if (!animation?.keyframes) return false; + const keyframes = animation.keyframes.keyframes; + return ( + Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= 1 || + Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= 1 + ); +} + +function resolveKeyframeToggleState( + session: DomEditSessionSlice | undefined, + currentTime: number, +): KeyframeToggleState { + if (!session?.domEditSelection) return NO_KEYFRAME_TOGGLE; + const arcAnimation = session.selectedGsapAnimations.find( + (animation) => animation.arcPath && animation.keyframes, + ); + const animation = + arcAnimation ?? + session.selectedGsapAnimations.find((candidate) => candidate.keyframes && !candidate.arcPath); + if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE; + + const isMotionPath = Boolean(arcAnimation); + if (!isPlayheadWithinTween(animation, currentTime)) { + return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true }; + } + + const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation); + const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage); + const active = animation.keyframes.keyframes.some( + (keyframe) => Math.abs(keyframe.percentage - percentage) <= 1, + ); + return { + state: pathEndpoint ? "none" : active ? "active" : "inactive", + isMotionPath, + pathEndpoint, + willExtend: false, + }; +} + function useKeyframeToggle(session?: DomEditSessionSlice) { const currentTime = usePlayerStore((s) => s.currentTime); const sessionRef = useRef(session); @@ -45,31 +99,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { sessionRef as React.RefObject, ); - if (!session) return { state: "none" as const, onToggle: undefined }; + const toggleState = resolveKeyframeToggleState(session, currentTime); - const sel = session.domEditSelection; - const anims = session.selectedGsapAnimations; - const kfAnim = anims.find((a) => a.keyframes); - - let state: "active" | "inactive" | "none" = "none"; - // Outside the tween, clicking extends the animation to the playhead rather than - // toggling a (clamped) edge keyframe — so the button stays an "add" affordance. - let willExtend = false; - if (kfAnim?.keyframes && sel) { - if (!isPlayheadWithinTween(kfAnim, currentTime)) { - state = "inactive"; - willExtend = true; - } else { - // Tween-relative percentage (not the clip range) so the button state matches - // where the keyframe would actually land. - const pct = computeElementPercentage(currentTime, sel, kfAnim); - state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1) - ? "active" - : "inactive"; - } - } - - return { state, willExtend, onToggle: sel ? onToggle : undefined }; + return { + ...toggleState, + onToggle: session?.domEditSelection && !toggleState.pathEndpoint ? onToggle : undefined, + }; } // fallow-ignore-next-line complexity @@ -91,6 +126,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent); const { state: keyframeState, + isMotionPath: keyframeIsMotionPath, + pathEndpoint: keyframePathEndpoint, willExtend: keyframeWillExtend, onToggle: onToggleKeyframe, } = useKeyframeToggle(domEditSession); @@ -180,15 +217,23 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool // toolbar layout never shifts. - )} + )} @@ -324,12 +339,13 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ const kfKey = timelineKeyframeSelectionKey(elementId, target); // While dragging this diamond, render it at the live preview clip-%. const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage; - // Center the diamond ON its keyframe %: left = (% · width) − half, so the - // diamond's midpoint sits exactly on the playhead/ruler x for that time. + // Center the marker's non-overlapping hit region ON its keyframe %, so + // the diamond's midpoint sits exactly on the playhead/ruler x for that time. // The 0% diamond's left half lands in the reserved left gutter (the // content origin is inset past the label column, Figma-style) so it stays // fully visible instead of being clipped by the sticky label column. - const leftPx = (renderPct / 100) * clipWidthPx - half; + const marker = markerMetrics[i]!; + const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2; const isKfSelected = selectedKeyframes.has(kfKey); const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; const isHighlighted = isKfSelected || atPlayhead; @@ -480,7 +496,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ left: leftPx, top: centerY, transform: "translateY(-50%)", - width: diamondSize, + width: marker.hitWidth, height: diamondSize, zIndex: isHighlighted ? 2 : 1, pointerEvents: "auto", @@ -489,6 +505,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ cursor: canDrag ? "ew-resize" : "pointer", padding: 0, touchAction: "none", + display: "flex", + alignItems: "center", + justifyContent: "center", + overflow: "visible", }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} @@ -510,7 +530,12 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({ }} title={`${kf.percentage}%`} > - + {isKfSelected && ( { - segment.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); - }); return segment.querySelector("button[data-keyframe-ease-button]"); } @@ -281,7 +275,7 @@ describe("TimelinePropertyLanes", () => { act(() => root.unmount()); }); - it("reveals one midpoint ease button per segment on hover, regardless of selection", () => { + it("keeps one accessible midpoint ease button per segment, regardless of selection", () => { const animations = [ animation("position-tween", "position", [ { percentage: 0, properties: { x: 0 } }, @@ -295,12 +289,15 @@ describe("TimelinePropertyLanes", () => { expect(segments).toHaveLength(2); expect(segments.map((segment) => segment.style.left)).toEqual(["0px", "100px"]); expect(laneDiamonds(host, "position")).toHaveLength(3); - // Resting state: no button until a segment is hovered. - expect(laneEaseButtons(host, "position")).toHaveLength(0); - - // Hovering reveals exactly one button — the hovered segment's. - expect(revealEaseButton(segments[0]!)).not.toBeNull(); - expect(laneEaseButtons(host, "position")).toHaveLength(1); + const buttons = laneEaseButtons(host, "position"); + expect(buttons).toHaveLength(2); + expect(buttons.every((button) => button.classList.contains("opacity-0"))).toBe(true); + expect(buttons.every((button) => button.classList.contains("group-hover:opacity-100"))).toBe( + true, + ); + expect(buttons.every((button) => button.classList.contains("focus-visible:opacity-100"))).toBe( + true, + ); // The ease button is available on hover even when the element is NOT selected // (a lane shows for the track's active/primary clip, not only the selected one). diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts index 204302e4b..969cc0ab3 100644 --- a/packages/studio/src/player/components/timelineCallbacks.ts +++ b/packages/studio/src/player/components/timelineCallbacks.ts @@ -73,9 +73,9 @@ export interface TimelineEditCallbacks { onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise | void; onRazorSplitAll?: (splitTime: number) => Promise | void; onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void; - onDeleteAllKeyframes?: (elementId: string) => void; + onDeleteAllKeyframes?: (element: TimelineElement) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; - onMoveKeyframeToPlayhead?: (elementId: string, keyframe: TimelineKeyframeTarget) => void; + onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void; /** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage * is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */ onMoveKeyframe?: ( diff --git a/packages/studio/src/player/components/timelineKeyframeIdentity.test.ts b/packages/studio/src/player/components/timelineKeyframeIdentity.test.ts new file mode 100644 index 000000000..5b774715a --- /dev/null +++ b/packages/studio/src/player/components/timelineKeyframeIdentity.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + timelineKeyframeSelectionKey, + timelineKeyframeTargetFromSelectionKey, +} from "./timelineKeyframeIdentity"; + +describe("timeline keyframe selection identity", () => { + it("round-trips an expanded lane with colon-bearing identities", () => { + const key = timelineKeyframeSelectionKey("comp#a:child", { + percentage: 75, + tweenPercentage: 40, + propertyGroup: "position", + animationId: "child:position", + }); + + expect(timelineKeyframeTargetFromSelectionKey("comp#a:child", key)).toEqual({ + percentage: 75, + tweenPercentage: 40, + propertyGroup: "position", + animationId: "child:position", + }); + }); + + it("does not confuse an expanded lane whose element id extends the active id", () => { + const key = timelineKeyframeSelectionKey("comp#a:child", { + percentage: 75, + tweenPercentage: 40, + propertyGroup: "position", + animationId: "child-position", + }); + + expect(timelineKeyframeTargetFromSelectionKey("comp#a", key)).toBeNull(); + }); + + it("retains the collapsed key fallback and rejects malformed percentages", () => { + expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:30")).toEqual({ + percentage: 30, + }); + expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#a:NaN")).toBeNull(); + expect(timelineKeyframeTargetFromSelectionKey("comp#a", "comp#b:30")).toBeNull(); + }); +}); diff --git a/packages/studio/src/player/components/timelineKeyframeIdentity.ts b/packages/studio/src/player/components/timelineKeyframeIdentity.ts index 8cf58d711..c9b761784 100644 --- a/packages/studio/src/player/components/timelineKeyframeIdentity.ts +++ b/packages/studio/src/player/components/timelineKeyframeIdentity.ts @@ -10,8 +10,50 @@ export function timelineKeyframeSelectionKey( target: TimelineKeyframeTarget, ): string { if (!target.propertyGroup) return `${elementId}:${target.percentage}`; - const groupKey = target.animationId - ? `${target.propertyGroup}:${target.animationId}` - : target.propertyGroup; - return `${elementId}:${groupKey}:${target.percentage}`; + return JSON.stringify([ + elementId, + target.propertyGroup, + target.animationId ?? "", + target.percentage, + target.tweenPercentage ?? target.percentage, + ]); +} + +export function timelineKeyframeTargetFromSelectionKey( + elementId: string, + key: string, +): TimelineKeyframeTarget | null { + if (key.startsWith("[")) { + let decoded: unknown; + try { + decoded = JSON.parse(key); + } catch { + return null; + } + if (!Array.isArray(decoded) || decoded.length !== 5) return null; + const [selectedElementId, propertyGroup, animationId, percentage, tweenPercentage] = decoded; + if ( + selectedElementId !== elementId || + typeof propertyGroup !== "string" || + propertyGroup.length === 0 || + typeof animationId !== "string" || + typeof percentage !== "number" || + !Number.isFinite(percentage) || + typeof tweenPercentage !== "number" || + !Number.isFinite(tweenPercentage) + ) { + return null; + } + return { + propertyGroup, + animationId: animationId || undefined, + percentage, + tweenPercentage, + }; + } + + const separator = key.lastIndexOf(":"); + if (separator < 0 || key.slice(0, separator) !== elementId) return null; + const percentage = Number(key.slice(separator + 1)); + return Number.isFinite(percentage) ? { percentage } : null; } diff --git a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts index e697d4927..2d18bace1 100644 --- a/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts +++ b/packages/studio/src/player/components/useTimelineKeyframeHandlers.ts @@ -77,10 +77,9 @@ export function useTimelineKeyframeHandlers({ const onContextMenuKeyframe = useCallback( (e: ReactMouseEvent, elId: string, target: TimelineKeyframeTarget) => { const el = expandedElements.find((item) => (item.key ?? item.id) === elId); - if (el) { - setSelectedElementId(elId); - onSelectElement?.(el); - } + if (!el) return; + setSelectedElementId(elId); + onSelectElement?.(el); const kfData = keyframeCache.get(elId); const kf = kfData?.keyframes.find( (item) => Math.abs(item.percentage - target.percentage) < 0.2, @@ -93,6 +92,7 @@ export function useTimelineKeyframeHandlers({ tweenPercentage: target.tweenPercentage ?? kf?.tweenPercentage, propertyGroup: target.propertyGroup, animationId: target.animationId, + element: el, currentEase: kf?.ease ?? kfData?.ease, }); }, diff --git a/packages/studio/src/utils/globalTimeCompiler.test.ts b/packages/studio/src/utils/globalTimeCompiler.test.ts index 963968f09..ddf85b82d 100644 --- a/packages/studio/src/utils/globalTimeCompiler.test.ts +++ b/packages/studio/src/utils/globalTimeCompiler.test.ts @@ -31,6 +31,12 @@ describe("absoluteToPercentage", () => { expect(absoluteToPercentage(1.0, 0.5, 1)).toBe(50); }); + test("preserves playhead timing beyond tenths of a percent", () => { + const percentage = absoluteToPercentage(17, 12.17, 20); + expect(percentage).toBe(24.15); + expect(percentageToAbsolute(percentage, 12.17, 20)).toBe(17); + }); + test("clamps below tween start to 0%", () => { expect(absoluteToPercentage(-1, 0, 2)).toBe(0); }); @@ -106,6 +112,10 @@ describe("resolveTweenDuration", () => { test("missing duration defaults to GSAP default (0.5)", () => { expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(0.5); }); + + test("missing duration can use its editor timing basis", () => { + expect(resolveTweenDuration(makeAnim({ duration: undefined }), 16.26)).toBe(16.26); + }); }); describe("findTweenAtTime", () => { diff --git a/packages/studio/src/utils/globalTimeCompiler.ts b/packages/studio/src/utils/globalTimeCompiler.ts index 3f050f925..5d158379f 100644 --- a/packages/studio/src/utils/globalTimeCompiler.ts +++ b/packages/studio/src/utils/globalTimeCompiler.ts @@ -7,7 +7,7 @@ export function absoluteToPercentage( ): number { if (tweenDuration <= 0) return 0; const raw = ((time - tweenStart) / tweenDuration) * 100; - return Math.max(0, Math.min(100, Math.round(raw * 10) / 10)); + return Math.max(0, Math.min(100, Math.round(raw * 1000) / 1000)); } export function percentageToAbsolute( @@ -34,8 +34,8 @@ export function resolveTweenStart(animation: GsapAnimation): number | null { return null; } -export function resolveTweenDuration(animation: GsapAnimation): number { - return animation.duration ?? 0.5; +export function resolveTweenDuration(animation: GsapAnimation, fallback = 0.5): number { + return animation.duration ?? fallback; } export function findTweenAtTime( diff --git a/packages/studio/src/utils/keyframeSelection.test.ts b/packages/studio/src/utils/keyframeSelection.test.ts deleted file mode 100644 index 6d8f91e77..000000000 --- a/packages/studio/src/utils/keyframeSelection.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { selectedKeyframePercentagesForElement } from "./keyframeSelection"; - -describe("selectedKeyframePercentagesForElement", () => { - it("returns the percentages of keyframes on the active element", () => { - const selected = new Set(["comp#a:25", "comp#a:75"]); - expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([25, 75]); - }); - - it("drops keyframes that belong to other elements", () => { - // The bug: a stale shift-selection on `comp#b` would otherwise have its - // percentages applied to the now-active `comp#a`, deleting the wrong keyframes. - const selected = new Set(["comp#a:25", "comp#b:50", "comp#b:80"]); - expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([25]); - }); - - it("returns nothing when no key belongs to the active element", () => { - const selected = new Set(["comp#b:50"]); - expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([]); - }); - - it("returns nothing when there is no active element", () => { - const selected = new Set(["comp#a:25"]); - expect(selectedKeyframePercentagesForElement(selected, null)).toEqual([]); - }); - - it("returns nothing for an empty selection", () => { - expect(selectedKeyframePercentagesForElement(new Set(), "comp#a")).toEqual([]); - }); - - it("splits on the final colon so element ids containing ':' still match", () => { - const selected = new Set(["a:b:40"]); - expect(selectedKeyframePercentagesForElement(selected, "a:b")).toEqual([40]); - }); - - it("skips keys without a percentage separator", () => { - const selected = new Set(["comp#a"]); - expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([]); - }); - - it("skips keys whose percentage is not a finite number", () => { - const selected = new Set(["comp#a:abc", "comp#a:NaN", "comp#a:30"]); - expect(selectedKeyframePercentagesForElement(selected, "comp#a")).toEqual([30]); - }); -}); diff --git a/packages/studio/src/utils/keyframeSelection.ts b/packages/studio/src/utils/keyframeSelection.ts deleted file mode 100644 index 3ecaa36b9..000000000 --- a/packages/studio/src/utils/keyframeSelection.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Resolves which keyframe percentages a bulk operation should act on. - * - * `selectedKeyframes` holds `":"` keys and can contain - * keyframes from more than one element — e.g. a shift-selection made before the - * active element changed (via a keyframe click, a clip click, the layers panel, - * or the keyframe context menu). A bulk delete only targets the active - * element's animation, so keys belonging to other elements must be dropped; - * otherwise their percentages get applied to the active element and remove - * keyframes the user never selected on it. - * - * The element id is everything before the final `:` so element ids that happen - * to contain `:` are handled correctly. - */ -export function selectedKeyframePercentagesForElement( - selectedKeyframes: ReadonlySet, - activeElementId: string | null, -): number[] { - if (!activeElementId) return []; - const percentages: number[] = []; - for (const key of selectedKeyframes) { - const separator = key.lastIndexOf(":"); - if (separator < 0) continue; - if (key.slice(0, separator) !== activeElementId) continue; - const percentage = Number(key.slice(separator + 1)); - if (Number.isFinite(percentage)) percentages.push(percentage); - } - return percentages; -} diff --git a/packages/studio/tests/e2e/fixtures/design-panel-qa/index.html b/packages/studio/tests/e2e/fixtures/design-panel-qa/index.html index b5e375401..6e325b47f 100644 --- a/packages/studio/tests/e2e/fixtures/design-panel-qa/index.html +++ b/packages/studio/tests/e2e/fixtures/design-panel-qa/index.html @@ -232,7 +232,7 @@ const tl = gsap.timeline({ paused: true }); tl.to("#qa-tween-box", { x: 300, rotation: 90, duration: 2, ease: "power1.inOut" }, 0); tl.to( - "#qa-keyframe-box", + "#qa-zone-keyframe", { keyframes: [ { x: 0, y: 0, duration: 1 },