fix(studio): resolve flat-inspector review defects

This commit is contained in:
Vance Ingalls
2026-07-14 15:51:57 -07:00
parent a1fccfa748
commit 6062d3b31c
15 changed files with 390 additions and 48 deletions
@@ -38,6 +38,7 @@ import {
type ColorGradingScope,
} from "./studioColorGradingScope";
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers";
const MIN_INSPECTOR_SPLIT_PERCENT = 20;
const MAX_INSPECTOR_SPLIT_PERCENT = 75;
@@ -63,7 +64,7 @@ export interface StudioRightPanelProps {
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
onToggleElementHidden?: ToggleHiddenHandler;
}
// fallow-ignore-next-line complexity
@@ -343,10 +344,11 @@ export function StudioRightPanel({
[projectId, refreshFileTree, showToast],
);
const handleHideAllSelected = () =>
domEditGroupSelections
.map((el) => el.id ?? el.selector)
.forEach((key) => key && void onToggleElementHidden?.(key, true));
const handleHideAllSelected = () => {
const { elements } = usePlayerStore.getState();
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
if (keys.length > 0) void onToggleElementHidden?.(keys, true);
};
const propertyPanel = (
<DesignPanelPromoteProvider
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
@@ -13,6 +13,7 @@ import {
readGsapRuntimeValuesForPanel,
readGsapBorderRadiusForPanel,
isSelectedElementHidden,
selectionIdentityKey,
} from "./propertyPanelHelpers";
import { MetricField, Section } from "./propertyPanelPrimitives";
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
@@ -269,7 +270,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
return (
<PropertyPanelFlat
{...props}
key={element.id ?? element.selector}
key={selectionIdentityKey(element)}
element={element}
styles={styles}
sections={sections}
@@ -372,12 +373,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
)}
{sections.colorGrading && (
<ColorGradingSection
key={[
element.id ?? "",
element.hfId ?? "",
element.selector ?? "",
String(element.selectorIndex ?? ""),
].join("|")}
key={selectionIdentityKey(element)}
projectId={projectId}
element={element}
assets={assets}
@@ -105,7 +105,7 @@ function FlatMultiSelectState({
const { glyph, className } = elementKindGlyph(element);
return (
<span
key={element.id ?? element.selector}
key={`${element.id ?? element.selector ?? ""}:${element.selectorIndex ?? 0}`}
className="flex items-center gap-2 rounded-lg border border-panel-border bg-panel-bg px-2.5 py-[7px]"
>
<span
@@ -363,10 +363,14 @@ export function PropertyPanelFlat({
});
}
if (showEditableSections) {
// Number.isFinite guard (not `|| 1`): opacity 0 is a real value — an
// invisible element must summarize as 0%, not 100%.
const opacityValue = parseFloat(styles.opacity ?? "1");
const opacityPct = Math.round((Number.isFinite(opacityValue) ? opacityValue : 1) * 100);
groups.push({
id: "style",
title: "Style",
summary: `fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`,
summary: `fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${opacityPct}%`,
content: (
<FlatStyleSection
projectId={projectId}
@@ -383,7 +387,8 @@ export function PropertyPanelFlat({
groups.push({
id: "layout",
title: "Layout",
accessory: <span className="text-[9px] text-panel-text-5">drag values to scrub</span>,
// No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing
// (wheel/arrow keys only) — advertising "drag values to scrub" here lies.
summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`,
content: (
<FlatLayoutSection
@@ -8,6 +8,19 @@ import type { DomEditSelection } from "./domEditingTypes";
* Extracted so the identical closure exists once shared by the legacy
* PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat).
*/
// Resolve by id when unique, otherwise by selector + selectorIndex — a bare
// querySelector(selector) always hits the FIRST match, so dragging on the
// second of two same-selector siblings would animate the wrong element.
function resolvePreviewNode(
doc: Document | null | undefined,
el: DomEditSelection,
): Element | null {
if (!doc) return null;
if (el.id) return doc.querySelector(`#${el.id}`);
if (!el.selector) return null;
return doc.querySelectorAll(el.selector)[el.selectorIndex ?? 0] ?? null;
}
export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) {
return (el: DomEditSelection, props: Record<string, number>) => {
const iframe = iframeRef.current;
@@ -15,8 +28,7 @@ export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameE
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
| null
| undefined;
const sel = el.id ? `#${el.id}` : el.selector;
const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
const node = resolvePreviewNode(iframe?.contentDocument, el);
if (win?.gsap && node) win.gsap.set(node, props);
};
}
@@ -21,21 +21,43 @@ export function FlatTimingRow({
const { start, duration, inferred: derived } = deriveElementTiming(element, animations);
const end = start + duration;
// While the range is inferred from animations, editing ONE field must pin the
// WHOLE displayed range: writing only data-duration flips inference off and
// drops start to data-start-or-0 (the clip silently shifts), and writing only
// data-start is ignored while duration is still inferred (the edit looks
// dead). Pin both attributes, sequentially, so the display never jumps.
const pinRange = async (nextStart: number, nextDuration: number) => {
await onSetAttribute("start", nextStart.toFixed(2));
await onSetAttribute("duration", nextDuration.toFixed(2));
};
const commitStart = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null) return;
if (derived) {
void pinRange(parsed, duration);
return;
}
void onSetAttribute("start", parsed.toFixed(2));
};
const commitDuration = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null || parsed <= 0) return;
if (derived) {
void pinRange(start, parsed);
return;
}
void onSetAttribute("duration", parsed.toFixed(2));
};
const commitEnd = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null || parsed <= start) return;
if (derived) {
void pinRange(start, parsed - start);
return;
}
void onSetAttribute("duration", (parsed - start).toFixed(2));
};
@@ -485,7 +485,7 @@ describe("FlatSlider — Grade extensions", () => {
act(() => rootB.unmount());
});
it("renders no reset slot at all when centerTick is omitted, matching existing Style/Media callers", () => {
it("renders no reset slot at all when neither centerTick nor onReset is provided", () => {
const { host, root } = renderInto(
<FlatSlider
label="Opacity"
@@ -501,6 +501,136 @@ describe("FlatSlider — Grade extensions", () => {
expect(host.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
act(() => root.unmount());
});
it("shows a reachable reset button on a non-centerTick slider that passes onReset (Grade Vignette/Effects)", () => {
const onReset = vi.fn();
const { host, root } = renderInto(
<FlatSlider
label="Vignette"
value={18}
min={0}
max={100}
tier="explicitCustom"
displayValue="18%"
onReset={onReset}
onCommit={vi.fn()}
/>,
);
const resetButton = host.querySelector<HTMLButtonElement>('[data-flat-slider-reset="true"]');
expect(resetButton).not.toBeNull();
act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onReset).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("never commits from a click released on a disabled slider", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatSlider
label="Opacity"
value={100}
min={0}
max={100}
tier="default"
displayValue="100%"
disabled
onCommit={onCommit}
/>,
);
const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a track element");
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }),
});
act(() => {
track.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, clientX: 50, pointerId: 1 }),
);
track.dispatchEvent(
new PointerEvent("pointerup", { bubbles: true, clientX: 50, pointerId: 1 }),
);
});
expect(onCommit).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("supports keyboard operation: focusable, arrow keys step, Home/End clamp to range", () => {
const onCommit = vi.fn();
const { host, root } = renderInto(
<FlatSlider
label="Volume"
value={50}
min={0}
max={100}
tier="default"
displayValue="50%"
onCommit={onCommit}
/>,
);
const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a track element");
expect(track.getAttribute("tabindex")).toBe("0");
expect(track.getAttribute("aria-valuemin")).toBe("0");
expect(track.getAttribute("aria-valuemax")).toBe("100");
act(() => {
track.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
});
expect(onCommit).toHaveBeenLastCalledWith(51);
act(() => {
track.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
});
expect(onCommit).toHaveBeenLastCalledWith(0);
act(() => root.unmount());
});
it("ignores the committed prop echoing back mid-drag (no knob snap-back)", () => {
const onCommit = vi.fn();
function Harness() {
const [value, setValue] = React.useState(10);
return (
<FlatSlider
label="Opacity"
value={value}
min={0}
max={100}
tier="explicitCustom"
displayValue={`${value}%`}
onCommit={(next) => {
onCommit(next);
setValue(next);
}}
/>
);
}
const { host, root } = renderInto(<Harness />);
const track = host.querySelector<HTMLElement>('[data-flat-slider-track="true"]');
if (!track) throw new Error("expected a track element");
Object.defineProperty(track, "getBoundingClientRect", {
value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }),
});
act(() => {
// Leading-edge commit fires at 30 and echoes back through the parent's
// state — mid-drag, that echo must NOT reset the draft.
track.dispatchEvent(
new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }),
);
});
act(() => {
track.dispatchEvent(
new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 }),
);
});
// Draft tracks the pointer (80), not the stale committed echo (30).
expect(track.getAttribute("aria-valuenow")).toBe("80");
act(() => {
track.dispatchEvent(
new PointerEvent("pointerup", { bubbles: true, clientX: 80, pointerId: 1 }),
);
});
expect(onCommit).toHaveBeenLastCalledWith(80);
expect(track.getAttribute("aria-valuenow")).toBe("80");
act(() => root.unmount());
});
});
describe("FlatSelectRow", () => {
@@ -216,6 +216,29 @@ export function FlatGroupHeader({
/* FlatSlider — full-width label/track/value row */
/* ------------------------------------------------------------------ */
/** Keyboard target for a slider keydown, or null for keys we don't handle. */
function sliderKeyTarget(
key: string,
current: number,
min: number,
max: number,
step: number,
): number | null {
if (key === "Home") return min;
if (key === "End") return max;
const deltas: Record<string, number> = {
ArrowLeft: -step,
ArrowDown: -step,
ArrowRight: step,
ArrowUp: step,
PageDown: -step * 10,
PageUp: step * 10,
};
const delta = deltas[key];
if (delta === undefined) return null;
return Math.max(min, Math.min(max, current + delta));
}
export function FlatSlider({
label,
value,
@@ -252,6 +275,11 @@ export function FlatSlider({
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastCommitAtRef = useRef(0);
const pendingRef = useRef<number | null>(null);
// True from pointerdown to pointerup/cancel. While dragging, the committed
// prop echoing back through the parent must NOT reset `draft` — the echo is
// up to 40ms stale (throttled commit), and syncing it mid-drag snaps the
// knob backwards under the user's pointer.
const draggingRef = useRef(false);
// Tracks the last value actually sent to onCommit — separate from `value`
// (the committed prop) because in a single pointerdown+pointerup click the
// leading-edge commit fires before the parent has re-rendered with the new
@@ -260,6 +288,7 @@ export function FlatSlider({
const lastCommittedRef = useRef(value);
useEffect(() => {
if (draggingRef.current) return;
setDraft(value);
lastCommittedRef.current = value;
}, [value]);
@@ -313,10 +342,14 @@ export function FlatSlider({
role="slider"
aria-label={label}
aria-valuenow={draft}
aria-valuemin={min}
aria-valuemax={max}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
className={`relative h-5 flex-1 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
onPointerDown={(e) => {
if (disabled) return;
draggingRef.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
setDraft(stepped);
@@ -332,6 +365,9 @@ export function FlatSlider({
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
if (disabled) return;
if (!draggingRef.current) return;
draggingRef.current = false;
// Recompute from the event itself rather than reading the `draft`
// closure — if pointerdown+pointerup land in the same React batch
// (e.g. a very fast click), the onPointerUp handler can still be
@@ -340,6 +376,20 @@ export function FlatSlider({
setDraft(stepped);
commitDraft(stepped);
}}
onPointerCancel={(e) => {
draggingRef.current = false;
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
onKeyDown={(e) => {
if (disabled) return;
const next = sliderKeyTarget(e.key, draft, min, max, step);
if (next === null) return;
e.preventDefault();
setDraft(next);
commitDraft(next);
}}
>
<div className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-panel-hover">
{centerTick && (
@@ -372,7 +422,7 @@ export function FlatSlider({
>
{displayValue}
</span>
{centerTick && (
{(centerTick || onReset) && (
<span data-flat-slider-reset-slot="true" className="w-3.5 flex-shrink-0">
{tier === "explicitCustom" && onReset && (
<button
@@ -411,11 +411,13 @@ function FlatOverflowMaskRows({
/>
<FlatSelectRow
label="Mask"
value={clipPathPreset === "custom" ? "none" : clipPathPreset}
options={["none", "inset", "circle"]}
value={clipPathPreset}
// "custom" = authored clip-path; showing "none" invites destroying it.
options={[...(clipPathPreset === "custom" ? ["custom"] : []), "none", "inset", "circle"]}
tier={resolveValueTier(clipPathPreset === "none" ? undefined : clipPathPreset, "none")}
disabled={disabled}
onChange={(next) => {
if (next === "custom") return;
void onSetStyle(
"clip-path",
buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue),
@@ -5,6 +5,7 @@ import type { ImportedFontAsset } from "./fontAssets";
import { normalizeTextMetricValue } from "./propertyPanelHelpers";
import { ColorField } from "./propertyPanelColor";
import { FontFamilyField } from "./propertyPanelFont";
import { PromotableControl } from "./PromotableControl";
import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
import {
resolveValueTier,
@@ -65,20 +66,33 @@ function FlatTextFieldEditor({
return (
<>
<TextAreaField
flat
label="Content"
value={field.value}
autoFocus={autoFocus}
onCommit={(next) => onSetText(next, field.key)}
/>
<FontFamilyField
flat
value={field.computedStyles["font-family"] || styles["font-family"] || "inherit"}
importedFonts={fontAssets}
onImportFonts={onImportFonts}
onCommit={(next) => onSetTextFieldStyle(field.key, "font-family", next)}
/>
<PromotableControl channel={{ kind: "text" }} enabled={field.source === "self"}>
{({ value, onCommit }) => (
<TextAreaField
flat
label="Content"
value={value ?? field.value}
autoFocus={autoFocus}
onCommit={onCommit ?? ((next) => onSetText(next, field.key))}
/>
)}
</PromotableControl>
<PromotableControl
channel={{ kind: "style", prop: "font-family" }}
enabled={field.source === "self"}
>
{({ value, onCommit }) => (
<FontFamilyField
flat
value={
value ?? (field.computedStyles["font-family"] || styles["font-family"] || "inherit")
}
importedFonts={fontAssets}
onImportFonts={onImportFonts}
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "font-family", next))}
/>
)}
</PromotableControl>
<FlatRow
label="Size"
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
@@ -180,12 +194,19 @@ function FlatTextFieldEditor({
}
}}
/>
<ColorField
flat
label="Color"
value={getTextFieldColor(field, styles)}
onCommit={(next) => onSetTextFieldStyle(field.key, "color", next)}
/>
<PromotableControl
channel={{ kind: "style", prop: "color" }}
enabled={field.source === "self"}
>
{({ value, onCommit }) => (
<ColorField
flat
label="Color"
value={value ?? getTextFieldColor(field, styles)}
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
/>
)}
</PromotableControl>
</>
);
}
@@ -29,6 +29,22 @@ export function isSelectedElementHidden(
);
}
/**
* 4-part element identity for keying panel remounts on selection change
* id or selector alone collides for id-less same-selector siblings, leaving
* mount-initialized state pointed at the previous element.
*/
export function selectionIdentityKey(
element: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex">,
): string {
return [
element.id ?? "",
element.hfId ?? "",
element.selector ?? "",
String(element.selectorIndex ?? ""),
].join("|");
}
/* ------------------------------------------------------------------ */
/* Font types & constants (shared by font and section modules) */
/* ------------------------------------------------------------------ */
@@ -233,7 +233,10 @@ export function useColorGradingController({
setMediaMetadata(metadata);
})
.catch(() => {
if (!controller.signal.aborted) MEDIA_METADATA_CACHE.set(cacheKey, null);
// Don't cache a transient fetch failure — a cached null would suppress
// the HDR banner for this asset for the page's whole lifetime. Leave the
// key absent so the next selection retries. (A successful response with
// no metadata still caches null above, which IS a stable answer.)
});
return () => controller.abort();
}, [projectId, selectedAssetPath]);
@@ -201,4 +201,56 @@ describe("toggleTimelineElementHidden", () => {
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
).toBeUndefined();
});
it("hides several elements in ONE atomic write when given an array of keys", async () => {
const files = new Map([
[
"index.html",
`<div id="hero" data-start="0" data-duration="2"></div>
<div id="caption" data-start="1" data-duration="2"></div>
<div id="badge" data-start="2" data-duration="2"></div>`,
],
]);
stubProjectFiles(files);
const hero = element({ id: "hero", key: "index.html:#hero", domId: "hero", track: 0 });
const caption = element({
id: "caption",
key: "index.html:#caption",
domId: "caption",
track: 1,
});
const badge = element({ id: "badge", key: "index.html:#badge", domId: "badge", track: 2 });
const writes: Array<{ path: string; content: string }> = [];
const recordEdit = vi.fn();
await toggleTimelineElementHidden({
projectId: "project-1",
activeCompPath: "index.html",
timelineElements: [hero, caption, badge],
elementKey: ["index.html:#hero", "index.html:#caption"],
hidden: true,
previewIframe: null,
writeProjectFile: async (path, content) => {
writes.push({ path, content });
},
recordEdit,
domEditSaveTimestampRef: { current: 0 },
pendingTimelineEditPathRef: { current: new Set() },
});
// One write carrying BOTH hides — per-element writes would clobber each
// other (each starts from the original file content).
expect(writes).toHaveLength(1);
expect(writes[0]?.content).toContain(
'id="hero" data-start="0" data-duration="2" data-hidden=""',
);
expect(writes[0]?.content).toContain(
'id="caption" data-start="1" data-duration="2" data-hidden=""',
);
expect(writes[0]?.content).toContain('id="badge" data-start="2" data-duration="2"></div>');
expect(recordEdit).toHaveBeenCalledTimes(1);
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide 2 elements");
});
});
@@ -33,7 +33,8 @@ interface ToggleTimelineTrackHiddenInput {
}
interface ToggleTimelineElementHiddenInput extends Omit<ToggleTimelineTrackHiddenInput, "track"> {
elementKey: string;
/** One timeline key, or several to hide/show in a single atomic file write. */
elementKey: string | readonly string[];
}
interface SetElementsHiddenInput {
@@ -233,13 +234,21 @@ export async function toggleTimelineElementHidden({
domEditSaveTimestampRef,
pendingTimelineEditPathRef,
}: ToggleTimelineElementHiddenInput): Promise<string[]> {
const element = timelineElements.find((item) => (item.key ?? item.id) === elementKey);
const keys = new Set(typeof elementKey === "string" ? [elementKey] : elementKey);
const elements = timelineElements.filter((item) => keys.has(item.key ?? item.id));
return setElementsHidden({
projectId,
activeCompPath,
elements: element ? [element] : [],
elements,
hidden,
label: hidden ? "Hide element" : "Show element",
label:
elements.length > 1
? hidden
? `Hide ${elements.length} elements`
: `Show ${elements.length} elements`
: hidden
? "Hide element"
: "Show element",
previewIframe,
writeProjectFile,
recordEdit,
@@ -323,11 +332,11 @@ export function useTimelineElementVisibilityEditing({
isRecordingRef,
forceReloadSdkSession,
}: UseTimelineElementVisibilityEditingInput): (
elementKey: string,
elementKey: string | readonly string[],
hidden: boolean,
) => Promise<void> {
return useCallback(
async (elementKey: string, hidden: boolean) => {
async (elementKey: string | readonly string[], hidden: boolean) => {
if (isRecordingRef?.current) {
showToast("Cannot edit timeline while recording", "error");
return;
@@ -237,6 +237,28 @@ export function resolveTimelineIdForSelection(
);
}
/**
* Resolve every multi-selected element to its scope-qualified timeline key
* (dropping unresolvable ones). Selections carry bare DOM ids/selectors, but
* the visibility toggle matches keys like "index.html#hero" and callers must
* hide all keys in ONE call so the file is patched in a single atomic write
* (per-element calls would clobber each other's reads).
*/
export function timelineKeysForSelections(
selections: readonly DomEditSelection[],
elements: TimelineElement[],
activeCompPath: string | null,
): string[] {
return selections
.map((selection) => resolveTimelineIdForSelection(selection, elements, activeCompPath))
.filter((key): key is string => key !== null);
}
export type ToggleHiddenHandler = (
elementKey: string | readonly string[],
hidden: boolean,
) => Promise<void> | void;
export function resolveTimelineSelectionSeekTime(
currentTime: number,
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,