fix(studio): retime the dragged element's own keyframe

Drag-to-retime resolved the dragged diamond against the selected element's
animations and committed through the selected element's DOM selection, so
dragging a diamond on a non-selected clip retimed the wrong tween. It now
resolves against the clicked element's animations and commits through that
element's selection, matching the delete path.

The three diamond callbacks also take the TimelineKeyframeTarget they already
had instead of five positional fields, and the two copies of the
sourceFile#domId split share splitTimelineElementKey.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-27 19:52:07 +02:00
parent 3f93794fa6
commit 8f66b06d12
8 changed files with 166 additions and 95 deletions
@@ -167,7 +167,16 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
const view = renderCallbacks(); const view = renderCallbacks();
act(() => { act(() => {
view.callbacks.onMoveKeyframe?.("box", 0, 25, "position", 0, flatAnimation.id); view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: flatAnimation.id,
},
25,
);
}); });
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled(); expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
@@ -189,13 +198,12 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
const view = renderCallbacks(); const view = renderCallbacks();
await act(async () => { await act(async () => {
view.callbacks.onDeleteKeyframe?.( view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
"scenes/main.html#circle", percentage: 0,
0, propertyGroup: "position",
"position", tweenPercentage: 0,
0, animationId: otherFlatAnimation.id,
otherFlatAnimation.id, });
);
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
}); });
@@ -223,13 +231,12 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
const view = renderCallbacks(); const view = renderCallbacks();
await act(async () => { await act(async () => {
view.callbacks.onDeleteKeyframe?.( view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", {
"scenes/main.html#circle", percentage: 100,
100, propertyGroup: "position",
"position", tweenPercentage: 100,
100, animationId: otherKeyframedAnimation.id,
otherKeyframedAnimation.id, });
);
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
}); });
@@ -287,7 +294,7 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
const view = renderCallbacks(); const view = renderCallbacks();
act(() => { act(() => {
view.callbacks.onMoveKeyframeToPlayhead?.("scenes/main.html#circle", 100); view.callbacks.onMoveKeyframeToPlayhead?.("scenes/main.html#circle", { percentage: 100 });
}); });
expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith( expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith(
@@ -301,7 +308,12 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
const view = renderCallbacks(); const view = renderCallbacks();
act(() => { act(() => {
view.callbacks.onDeleteKeyframe?.("box", 0, "position", 0, flatAnimation.id); view.callbacks.onDeleteKeyframe?.("box", {
percentage: 0,
propertyGroup: "position",
tweenPercentage: 0,
animationId: flatAnimation.id,
});
}); });
expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(flatAnimation.id); expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(flatAnimation.id);
@@ -371,7 +383,12 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
const view = renderCallbacks(); const view = renderCallbacks();
act(() => { act(() => {
view.callbacks.onDeleteKeyframe?.("box", 50, "position", 50, flatAnimation.id); view.callbacks.onDeleteKeyframe?.("box", {
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
animationId: flatAnimation.id,
});
}); });
expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50); expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50);
@@ -379,16 +396,74 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
view.unmount(); view.unmount();
}); });
it("keeps an authored interior drag on the per-keyframe move path", () => { it("keeps an authored interior drag on the per-keyframe move path", async () => {
mocks.animations = [authoredInteriorAnimation()]; const authored = authoredInteriorAnimation();
mocks.animations = [authored];
usePlayerStore.setState({ gsapAnimations: new Map([["box", [authored]]]) });
const view = renderCallbacks(); const view = renderCallbacks();
act(() => { await act(async () => {
view.callbacks.onMoveKeyframe?.("box", 50, 75, "position", 50, flatAnimation.id); await view.callbacks.onMoveKeyframe?.(
"box",
{
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
animationId: authored.id,
},
75,
);
}); });
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50, 75); expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
authored.id,
50,
75,
mocks.selection,
);
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled(); expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
view.unmount(); view.unmount();
}); });
// A drag starts on whatever diamond the pointer is over, which need not be the
// selected element. Resolving against the selection would retime the selected
// element's tween and commit it through the selected element's file.
it("retimes a non-selected element's keyframe through that element's own selection", async () => {
const circle: TimelineElement = {
...element,
id: "circle",
key: "scenes/main.html#circle",
domId: "circle",
sourceFile: "scenes/main.html",
};
const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" };
const circleAnimation = { ...authoredInteriorAnimation(), id: "circle-to-0-position" };
usePlayerStore.setState({
elements: [element, circle],
gsapAnimations: new Map([["scenes/main.html#circle", [circleAnimation]]]),
});
mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection);
const view = renderCallbacks();
await act(async () => {
await view.callbacks.onMoveKeyframe?.(
"scenes/main.html#circle",
{
percentage: 50,
propertyGroup: "position",
tweenPercentage: 50,
animationId: circleAnimation.id,
},
75,
);
});
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
circleAnimation.id,
50,
75,
circleSelection,
);
view.unmount();
});
}); });
@@ -14,6 +14,8 @@ import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
import { resolveKeyframeRetime } from "../editor/keyframeRetime"; import { resolveKeyframeRetime } from "../editor/keyframeRetime";
import type { DomEditSelection } from "../editor/domEditingTypes"; import type { DomEditSelection } from "../editor/domEditingTypes";
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter"; import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
import { splitTimelineElementKey } from "../../player/lib/timelineElementHelpers";
import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity";
export interface TimelineEditCallbackDeps { export interface TimelineEditCallbackDeps {
handleTimelineElementMove: ( handleTimelineElementMove: (
@@ -117,14 +119,12 @@ export function useTimelineEditCallbacks({
const resolveElementAnimations = useCallback( const resolveElementAnimations = useCallback(
(elementKey: string): GsapAnimation[] => { (elementKey: string): GsapAnimation[] => {
const { gsapAnimations } = usePlayerStore.getState(); const { gsapAnimations } = usePlayerStore.getState();
const hashIndex = elementKey.lastIndexOf("#"); const { sourceFile, domId } = splitTimelineElementKey(elementKey);
const elementId = hashIndex === -1 ? elementKey : elementKey.slice(hashIndex + 1); const scope = sourceFile ?? activeCompPath ?? "index.html";
const sourceFile =
hashIndex === -1 ? (activeCompPath ?? "index.html") : elementKey.slice(0, hashIndex);
return ( return (
gsapAnimations.get(`${sourceFile}#${elementId}`) ?? gsapAnimations.get(`${scope}#${domId}`) ??
gsapAnimations.get(`index.html#${elementId}`) ?? gsapAnimations.get(`index.html#${domId}`) ??
gsapAnimations.get(elementId) ?? gsapAnimations.get(domId) ??
[] []
); );
}, },
@@ -136,19 +136,15 @@ export function useTimelineEditCallbacks({
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the // diamond reports a clip-% but the script ops key on the tween-%. Prefers the
// anim in the keyframe's property group, falling back to the first keyframed one. // anim in the keyframe's property group, falling back to the first keyframed one.
const resolveKeyframeTarget = useCallback( const resolveKeyframeTarget = useCallback(
// fallow-ignore-next-line complexity
( (
pct: number, target: TimelineKeyframeTarget,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
animations: GsapAnimation[] = selectedGsapAnimations, animations: GsapAnimation[] = selectedGsapAnimations,
elementKey?: string, elementKey?: string,
): { animId: string; tweenPct: number } | null => { ): { animId: string; tweenPct: number } | null => {
const explicitTarget = const carriesIdentity =
propertyGroup !== undefined || tweenPercentage !== undefined || animationId !== undefined target.propertyGroup !== undefined ||
? [{ percentage: pct, propertyGroup, tweenPercentage, animationId }] target.tweenPercentage !== undefined ||
: undefined; target.animationId !== undefined;
// The clicked element's own cache when the caller knows it: the diamond // The clicked element's own cache when the caller knows it: the diamond
// context menu can open on an element that is not the selected one, and // context menu can open on an element that is not the selected one, and
// reading the selection's cache there resolves against the wrong element. // reading the selection's cache there resolves against the wrong element.
@@ -156,8 +152,8 @@ export function useTimelineEditCallbacks({
.getState() .getState()
.keyframeCache.get(elementKey ?? domEditSelection?.id ?? ""); .keyframeCache.get(elementKey ?? domEditSelection?.id ?? "");
return resolveTimelineKeyframeTarget( return resolveTimelineKeyframeTarget(
pct, target.percentage,
explicitTarget ?? cached?.keyframes ?? [], carriesIdentity ? [target] : (cached?.keyframes ?? []),
animations, animations,
); );
}, },
@@ -202,9 +198,9 @@ export function useTimelineEditCallbacks({
if (!anim) return; if (!anim) return;
handleGsapRemoveAllKeyframes(anim.id); handleGsapRemoveAllKeyframes(anim.id);
}, },
onDeleteKeyframe: (elId, pct, group, tweenPct, animationId) => { onDeleteKeyframe: (elId, keyframe) => {
const animations = resolveElementAnimations(elId); const animations = resolveElementAnimations(elId);
const target = resolveKeyframeTarget(pct, group, tweenPct, animationId, animations, elId); const target = resolveKeyframeTarget(keyframe, animations, elId);
if (!target) return; if (!target) return;
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId); const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
if (!element) { if (!element) {
@@ -219,15 +215,8 @@ export function useTimelineEditCallbacks({
}); });
}, },
// Retime the keyframe to the playhead, preserving its value + ease. // Retime the keyframe to the playhead, preserving its value + ease.
onMoveKeyframeToPlayhead: (elId, pct, group, tweenPct, animationId) => { onMoveKeyframeToPlayhead: (elId, keyframe) => {
const target = resolveKeyframeTarget( const target = resolveKeyframeTarget(keyframe, resolveElementAnimations(elId), elId);
pct,
group,
tweenPct,
animationId,
resolveElementAnimations(elId),
elId,
);
if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct); if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct);
}, },
// Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives // Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives
@@ -238,11 +227,17 @@ export function useTimelineEditCallbacks({
// resizes the tween — position/duration grow so the dragged keyframe lands at // resizes the tween — position/duration grow so the dragged keyframe lands at
// the drop while every other keyframe keeps its absolute time (value+ease too). // the drop while every other keyframe keeps its absolute time (value+ease too).
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
onMoveKeyframe: async (_elId, fromClipPct, toClipPct, group, tweenPct, animationId) => { onMoveKeyframe: async (elId, keyframe, toClipPct) => {
const target = resolveKeyframeTarget(fromClipPct, group, tweenPct, animationId); const animations = resolveElementAnimations(elId);
const sel = domEditSelection; const target = resolveKeyframeTarget(keyframe, animations, elId);
if (!target || !sel) return false; if (!target) return false;
const anim = selectedGsapAnimations.find((a) => a.id === target.animId); // The dragged diamond's OWN element, not the selected one: a drag on a
// non-selected clip has to read that clip's animations and commit
// through that clip's selection, or it retimes whatever is selected.
const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId);
const sel = element ? await buildDomSelectionForTimelineElement(element) : domEditSelection;
if (!sel) return false;
const anim = animations.find((a) => a.id === target.animId);
const tweenStart = anim ? resolveTweenStart(anim) : null; const tweenStart = anim ? resolveTweenStart(anim) : null;
if (!anim || tweenStart === null) return false; if (!anim || tweenStart === null) return false;
// Synthesized flat endpoints are clip boundaries, not authored keyframes. // Synthesized flat endpoints are clip boundaries, not authored keyframes.
@@ -267,7 +262,7 @@ export function useTimelineEditCallbacks({
dropAbsTime, dropAbsTime,
}); });
if (decision.kind === "move" && decision.toTweenPct != null) { if (decision.kind === "move" && decision.toTweenPct != null) {
handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct); handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
} else if ( } else if (
decision.kind === "resize" && decision.kind === "resize" &&
decision.pctRemap && decision.pctRemap &&
@@ -280,15 +275,17 @@ export function useTimelineEditCallbacks({
decision.position, decision.position,
decision.duration, decision.duration,
decision.pctRemap, decision.pctRemap,
sel,
); );
} else { } else {
// resize-keyframed-tween requires an authored `keyframes` AST node // resize-keyframed-tween requires an authored `keyframes` AST node
// and intentionally no-ops for a flat tween. Update its real tween // and intentionally no-ops for a flat tween. Update its real tween
// window through the metadata writer (and SDK cutover path) instead. // window through the metadata writer (and SDK cutover path) instead.
handleGsapUpdateMeta(target.animId, { handleGsapUpdateMeta(
position: decision.position, target.animId,
duration: decision.duration, { position: decision.position, duration: decision.duration },
}); sel,
);
} }
} else { } else {
return false; return false;
@@ -48,12 +48,13 @@ interface TimelineClipDiamondsProps {
onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
/** Drag-to-retime: move a keyframe to a new time, preserving its value + ease. /** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
* Both percentages are clip-relative: `fromClipPercentage` identifies the * `keyframe` identifies the dragged keyframe (clip-relative percentage plus
* dragged keyframe, `toClipPercentage` is the neighbour-clamped drop position. * whatever animation identity the row carries); `toClipPercentage` is the
* The handler decides move (within the tween) vs resize (past its boundary). */ * neighbour-clamped drop position, also clip-relative. The handler decides
* move (within the tween) vs resize (past its boundary). */
onMoveKeyframe?: ( onMoveKeyframe?: (
elementId: string, elementId: string,
fromClipPercentage: number, keyframe: TimelineKeyframeTarget,
toClipPercentage: number, toClipPercentage: number,
) => Promise<boolean>; ) => Promise<boolean>;
/** Open the segment ease editor for the hovered mid-point button — available on /** Open the segment ease editor for the hovered mid-point button — available on
@@ -549,7 +550,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds(
onMoveKeyframe={ onMoveKeyframe={
props.onMoveKeyframe props.onMoveKeyframe
? (target, toClipPercentage) => ? (target, toClipPercentage) =>
props.onMoveKeyframe?.(props.elementId, target.percentage, toClipPercentage) ?? props.onMoveKeyframe?.(props.elementId, target, toClipPercentage) ??
Promise.resolve(false) Promise.resolve(false)
: undefined : undefined
} }
@@ -3,6 +3,7 @@ import { Eye, EyeSlash } from "@phosphor-icons/react";
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
import { TimelineClip } from "./TimelineClip"; import { TimelineClip } from "./TimelineClip";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
import type { TimelineTheme } from "./timelineTheme"; import type { TimelineTheme } from "./timelineTheme";
@@ -76,7 +77,7 @@ export interface TimelineLaneBaseProps {
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
onMoveKeyframe?: ( onMoveKeyframe?: (
elementId: string, elementId: string,
fromClipPercentage: number, keyframe: TimelineKeyframeTarget,
toClipPercentage: number, toClipPercentage: number,
) => Promise<boolean>; ) => Promise<boolean>;
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
@@ -106,12 +106,12 @@ export function TimelineOverlays({
<KeyframeDiamondContextMenu <KeyframeDiamondContextMenu
state={kfContextMenu} state={kfContextMenu}
onClose={() => setKfContextMenu(null)} onClose={() => setKfContextMenu(null)}
onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)} onDelete={(elId, pct) => onDeleteKeyframe?.(elId, { percentage: pct })}
onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)} onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)} onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
onMoveToPlayhead={ onMoveToPlayhead={
onMoveKeyframeToPlayhead onMoveKeyframeToPlayhead
? (elId, pct) => onMoveKeyframeToPlayhead(elId, pct) ? (elId, pct) => onMoveKeyframeToPlayhead(elId, { percentage: pct })
: undefined : undefined
} }
onCopyProperties={(elId, pct) => { onCopyProperties={(elId, pct) => {
@@ -4,6 +4,7 @@ import type { TimelineElement } from "../store/playerStore";
import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter"; import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
import type { BlockedTimelineEditIntent } from "./timelineEditing"; import type { BlockedTimelineEditIntent } from "./timelineEditing";
import type { PropertyGroupName } from "@hyperframes/core/gsap-parser"; import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
export interface TimelinePropertyGroupKeyframeToggle { export interface TimelinePropertyGroupKeyframeToggle {
animationId: string; animationId: string;
@@ -71,29 +72,16 @@ export interface TimelineEditCallbacks {
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void; onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void; onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplitAll?: (splitTime: number) => Promise<void> | void; onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
onDeleteKeyframe?: ( onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
elementId: string,
percentage: number,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
) => void;
onDeleteAllKeyframes?: (elementId: string) => void; onDeleteAllKeyframes?: (elementId: string) => void;
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void; onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
onMoveKeyframeToPlayhead?: ( onMoveKeyframeToPlayhead?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
elementId: string, /** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
percentage: number, * is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
) => void;
onMoveKeyframe?: ( onMoveKeyframe?: (
elementId: string, elementId: string,
fromClipPercentage: number, keyframe: TimelineKeyframeTarget,
toClipPercentage: number, toClipPercentage: number,
propertyGroup?: string,
tweenPercentage?: number,
animationId?: string,
) => Promise<boolean>; ) => Promise<boolean>;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
onTogglePropertyGroupKeyframe?: ( onTogglePropertyGroupKeyframe?: (
@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore"; import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore";
import type { ClipManifestClip } from "../lib/playbackTypes"; import type { ClipManifestClip } from "../lib/playbackTypes";
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM"; import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
import { buildTimelineElementKey } from "../lib/timelineElementHelpers"; import { buildTimelineElementKey, splitTimelineElementKey } from "../lib/timelineElementHelpers";
function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null { function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null {
let current = parentMap.get(id); let current = parentMap.get(id);
@@ -19,18 +19,13 @@ function findTopLevelAncestor(id: string, parentMap: Map<string, string>): strin
return current; return current;
} }
function extractDomId(key: string): string {
const hashIdx = key.lastIndexOf("#");
return hashIdx >= 0 ? key.slice(hashIdx + 1) : key;
}
function resolveRawId( function resolveRawId(
selectedId: string | null, selectedId: string | null,
manifest: ClipManifestClip[], manifest: ClipManifestClip[],
parentMap: Map<string, string>, parentMap: Map<string, string>,
): string | null { ): string | null {
if (!selectedId) return null; if (!selectedId) return null;
const rawId = extractDomId(selectedId); const rawId = splitTimelineElementKey(selectedId).domId;
if (parentMap.has(rawId)) return rawId; if (parentMap.has(rawId)) return rawId;
if (parentMap.has(selectedId)) return selectedId; if (parentMap.has(selectedId)) return selectedId;
const clip = manifest.find((c) => c.label === selectedId || c.label === rawId); const clip = manifest.find((c) => c.label === selectedId || c.label === rawId);
@@ -298,6 +298,20 @@ export function buildTimelineElementKey(params: {
return `${scope}:${params.id}:${params.fallbackIndex}`; return `${scope}:${params.id}:${params.fallbackIndex}`;
} }
/**
* Inverse of {@link buildTimelineElementKey} for the `sourceFile#domId` form.
* A key with no `#` is a bare dom id and carries no source file of its own, so
* the caller supplies the scope it wants to look that id up in.
*/
export function splitTimelineElementKey(key: string): {
sourceFile: string | null;
domId: string;
} {
const hashIndex = key.lastIndexOf("#");
if (hashIndex < 0) return { sourceFile: null, domId: key };
return { sourceFile: key.slice(0, hashIndex), domId: key.slice(hashIndex + 1) };
}
export function buildTimelineElementIdentity(params: { export function buildTimelineElementIdentity(params: {
preferredId?: string | null; preferredId?: string | null;
label: string; label: string;