refactor(studio): single-source timeline id-resolution and resize-clamp math

Extract resolveTimelineIdForSelection so DOM-to-timeline id mapping lives in one place
with a single sourceFile / activeCompPath / index.html fallback, fixing a sub-composition
selection that previously diverged between callers.

Extract shared start-trim delta helpers used by both single-clip and group resize, and
remove the never-called refreshDomEditGroupSelectionsFromPreview.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-09 16:54:34 -04:00
parent 95ded6c026
commit 3c6c1d3f27
6 changed files with 149 additions and 129 deletions
+21 -80
View File
@@ -4,11 +4,7 @@ import {
getAllPreviewTargetsFromPointer,
getPreviewTargetFromPointer,
} from "../utils/studioPreviewHelpers";
import {
findMatchingTimelineElementId,
findTimelineIdByAncestor,
type RightPanelTab,
} from "../utils/studioHelpers";
import { resolveTimelineIdForSelection, type RightPanelTab } from "../utils/studioHelpers";
import {
domEditSelectionsTargetSame,
domEditSelectionInGroup,
@@ -96,7 +92,6 @@ export interface UseDomSelectionReturn {
) => Promise<DomEditSelection | null>;
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
}
@@ -208,20 +203,24 @@ export function useDomSelection({
setRightCollapsed(false);
setRightPanelTab("design");
}
const nextSelectedTimelineId =
findMatchingTimelineElementId(nextSelection, timelineElements) ??
findTimelineIdByAncestor(
nextSelection.element,
timelineElements,
nextSelection.sourceFile || "index.html",
);
const nextSelectedTimelineId = resolveTimelineIdForSelection(
nextSelection,
timelineElements,
activeCompPath,
);
setSelectedTimelineElementId(nextSelectedTimelineId);
return;
}
setSelectedTimelineElementId(null);
},
[setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
[
setSelectedTimelineElementId,
timelineElements,
setRightCollapsed,
setRightPanelTab,
activeCompPath,
],
);
const clearDomSelection = useCallback(() => {
@@ -401,55 +400,6 @@ export function useDomSelection({
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
);
const refreshDomEditGroupSelectionsFromPreview = useCallback(
// fallow-ignore-next-line complexity
async (selections: DomEditSelection[]) => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
try {
doc = iframe?.contentDocument ?? null;
} catch {
return;
}
if (!doc) return;
const nextGroup: DomEditSelection[] = [];
for (const selection of selections) {
const element = findElementForSelection(doc, selection, activeCompPath);
if (!element) continue;
const nextSelection = await buildDomSelectionFromTarget(element);
if (nextSelection) nextGroup.push(nextSelection);
}
if (nextGroup.length === 0) return;
const currentSelection = domEditSelectionRef.current;
const nextSelection =
nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
nextGroup[0] ??
null;
domEditSelectionRef.current = nextSelection;
domEditGroupSelectionsRef.current = nextGroup;
setDomEditSelection(nextSelection);
setDomEditGroupSelections(nextGroup);
if (nextSelection) {
setSelectedTimelineElementId(
findMatchingTimelineElementId(nextSelection, timelineElements),
);
} else {
setSelectedTimelineElementId(null);
}
},
[
activeCompPath,
buildDomSelectionFromTarget,
setSelectedTimelineElementId,
timelineElements,
previewIframeRef,
],
);
// ── Effects ──
// Clear hover unconditionally on composition/project/preview change
@@ -531,22 +481,14 @@ export function useDomSelection({
domEditGroupSelectionsRef.current = nextGroup;
setDomEditSelection(nextSelection);
setDomEditGroupSelections(nextGroup);
const nextTimelineId =
findMatchingTimelineElementId(nextSelection, timelineElements) ??
findTimelineIdByAncestor(
nextSelection.element,
timelineElements,
nextSelection.sourceFile || "index.html",
);
const nextTimelineId = resolveTimelineIdForSelection(
nextSelection,
timelineElements,
activeCompPath,
);
const nextTimelineIds = nextGroup
.map(
(selection) =>
findMatchingTimelineElementId(selection, timelineElements) ??
findTimelineIdByAncestor(
selection.element,
timelineElements,
selection.sourceFile || "index.html",
),
.map((selection) =>
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
)
.filter((id): id is string => Boolean(id));
if (nextTimelineIds.length > 0) {
@@ -555,7 +497,7 @@ export function useDomSelection({
setSelectedTimelineElementId(null);
}
},
[applyDomSelection, timelineElements, setSelectedTimelineElementId],
[applyDomSelection, timelineElements, setSelectedTimelineElementId, activeCompPath],
);
// Disabled inspector effect
@@ -592,7 +534,6 @@ export function useDomSelection({
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
refreshDomEditGroupSelectionsFromPreview,
applyMarqueeSelection,
};
}
@@ -1,7 +1,7 @@
import { useEffect, useMemo } from "react";
import type { TimelineElement } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
import { findMatchingTimelineElementId, findTimelineIdByAncestor } from "../utils/studioHelpers";
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
interface UseTimelineSelectionPreviewSyncParams {
selectedElementId: string | null;
@@ -26,21 +26,6 @@ function orderSelectedIds(ids: Set<string>, anchor: string | null): string[] {
return [anchor, ...ordered.filter((id) => id !== anchor)];
}
function selectionTimelineId(
selection: DomEditSelection,
timelineElements: TimelineElement[],
activeCompPath: string | null,
): string | null {
return (
findMatchingTimelineElementId(selection, timelineElements) ??
findTimelineIdByAncestor(
selection.element,
timelineElements,
selection.sourceFile || activeCompPath || "index.html",
)
);
}
function selectionIdsMatch(currentIds: string[], selectedIds: string[]): boolean {
if (currentIds.length !== selectedIds.length) return false;
const selected = new Set(selectedIds);
@@ -72,7 +57,9 @@ export function useTimelineSelectionPreviewSync({
? [domEditSelection]
: [];
const currentIds = currentSelections
.map((selection) => selectionTimelineId(selection, timelineElements, activeCompPath))
.map((selection) =>
resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
)
.filter((id): id is string => Boolean(id));
if (selectedIds.length === 0) {
@@ -4,7 +4,11 @@ import type { StackingTimelineLayer, TimelineLayerId } from "./timelineTrackOrde
import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
import { resolveTimelineMinDuration } from "./timelineGroupEditing";
import {
applyClipStartTrimDelta,
clipStartTrimDeltaBounds,
resolveTimelineMinDuration,
} from "./timelineGroupEditing";
export {
clampTimelineGroupResizeDelta,
@@ -220,23 +224,15 @@ export function resolveTimelineResize(
};
}
const playbackRate = Math.max(0.1, input.playbackRate ?? 1);
const maxLeftExtensionFromMedia =
input.playbackStart != null ? input.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
const minDelta = -Math.min(input.start - input.minStart, maxLeftExtensionFromMedia);
const maxDelta = input.duration - minDuration;
const { minDelta, maxDelta } = clipStartTrimDeltaBounds(input, input.minStart, minDuration);
const clampedDelta = clamp(deltaTime, minDelta, maxDelta);
const nextStart = roundToCentiseconds(input.start + clampedDelta);
const nextDuration = roundToCentiseconds(input.duration - clampedDelta);
const nextPlaybackStart =
input.playbackStart != null
? roundToCentiseconds(Math.max(0, input.playbackStart + clampedDelta * playbackRate))
: undefined;
const trimmed = applyClipStartTrimDelta(input, clampedDelta);
return {
start: nextStart,
duration: nextDuration,
playbackStart: nextPlaybackStart,
start: roundToCentiseconds(trimmed.start),
duration: roundToCentiseconds(trimmed.duration),
playbackStart:
trimmed.playbackStart != null ? roundToCentiseconds(trimmed.playbackStart) : undefined,
};
}
@@ -15,6 +15,58 @@ export function resolveTimelineMinDuration(minDuration?: number): number {
return Math.max(ABSOLUTE_TIMELINE_MIN_DURATION, minDuration ?? DEFAULT_TIMELINE_MIN_DURATION);
}
/** Playback rate never drops to zero (would make media-in-point math divide by ~0). */
function resolveTimelinePlaybackRate(rate?: number): number {
return Math.max(0.1, rate ?? 1);
}
interface TimelineStartTrimClip {
start: number;
duration: number;
playbackStart?: number;
playbackRate?: number;
}
/**
* Delta bounds for trimming a clip's START edge (shared by single-clip and group
* resize). Left-bounded by how far the start can move toward `minStart` and by the
* media in-point (`playbackStart / playbackRate`); right-bounded by `minDuration`.
* Returned deltas are unrounded callers round with their own centisecond helper.
*/
export function clipStartTrimDeltaBounds(
clip: TimelineStartTrimClip,
minStart: number,
minDuration: number,
): { minDelta: number; maxDelta: number } {
const playbackRate = resolveTimelinePlaybackRate(clip.playbackRate);
const maxLeftExtensionFromMedia =
clip.playbackStart != null ? clip.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
return {
minDelta: -Math.min(clip.start - minStart, maxLeftExtensionFromMedia),
maxDelta: clip.duration - minDuration,
};
}
/**
* Apply a start-edge delta to one clip (unrounded): moves the start, shrinks the
* duration by the same amount, and shifts the media in-point by the delta scaled to
* the playback rate (clamped at 0).
*/
export function applyClipStartTrimDelta(
clip: TimelineStartTrimClip,
delta: number,
): { start: number; duration: number; playbackStart?: number } {
const playbackRate = resolveTimelinePlaybackRate(clip.playbackRate);
return {
start: clip.start + delta,
duration: clip.duration - delta,
playbackStart:
clip.playbackStart != null
? Math.max(0, clip.playbackStart + delta * playbackRate)
: undefined,
};
}
export interface TimelineGroupTimingMember {
start: number;
duration: number;
@@ -70,17 +122,10 @@ export function clampTimelineGroupResizeDelta(
return roundTimelineTime(Math.max(rawDelta, minDelta));
}
const minDelta = Math.max(
...members.map((member) => {
const playbackRate = Math.max(0.1, member.playbackRate ?? 1);
const maxLeftExtensionFromMedia =
member.playbackStart != null
? member.playbackStart / playbackRate
: Number.POSITIVE_INFINITY;
return -Math.min(member.start, maxLeftExtensionFromMedia);
}),
);
const maxDelta = Math.min(...members.map((member) => member.duration - minDuration));
// Rigid group: the applied delta is bounded by the most-constrained member.
const bounds = members.map((member) => clipStartTrimDeltaBounds(member, 0, minDuration));
const minDelta = Math.max(...bounds.map((b) => b.minDelta));
const maxDelta = Math.min(...bounds.map((b) => b.maxDelta));
return roundTimelineTime(clamp(rawDelta, minDelta, maxDelta));
}
@@ -102,14 +147,12 @@ export function resolveTimelineGroupResize(
};
}
const playbackRate = Math.max(0.1, member.playbackRate ?? 1);
const trimmed = applyClipStartTrimDelta(member, delta);
return {
start: roundTimelineTime(member.start + delta),
duration: roundTimelineTime(member.duration - delta),
start: roundTimelineTime(trimmed.start),
duration: roundTimelineTime(trimmed.duration),
playbackStart:
member.playbackStart != null
? roundTimelineTime(Math.max(0, member.playbackStart + delta * playbackRate))
: undefined,
trimmed.playbackStart != null ? roundTimelineTime(trimmed.playbackStart) : undefined,
};
}),
};
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import {
findMatchingTimelineElementId,
findTimelineIdByAncestor,
resolveTimelineIdForSelection,
resolveTimelineSelectionSeekTime,
} from "./studioHelpers";
@@ -72,3 +73,33 @@ describe("findTimelineIdByAncestor", () => {
expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null);
});
});
describe("resolveTimelineIdForSelection", () => {
const el = (over: Record<string, unknown>) =>
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
it("resolves an ancestor clip against activeCompPath when the selection has no sourceFile", () => {
// #card (a clip in a sub-composition) > .leaf (selected, not itself a clip)
const card = document.createElement("div");
card.id = "card";
const leaf = document.createElement("span");
leaf.className = "leaf";
card.appendChild(leaf);
const els = [
el({
id: "card",
domId: "card",
key: "comps/panel.html#card",
sourceFile: "comps/panel.html",
}),
];
const selection = { element: leaf } as never;
// Falling back to the active comp matches; the old index.html-only fallback would miss.
expect(resolveTimelineIdForSelection(selection, els, "comps/panel.html")).toBe(
"comps/panel.html#card",
);
expect(resolveTimelineIdForSelection(selection, els, null)).toBe(null);
});
});
@@ -209,6 +209,28 @@ export function findTimelineIdByAncestor(
return null;
}
/**
* Resolve the timeline element id for a DOM selection: direct match first, then
* nearest clip ancestor. The ancestor lookup resolves against the selection's own
* source file, falling back to the active composition path, then index.html so a
* sub-composition selection with no explicit sourceFile resolves against the comp
* currently open, not always the root file.
*/
export function resolveTimelineIdForSelection(
selection: DomEditSelection,
elements: TimelineElement[],
activeCompPath: string | null,
): string | null {
return (
findMatchingTimelineElementId(selection, elements) ??
findTimelineIdByAncestor(
selection.element,
elements,
selection.sourceFile || activeCompPath || "index.html",
)
);
}
export function resolveTimelineSelectionSeekTime(
currentTime: number,
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,