Files
hyperframes/packages/studio/src/utils/studioPreviewHelpers.ts
T
Miguel Ángel f7d63fbebd fix(studio): address PR review — add tests, strip GSAP transform for rotation, remove dead exports
Addresses review feedback from Rames and Vai:

1. Add 7 new tests for createStudioPositionSeekReapplyScript:
   box-size reapplication, GSAP translate stripping (identity removal,
   scale+translate preservation, transform:none no-op), and rotation-
   only elements with GSAP-baked translate.

2. Add pinning test for the PiP-over-sub-composition selection bug:
   elementsFromPoint returns [pipVideo, subCompRoot, sfChromeImg] as
   siblings — assert the topmost (pipVideo) wins.

3. Apply stripGsapTranslateFromTransform to rotation-only elements
   too, not just path-offset elements. A rotation-only element with
   a GSAP-animated translate would have its position clobbered.

4. Remove dead exports: getPreviewLocalPointer,
   buildRasterClickSelectionContext, getPreviewPlayer,
   seekStudioPreview, PreviewPlayerCompat, PreviewLocalPointer from
   studioPreviewHelpers.ts. Unexport resolvePreviewLocalPointer.
2026-05-19 16:05:37 -04:00

158 lines
4.8 KiB
TypeScript

import type { DomEditViewport } from "../components/editor/domEditing";
import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing";
import {
getDomLayerPatchTarget,
isElementComputedVisible,
} from "../components/editor/domEditingElement";
import { getEventTargetElement } from "./studioHelpers";
interface PreviewLocalPointer {
x: number;
y: number;
viewport: DomEditViewport;
}
function resolvePreviewLocalPointer(
iframe: HTMLIFrameElement,
doc: Document,
win: Window,
clientX: number,
clientY: number,
): PreviewLocalPointer | null {
const iframeRect = iframe.getBoundingClientRect();
const root =
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
const rootRect = root?.getBoundingClientRect();
const rootWidth = rootRect?.width || win.innerWidth;
const rootHeight = rootRect?.height || win.innerHeight;
if (!rootWidth || !rootHeight) return null;
const scaleX = iframeRect.width / rootWidth;
const scaleY = iframeRect.height / rootHeight;
return {
x: (clientX - iframeRect.left) / scaleX,
y: (clientY - iframeRect.top) / scaleY,
viewport: { width: rootWidth, height: rootHeight },
};
}
const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
try {
const style = doc.createElement("style");
style.id = POINTER_EVENTS_OVERRIDE_ID;
style.textContent = "* { pointer-events: auto !important; }";
doc.head.appendChild(style);
return style;
} catch {
return null;
}
}
function removePointerEventsOverride(style: HTMLStyleElement | null): void {
try {
style?.remove();
} catch {
// cross-origin or detached doc
}
}
export function getPreviewTargetFromPointer(
iframe: HTMLIFrameElement,
clientX: number,
clientY: number,
activeCompositionPath: string | null,
): HTMLElement | null {
let doc: Document | null = null;
let win: Window | null = null;
try {
doc = iframe.contentDocument;
win = iframe.contentWindow;
} catch {
return null;
}
if (!doc || !win) return null;
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
if (!localPointer) return null;
const overrideStyle = forcePointerEventsAuto(doc);
try {
if (typeof doc.elementsFromPoint === "function") {
const visualTarget = resolveVisualDomEditSelectionTarget(
doc.elementsFromPoint(localPointer.x, localPointer.y),
{
activeCompositionPath,
},
);
if (visualTarget) return visualTarget;
}
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
if (!isElementComputedVisible(fallback)) return null;
return fallback;
} finally {
removePointerEventsOverride(overrideStyle);
}
}
function objectLike(value: unknown): object | null {
return value && (typeof value === "object" || typeof value === "function") ? value : null;
}
function callPlaybackMethod(target: object | null, key: string): void {
const method = target ? Reflect.get(target, key) : null;
if (typeof method !== "function") return;
try {
method.call(target);
} catch {
// Best-effort playback freeze; drag should still work if playback control is unavailable.
}
}
function readPlaybackTime(target: object | null, key: string): number | null {
const method = target ? Reflect.get(target, key) : null;
if (typeof method !== "function") return null;
try {
const value = method.call(target);
return typeof value === "number" && Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
export function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
const win = iframe?.contentWindow;
if (!win) return null;
try {
const player = objectLike(Reflect.get(win, "__player"));
const playerPausedTime = readPlaybackTime(player, "getTime");
const playerPause = player ? Reflect.get(player, "pause") : null;
if (typeof playerPause === "function") {
callPlaybackMethod(player, "pause");
return playerPausedTime;
}
let pausedTime: number | null = null;
const timeline = objectLike(Reflect.get(win, "__timeline"));
pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
callPlaybackMethod(timeline, "pause");
const timelines = objectLike(Reflect.get(win, "__timelines"));
if (timelines) {
for (const value of Object.values(timelines)) {
const timelineRecord = objectLike(value);
pausedTime = pausedTime ?? readPlaybackTime(timelineRecord, "time");
callPlaybackMethod(timelineRecord, "pause");
}
}
return pausedTime;
} catch {
return null;
}
}