diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx
index cfa981a3e..b42a06837 100644
--- a/packages/studio/src/components/StudioPreviewArea.tsx
+++ b/packages/studio/src/components/StudioPreviewArea.tsx
@@ -3,6 +3,8 @@ import { NLELayout } from "./nle/NLELayout";
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
import { DomEditOverlay } from "./editor/DomEditOverlay";
+import { MotionPathOverlay } from "./editor/MotionPathOverlay";
+import { useCompositionDimensions } from "../hooks/useCompositionDimensions";
import { SnapToolbar } from "./editor/SnapToolbar";
import { StudioFeedbackBar } from "./StudioFeedbackBar";
import type { TimelineElement } from "../player";
@@ -10,6 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore";
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
+ STUDIO_KEYFRAMES_ENABLED,
STUDIO_PREVIEW_MANUAL_EDITING_ENABLED,
STUDIO_PREVIEW_SELECTION_ENABLED,
} from "./editor/manualEditingAvailability";
@@ -108,6 +111,7 @@ export function StudioPreviewArea({
isPlaying,
refreshPreviewDocumentVersion,
} = useStudioPlaybackContext();
+ const compositionDimensions = useCompositionDimensions();
const {
domEditHoverSelection,
@@ -337,6 +341,14 @@ export function StudioPreviewArea({
onToggleRecording={onToggleRecording}
/>
+ {STUDIO_KEYFRAMES_ENABLED && (
+
+ )}
{gestureOverlay}
>
) : null
diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx
index 12789407c..64ca5147f 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.tsx
+++ b/packages/studio/src/components/editor/DomEditOverlay.tsx
@@ -243,6 +243,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
if (!selection) return "none";
return `${selection.sourceFile}:${selection.id ?? selection.selector ?? selection.label}:${selection.selectorIndex ?? 0}`;
}, [selection]);
+
const groupBounds = useMemo(
() => resolveDomEditGroupOverlayRect(groupOverlayItems.map((item) => item.rect)),
[groupOverlayItems],
diff --git a/packages/studio/src/components/editor/MotionPathNode.tsx b/packages/studio/src/components/editor/MotionPathNode.tsx
new file mode 100644
index 000000000..c6d3ce43c
--- /dev/null
+++ b/packages/studio/src/components/editor/MotionPathNode.tsx
@@ -0,0 +1,98 @@
+import type React from "react";
+
+// Editor primary color (themeable via --hf-accent). Applied through inline
+// style because CSS var() isn't valid in SVG presentation attributes.
+export const ACCENT = "var(--hf-accent, #3CE6AC)";
+
+/** One path node: a diamond (matching the timeline keyframe), a wider transparent
+ * grab target (when editable), and a hover-revealed × delete badge (when removable). */
+export function MotionPathNode(props: {
+ cx: number;
+ cy: number;
+ r: number;
+ interactive: boolean;
+ removable: boolean;
+ grabbing: boolean;
+ selected: boolean;
+ onEnter: () => void;
+ onLeave: () => void;
+ onPointerDown: (e: React.PointerEvent) => void;
+ onPointerMove: (e: React.PointerEvent) => void;
+ onPointerUp: (e: React.PointerEvent) => void;
+ onRemove: (e: React.PointerEvent) => void;
+ onContextMenu?: (e: React.MouseEvent) => void;
+}) {
+ const { cx, cy, r, interactive, removable, grabbing, selected } = props;
+ const bx = cx + r * 1.8;
+ const by = cy - r * 1.8;
+ const k = r * 0.55;
+ // Diamond matching the timeline keyframe (a 45°-rotated rounded square).
+ // `side` is chosen so the diamond's points reach ~`r` from center, matching the
+ // old dot's footprint; selection is shown by enlarging it (no extra shape).
+ const side = (selected ? r * 1.5 : r) * 1.414;
+ return (
+
+
+ {interactive && (
+
+ )}
+ {removable && (
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/studio/src/components/editor/MotionPathOverlay.tsx b/packages/studio/src/components/editor/MotionPathOverlay.tsx
new file mode 100644
index 000000000..8a75d29e1
--- /dev/null
+++ b/packages/studio/src/components/editor/MotionPathOverlay.tsx
@@ -0,0 +1,581 @@
+import { memo, useEffect, useRef, useState, type RefObject } from "react";
+import type { DomEditSelection } from "./domEditing";
+import { useDomEditContext } from "../../contexts/DomEditContext";
+import { usePlayerStore } from "../../player/store/playerStore";
+import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
+import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
+import { isElementVisibleInPreview } from "./domEditOverlayGeometry";
+import {
+ buildMotionPathGeometry,
+ nearestPointOnPath,
+ type MotionNodeRef,
+ type MotionPathGeometry,
+} from "./motionPathGeometry";
+import { editableAnimationId, selectorFor } from "./motionPathSelection";
+import { ACCENT, MotionPathNode } from "./MotionPathNode";
+import {
+ KeyframeDiamondContextMenu,
+ type KeyframeDiamondContextMenuState,
+} from "../../player/components/KeyframeDiamondContextMenu";
+import {
+ commitAddKeyframe,
+ commitAddWaypoint,
+ commitCreatePath,
+ commitNode,
+ commitRemoveWaypoint,
+} from "./motionPathCommit";
+
+interface MotionPathOverlayProps {
+ iframeRef: RefObject;
+ selection: DomEditSelection | null;
+ compositionSize: { width: number; height: number } | null;
+ isPlaying: boolean;
+}
+
+type Rect = { left: number; top: number; width: number; height: number };
+type Draft = { index: number; x: number; y: number };
+type DragState = {
+ index: number;
+ startX: number;
+ startY: number;
+ initX: number;
+ initY: number;
+ scale: number;
+ ref: MotionNodeRef;
+};
+
+const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
+
+/** The element's layout-home center in composition coordinates. GSAP x/y (and
+ * motionPath coords) are offsets from this point, so the overlay adds it to
+ * each node to place the path on the element rather than the canvas origin.
+ * offsetLeft/Top are transform-excluded, so home is stable across the
+ * animation; walk up to (not including) the composition root. */
+function elementHome(el: HTMLElement): { x: number; y: number } {
+ let left = 0;
+ let top = 0;
+ let node: HTMLElement | null = el;
+ while (node) {
+ left += node.offsetLeft;
+ top += node.offsetTop;
+ const parent = node.offsetParent as HTMLElement | null;
+ if (!parent || parent.hasAttribute("data-composition-id")) break;
+ node = parent;
+ }
+ let x = left + el.offsetWidth / 2;
+ let y = top + el.offsetHeight / 2;
+ // Include the manual CSS path offset (`--hf-studio-offset`, applied via
+ // `translate`). offsetLeft excludes transforms, but this offset is a stable
+ // nudge (not animated) that shifts where the element — and thus its entire
+ // keyframe path — actually renders. Keyframe values stay in gsap space (the
+ // path offset is composed separately at runtime), so without this the whole
+ // path draws shifted by the offset (e.g. a gesture recorded on a dragged-down
+ // element drew its path above the element).
+ if ((el.style.translate ?? "").includes("var(")) {
+ x += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
+ y += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
+ }
+ return { x, y };
+}
+
+/** Cross-realm-safe HTMLElement check. An element queried from the preview
+ * iframe's document is an instance of the IFRAME window's `HTMLElement`, NOT the
+ * studio window's — so a plain `node instanceof HTMLElement` is always false for
+ * preview nodes. Check against the iframe realm's constructor instead. */
+function isPreviewHtmlElement(
+ node: Element | null | undefined,
+ iframe: HTMLIFrameElement | null,
+): node is HTMLElement {
+ const Ctor = (iframe?.contentWindow as unknown as { HTMLElement?: typeof HTMLElement } | null)
+ ?.HTMLElement;
+ return Boolean(node && Ctor && node instanceof Ctor);
+}
+
+function rectsClose(a: Rect, b: Rect): boolean {
+ return (
+ Math.abs(a.left - b.left) < 0.5 &&
+ Math.abs(a.top - b.top) < 0.5 &&
+ Math.abs(a.width - b.width) < 0.5 &&
+ Math.abs(a.height - b.height) < 0.5
+ );
+}
+
+function hasMotionPathPlugin(iframe: HTMLIFrameElement | null): boolean {
+ try {
+ return Boolean(
+ (iframe?.contentWindow as unknown as { MotionPathPlugin?: unknown })?.MotionPathPlugin,
+ );
+ } catch {
+ return false;
+ }
+}
+
+/** Track the iframe rect (every frame) and the selected element's path geometry
+ * (polled lightly, so it stays fresh through seeks/edits/soft reloads). */
+function useMotionPathData(
+ iframeRef: RefObject,
+ selector: string | null,
+): {
+ rect: Rect | null;
+ geometry: MotionPathGeometry | null;
+ visibleInPreview: boolean;
+ home: { x: number; y: number } | null;
+} {
+ const [rect, setRect] = useState(null);
+ const [geometry, setGeometry] = useState(null);
+ // Whether the target element is actually painted on screen — the path hides when
+ // it isn't (e.g. covered by a later scene), matching the selection overlay.
+ const [visibleInPreview, setVisibleInPreview] = useState(true);
+ // The element's layout-home center, computed from the LIVE current-document
+ // element (see below). Path nodes are drawn at home + keyframe offset, so a
+ // stale home translates the whole path off the element.
+ const [home, setHome] = useState<{ x: number; y: number } | null>(null);
+
+ useEffect(() => {
+ if (!selector) {
+ setRect(null);
+ setHome(null);
+ return;
+ }
+ // New selector → drop the previous element's anchor immediately; the first
+ // tick recomputes it for the new element. Avoids a 1-frame path at the old home.
+ setHome(null);
+ let raf = 0;
+ const tick = () => {
+ const el = iframeRef.current;
+ if (el) {
+ const r = el.getBoundingClientRect();
+ // Position relative to the preview surface (the `relative overflow-hidden`
+ // wrapper) so the SVG is `absolute` inside it and gets clipped to the canvas
+ // — instead of `fixed`, which would paint over the side panels at zoom.
+ // NOTE: the composition iframe lives in the player's SHADOW DOM, so
+ // `el.closest()` can't reach the pan-surface (it stops at the shadow root)
+ // and would silently return null → the SVG falls back to raw viewport
+ // coords and is offset by the pan-surface's position (worsening with
+ // zoom/pan). Query the light DOM via the document instead.
+ const surface = el.ownerDocument?.querySelector("[data-preview-pan-surface]");
+ const sRect = surface?.getBoundingClientRect();
+ const next = {
+ left: sRect ? r.left - sRect.left : r.left,
+ top: sRect ? r.top - sRect.top : r.top,
+ width: r.width,
+ height: r.height,
+ };
+ setRect((prev) => (prev && rectsClose(prev, next) ? prev : next));
+ // Resolve the element in the CURRENT iframe document (same one the path
+ // geometry reads) — never trust a possibly-stale `selection.element` from a
+ // prior document. Soft-reloads (every commit) rebuild the iframe DOM, so a
+ // captured node detaches: its offsetLeft/offsetParent collapse and the
+ // computed home — hence the whole path — lands in the wrong place.
+ let target: Element | null = null;
+ try {
+ target = el.contentDocument?.querySelector(selector) ?? null;
+ } catch {
+ /* cross-origin guard */
+ }
+ const live = isPreviewHtmlElement(target, el) ? target : null;
+ const vis = live ? isElementVisibleInPreview(live) : true;
+ setVisibleInPreview((prev) => (prev === vis ? prev : vis));
+ if (live) {
+ const h = elementHome(live);
+ setHome((prev) =>
+ prev && Math.abs(prev.x - h.x) < 0.5 && Math.abs(prev.y - h.y) < 0.5 ? prev : h,
+ );
+ }
+ }
+ raf = requestAnimationFrame(tick);
+ };
+ raf = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(raf);
+ }, [selector, iframeRef]);
+
+ useEffect(() => {
+ if (!selector) {
+ setGeometry(null);
+ return;
+ }
+ // Poll the runtime: edits commit with an in-place soft reload (the timeline
+ // re-executes without an iframe load or a refresh-version bump), so there's
+ // no event to subscribe to. The read is cheap and the points-equality guard
+ // suppresses redundant re-renders. ponytail: a shared gsap-soft-reload
+ // version signal would let this (and future overlays) go event-driven —
+ // that's a cross-cutting change tracked with the soft-reload work, not here.
+ const recompute = () => {
+ const read = readRuntimeKeyframes(iframeRef.current, selector);
+ const next = buildMotionPathGeometry(read);
+ setGeometry((prev) => (prev?.points === next?.points ? prev : next));
+ };
+ recompute();
+ const id = window.setInterval(recompute, 250);
+ return () => window.clearInterval(id);
+ }, [selector, iframeRef]);
+
+ return { rect, geometry, visibleInPreview, home };
+}
+
+/**
+ * Draws the selected element's GSAP motion path over the canvas — a dashed
+ * polyline through its x/y keyframes (or motionPath waypoints) with a draggable
+ * node at each. Dragging an x/y node rewrites the keyframe; dragging a waypoint
+ * rewrites the motionPath point; both commit to source (undoable). Renders in
+ * declared composition coordinates so the path doesn't drift under GSAP
+ * transforms. Read-only (no drag) while playing or when the tween isn't
+ * statically editable. Nothing renders when the selection has no positional
+ * motion.
+ */
+// fallow-ignore-next-line complexity
+export const MotionPathOverlay = memo(function MotionPathOverlay({
+ iframeRef,
+ selection,
+ compositionSize,
+ isPlaying,
+}: MotionPathOverlayProps) {
+ const {
+ commitMutation,
+ selectedGsapAnimations,
+ handleGsapRemoveKeyframe,
+ handleGsapDeleteAllForElement,
+ } = useDomEditContext();
+ const { rect, geometry, visibleInPreview, home } = useMotionPathData(
+ iframeRef,
+ selectorFor(selection),
+ );
+ const [draft, setDraft] = useState(null);
+ const [ghost, setGhost] = useState<{ x: number; y: number; segIndex: number } | null>(null);
+ const [hoverNode, setHoverNode] = useState(null);
+ // Right-click context menu on a keyframe node — same delete actions as the
+ // timeline keyframe diamond.
+ const [kfMenu, setKfMenu] = useState(null);
+ // The keyframe % selected by clicking its node — highlighted, and the next drag
+ // modifies it rather than adding a keyframe.
+ const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
+ const dragRef = useRef(null);
+
+ // Create mode: a selected element with no positional motion. A double-click on
+ // the canvas authors a new motionPath from the element to that point.
+ const createMode = !geometry && Boolean(selection?.element) && !isPlaying;
+ // fallow-ignore-next-line complexity
+ useEffect(() => {
+ if (!createMode || !selection?.element || !compositionSize) return;
+ const targetSelector = selectorFor(selection);
+ if (!targetSelector) return;
+ // fallow-ignore-next-line complexity
+ const onDbl = (e: MouseEvent) => {
+ const iframe = iframeRef.current;
+ if (!iframe || !hasMotionPathPlugin(iframe)) return;
+ const r = iframe.getBoundingClientRect();
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) {
+ return;
+ }
+ // Resolve the element LIVE from the current iframe document — the selected
+ // node may be detached after a soft-reload, which would skew home.
+ const live = iframe.contentDocument?.querySelector(targetSelector);
+ if (!isPreviewHtmlElement(live, iframe)) return;
+ const sc = r.width / compositionSize.width;
+ const elHome = elementHome(live);
+ const px = Math.round((e.clientX - r.left) / sc - elHome.x);
+ const py = Math.round((e.clientY - r.top) / sc - elHome.y);
+ const t = Math.round(usePlayerStore.getState().currentTime * 100) / 100;
+ void commitCreatePath(targetSelector, t, px, py, commitMutation);
+ };
+ window.addEventListener("dblclick", onDbl);
+ return () => window.removeEventListener("dblclick", onDbl);
+ }, [createMode, selection, compositionSize, iframeRef, commitMutation]);
+
+ if (!rect || rect.width <= 0 || !compositionSize || compositionSize.width <= 0) return null;
+ // Hide the whole overlay (path + create hint) when the element isn't painted —
+ // same "what you see in the preview" rule as the selection box.
+ if (!visibleInPreview) return null;
+ // No live anchor (element not in the current document) → can't place the path.
+ if (!home) return null;
+
+ if (!geometry) {
+ if (!createMode || !selection?.element || !hasMotionPathPlugin(iframeRef.current)) return null;
+ const sc = rect.width / compositionSize.width;
+ const hr = (NODE_PX / sc) * 1.6;
+ return (
+
+ );
+ }
+
+ const scale = rect.width / compositionSize.width;
+ const nodeR = NODE_PX / scale;
+ const animId = editableAnimationId(selectedGsapAnimations ?? [], geometry.kind);
+ const interactive = Boolean(animId) && !isPlaying;
+ // The × "quick remove" badge applies to non-cubic motionPath arcs only (cubic
+ // anchors carry control points we don't synthesize; keyframe paths remove via
+ // the right-click menu instead).
+ const arcAnim = animId ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
+ const isCubic = arcAnim?.arcPath?.segments?.some((s) => s.cp1 != null) ?? false;
+ const structural = geometry.kind === "arc" && interactive && !isCubic;
+ const removable = structural && geometry.nodes.length > 2;
+ // Click-on-path to insert a node works for both kinds: a motionPath waypoint
+ // (non-cubic arcs), or an x/y keyframe (linear paths) at the projected tween-%.
+ const addable = interactive && (geometry.kind === "arc" ? !isCubic : true);
+
+ const nodes = draft
+ ? geometry.nodes.map((n, i) => (i === draft.index ? { ...n, x: draft.x, y: draft.y } : n))
+ : geometry.nodes;
+ // ax/ay = absolute composition position (home + offset) for drawing; n.x/n.y
+ // stay offsets so the drag commit writes the right tween values.
+ const abs = nodes.map((n) => ({ ...n, ax: home.x + n.x, ay: home.y + n.y }));
+ const points = abs.map((p) => `${p.ax},${p.ay}`).join(" ");
+ // Map a VIEWPORT pointer to composition space. Use the iframe's LIVE viewport
+ // rect, not `rect` — `rect.left/top` are stored pan-surface-relative (for the
+ // absolute-positioned SVG), so subtracting them from a viewport clientX/Y would
+ // offset the projection by the surface's gutter (panel/toolbar), and the add-
+ // ghost wouldn't track the cursor. `scale` is unaffected (width is stored raw).
+ const clientToComp = (e: React.PointerEvent) => {
+ const vr = iframeRef.current?.getBoundingClientRect();
+ const left = vr ? vr.left : rect.left;
+ const top = vr ? vr.top : rect.top;
+ return { x: (e.clientX - left) / scale, y: (e.clientY - top) / scale };
+ };
+
+ const onDown = (
+ e: React.PointerEvent,
+ index: number,
+ x: number,
+ y: number,
+ ref: MotionNodeRef,
+ ) => {
+ if (!interactive) return;
+ e.stopPropagation();
+ (e.target as Element).setPointerCapture(e.pointerId);
+ dragRef.current = {
+ index,
+ startX: e.clientX,
+ startY: e.clientY,
+ initX: x,
+ initY: y,
+ scale,
+ ref,
+ };
+ setDraft({ index, x, y });
+ };
+ const onMove = (e: React.PointerEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+ setDraft({
+ index: d.index,
+ x: d.initX + (e.clientX - d.startX) / d.scale,
+ y: d.initY + (e.clientY - d.startY) / d.scale,
+ });
+ };
+ // fallow-ignore-next-line complexity
+ const onUp = (e: React.PointerEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+ dragRef.current = null;
+ setDraft(null);
+ if (!animId) return;
+ const screenDx = e.clientX - d.startX;
+ const screenDy = e.clientY - d.startY;
+ const x = Math.round(d.initX + screenDx / d.scale);
+ const y = Math.round(d.initY + screenDy / d.scale);
+ if (x === Math.round(d.initX) && y === Math.round(d.initY)) {
+ // No drag → treat as a click: select this keyframe and park the playhead on
+ // it. Selecting it makes the next drag MODIFY this keyframe (honored via
+ // activeKeyframePct) instead of creating a new one.
+ if (d.ref.type === "keyframe") {
+ usePlayerStore.getState().setActiveKeyframePct(d.ref.pct);
+ const anim = selectedGsapAnimations?.find((a) => a.id === animId);
+ if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct);
+ }
+ return; // no commit
+ }
+ void commitNode(d.ref, x, y, animId, commitMutation);
+ // Park the playhead on the edited keyframe's time so the element previews AT
+ // that keyframe. Without it, a playhead sitting before the tween renders the
+ // element's base pose — the edit (correct on the path) looks like it vanished.
+ if (d.ref.type === "keyframe") {
+ const anim = selectedGsapAnimations?.find((a) => a.id === animId);
+ if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct);
+ }
+ };
+
+ // Ghost "add" affordance: project the cursor onto the path; click inserts.
+ const onPathHover = (e: React.PointerEvent) => {
+ const c = clientToComp(e);
+ const np = nearestPointOnPath(
+ c.x,
+ c.y,
+ abs.map((p) => ({ x: p.ax, y: p.ay })),
+ );
+ setGhost(np ? { x: np.x, y: np.y, segIndex: np.segIndex } : null);
+ };
+ const onPathDown = (e: React.PointerEvent) => {
+ if (!animId) return;
+ // Compute the insertion point from the event directly so a click works
+ // without (or faster than) a preceding hover.
+ const c = clientToComp(e);
+ const np = nearestPointOnPath(
+ c.x,
+ c.y,
+ abs.map((p) => ({ x: p.ax, y: p.ay })),
+ );
+ if (!np) return;
+ const x = Math.round(np.x - home.x);
+ const y = Math.round(np.y - home.y);
+ if (geometry.kind === "arc") {
+ e.stopPropagation();
+ void commitAddWaypoint(animId, np.segIndex + 1, x, y, commitMutation);
+ } else {
+ // Linear keyframe path: interpolate the new stop's tween-% from the two
+ // keyframes bounding the clicked segment (np.t = fraction along it), then
+ // insert it. Lands ON the current line, so the dot doesn't jump — drag it
+ // after to bend the path.
+ const a = abs[np.segIndex]?.ref;
+ const b = abs[np.segIndex + 1]?.ref;
+ if (a?.type !== "keyframe" || b?.type !== "keyframe") return;
+ const pct = Math.round((a.pct + (b.pct - a.pct) * np.t) * 1000) / 1000;
+ e.stopPropagation();
+ void commitAddKeyframe(animId, pct, x, y, commitMutation);
+ }
+ setGhost(null);
+ };
+ const onRemove = (e: React.PointerEvent, index: number) => {
+ e.stopPropagation();
+ if (!animId) return;
+ setHoverNode(null);
+ void commitRemoveWaypoint(animId, index, commitMutation);
+ };
+
+ const elementId = selection?.id ?? null;
+ // Right-click a keyframe node → the timeline's keyframe context menu (delete
+ // this keyframe / delete all), so motion-path keyframes are removable in place.
+ const onNodeContextMenu = (e: React.MouseEvent, ref: MotionNodeRef) => {
+ if (ref.type !== "keyframe" || !animId || !elementId) return;
+ e.preventDefault();
+ e.stopPropagation();
+ setKfMenu({
+ x: e.clientX,
+ y: e.clientY,
+ elementId,
+ percentage: ref.pct,
+ tweenPercentage: ref.pct,
+ });
+ };
+
+ return (
+ <>
+
+ {kfMenu && (
+ setKfMenu(null)}
+ onDelete={(_elId, pct) => animId && handleGsapRemoveKeyframe(animId, pct)}
+ onDeleteAll={(elId) => handleGsapDeleteAllForElement(`#${elId}`)}
+ />
+ )}
+ >
+ );
+});
diff --git a/packages/studio/src/components/editor/useDomEditOverlayRects.ts b/packages/studio/src/components/editor/useDomEditOverlayRects.ts
index a0c72e083..a922d4404 100644
--- a/packages/studio/src/components/editor/useDomEditOverlayRects.ts
+++ b/packages/studio/src/components/editor/useDomEditOverlayRects.ts
@@ -10,7 +10,7 @@ import {
type OverlayRect,
type ResolvedElementRef,
groupOverlayItemsEqual,
- isElementVisibleForOverlay,
+ isElementVisibleInPreview,
rectsEqual,
resolveElementForOverlay,
selectionCacheKey,
@@ -148,7 +148,7 @@ export function useDomEditOverlayRects({
activeCompositionPathRef.current,
resolvedElementRef as ResolvedElementRef,
);
- if (el && isElementVisibleForOverlay(el)) {
+ if (el && isElementVisibleInPreview(el)) {
const nextRect = toOverlayRect(overlayEl, iframe, el);
setOverlayRect(nextRect);
const descendants = el.querySelectorAll("*");