Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions packages/studio/src/components/editor/DomEditOverlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,6 @@ describe("DomEditOverlay", () => {
};

let currentSelection: DomEditSelection | null = selection;
const onToggleRecording = vi.fn();
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
HTMLDivElement.prototype.setPointerCapture = () => {};
Expand All @@ -298,8 +297,6 @@ describe("DomEditOverlay", () => {
hoverSelection: null,
onSelectionChange: (next: DomEditSelection) => setSelected(next),
}),
recordingState: "idle",
onToggleRecording,
});
}

Expand Down Expand Up @@ -340,16 +337,6 @@ describe("DomEditOverlay", () => {
"drag",
expect.objectContaining({ button: 0 }),
);
const recordButton = host.querySelector(
'[aria-label="Record gesture (R)"]',
) as HTMLButtonElement;
expect(recordButton).toBeTruthy();

act(() => {
recordButton.click();
});

expect(onToggleRecording).toHaveBeenCalledTimes(1);

act(() => {
root.unmount();
Expand Down
11 changes: 1 addition & 10 deletions packages/studio/src/components/editor/DomEditOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
import { GridOverlay } from "./GridOverlay";
import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl";
import type { GestureRecordingState } from "./GestureRecordControl";

// Re-exports for external consumers — preserving existing import paths.
export {
Expand Down Expand Up @@ -87,8 +87,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
onGroupPathOffsetCommit,
onBoxSizeCommit,
onRotationCommit,
recordingState,
onToggleRecording,
}: DomEditOverlayProps) {
const overlayRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -434,13 +432,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
/>
</div>
)}
{onToggleRecording && (
<GestureRecordBadge
rect={overlayRect}
recordingState={recordingState}
onToggleRecording={onToggleRecording}
/>
)}
<div
key={selectionKey}
ref={boxRef}
Expand Down
33 changes: 28 additions & 5 deletions packages/studio/src/components/editor/MotionPathOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
import {
buildMotionPathGeometry,
nearestPointOnPath,
Expand Down Expand Up @@ -188,7 +188,11 @@ function useMotionPathData(
/* cross-origin guard */
}
const live = isPreviewHtmlElement(target, el) ? target : null;
const vis = live ? isElementVisibleInPreview(live) : true;
// Basic visibility (display/visibility/opacity), NOT the occlusion heuristic:
// the motion path belongs to the explicitly-selected element, so an opacity-1
// backgroundless scene "covering" it must not suppress the path — same reason
// the selection overlay uses isElementVisibleForOverlay (see useDomEditOverlayRects).
const vis = live ? isElementVisibleForOverlay(live) : true;
setVisibleInPreview((prev) => (prev === vis ? prev : vis));
if (live) {
const h = elementHome(live);
Expand Down Expand Up @@ -271,6 +275,17 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
// modifies it rather than adding a keyframe.
const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
const dragRef = useRef<DragState | null>(null);
// Park-on-click is debounced so a double-click cancels the seek (see onUp).
const parkTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// The animation id whose path is currently editable. Computed at hook level (not
// just in render, after the early returns) so the park-timer cleanup can key on
// it: a pending park seek belongs to the OLD animation, so firing it after the
// active animation changed would jump the playhead onto a stale keyframe.
const animId = editableAnimationId(selectedGsapAnimations ?? [], geometry?.kind ?? "linear");
// Clear the debounced park timer on unmount AND whenever the active animation id
// changes — not unmount-only, or a queued seek from the previous selection still
// fires against the new one.
useEffect(() => () => clearTimeout(parkTimerRef.current), [animId]);

// 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. Gated on
Expand Down Expand Up @@ -368,7 +383,6 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({

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
Expand Down Expand Up @@ -408,6 +422,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
ref: MotionNodeRef,
) => {
if (!interactive) return;
if (e.button !== 0) return; // primary button only — right-click is the context menu
e.stopPropagation();
(e.target as Element).setPointerCapture(e.pointerId);
dragRef.current = {
Expand Down Expand Up @@ -453,8 +468,16 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
// 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);
const ref = d.ref;
// Debounce the playhead seek: a double-click cancels it (e.detail >= 2),
// so only a lone single-click parks the playhead on the keyframe.
clearTimeout(parkTimerRef.current);
if (e.detail < 2) {
parkTimerRef.current = setTimeout(() => {
const anim = selectedGsapAnimations?.find((a) => a.id === animId);
if (anim) parkPlayheadOnKeyframe(anim, ref.pct);
}, 250);
}
}
return; // no commit
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { type DomEditSelection } from "./domEditing";
import {
createManualOffsetDragMember,
readGsapRotation,
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
Expand Down Expand Up @@ -115,7 +116,10 @@ export function startGesture(
return false;

const size = readStudioBoxSize(sel.element);
const rotation = readStudioRotation(sel.element);
// Single-source rotation base = the live GSAP transform rotation plus any legacy
// `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
// element's actual visual angle and commits an absolute angle to the timeline.
const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX;
const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY;
let initialPathOffset = captureStudioPathOffset(sel.element);
Expand Down
26 changes: 26 additions & 0 deletions packages/studio/src/components/editor/domEditingDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,31 @@ export function getElementDepth(el: HTMLElement): number {

// ─── Composition source resolution ───────────────────────────────────────────

// The runtime INLINES subcompositions and strips the source-file linkage from the
// mounted root (it keeps `data-composition-id` but drops `data-composition-src`/
// `-file`), so a subcomp element's DOM ancestors no longer say which file it came
// from. This project-global map (composition-id → source file, built once from
// index.html's clips — see NLELayout) recovers it. The studio loads one project at a
// time, so module scope is the right lifetime; it's empty until set, in which case
// resolution falls back to the historical attribute-only behavior.
let compositionSourceMap: Map<string, string> = new Map();

export function setCompositionSourceMap(map: Map<string, string>): void {
compositionSourceMap = map;
}

function sourceFromCompositionId(ownerRoot: HTMLElement | null): string | undefined {
if (!ownerRoot || compositionSourceMap.size === 0) return undefined;
// The runtime may rename the mounted id to a runtime-unique one, preserving the
// authored id on `data-hf-original-composition-id` — prefer that, then the current id.
const authored = ownerRoot.getAttribute("data-hf-original-composition-id");
const current = ownerRoot.getAttribute("data-composition-id");
return (
(authored ? compositionSourceMap.get(authored) : undefined) ??
(current ? compositionSourceMap.get(current) : undefined)
);
}

export function getSourceFileForElement(
el: HTMLElement,
activeCompositionPath: string | null,
Expand All @@ -152,6 +177,7 @@ export function getSourceFileForElement(
sourceHost?.getAttribute("data-composition-src") ??
ownerRoot?.getAttribute("data-composition-file") ??
ownerRoot?.getAttribute("data-composition-src") ??
sourceFromCompositionId(ownerRoot) ??
activeCompositionPath ??
"index.html";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,6 @@ describe("manual editing availability", () => {
expect(availability.STUDIO_INSPECTOR_PANELS_ENABLED).toBe(true);
});

it("enables GSAP drag intercept by default", async () => {
const availability = await loadAvailabilityWithEnv({});
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(true);
});

it("keeps color grading off by default", async () => {
const availability = await loadAvailabilityWithEnv({});
expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(false);
Expand All @@ -41,13 +36,6 @@ describe("manual editing availability", () => {
expect(availability.STUDIO_COLOR_GRADING_ENABLED).toBe(true);
});

it("disables GSAP drag intercept when env var is false", async () => {
const availability = await loadAvailabilityWithEnv({
VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT: "false",
});
expect(availability.STUDIO_GSAP_DRAG_INTERCEPT_ENABLED).toBe(false);
});

it("disables preview selection when the inspector panel flag is explicitly off", async () => {
const availability = await loadAvailabilityWithEnv({
VITE_STUDIO_ENABLE_INSPECTOR_PANELS: "0",
Expand Down
10 changes: 0 additions & 10 deletions packages/studio/src/components/editor/manualEditingAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,6 @@ export const STUDIO_STORYBOARD_ENABLED = resolveStudioBooleanEnvFlag(
false,
);

// When disabled (the default), drag/resize/rotate commits always take the CSS
// persist path instead of being intercepted into GSAP script keyframe
// mutations. The keyframe intercept rewrites timeline tweens from drag
// gestures and is opt-in until its recording path is hardened.
export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_ENABLE_GSAP_DRAG_INTERCEPT", "VITE_STUDIO_GSAP_DRAG_INTERCEPT_ENABLED"],
true,
);

export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;

// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/components/editor/manualEdits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
endStudioManualEditGesture,
isStudioManualEditGestureCurrent,
readStudioPathOffset,
readAppliedStudioPathOffset,
readStudioBoxSize,
readStudioRotation,
applyStudioPathOffset,
Expand Down
13 changes: 13 additions & 0 deletions packages/studio/src/components/editor/manualEditsDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ export function readStudioPathOffset(element: HTMLElement): { x: number; y: numb
};
}

/**
* The path offset ACTUALLY applied right now. The `--hf-studio-offset` vars can
* linger after GSAP re-bakes the element's transform (`translate:"none"`), so the
* raw var isn't a safe drag base — using it re-commits a phantom offset and flings
* the element off-screen. The offset only counts when the inline `translate` is the
* studio var-translate; otherwise it's dormant and the applied offset is zero.
*/
export function readAppliedStudioPathOffset(element: HTMLElement): { x: number; y: number } {
return (element.style.translate || "").includes(STUDIO_OFFSET_X_PROP)
? readStudioPathOffset(element)
: { x: 0, y: 0 };
}

export function readStudioBoxSize(element: HTMLElement): { width: number; height: number } {
return {
width: readPxCustomProperty(element, STUDIO_WIDTH_PROP),
Expand Down
12 changes: 12 additions & 0 deletions packages/studio/src/components/editor/manualOffsetDrag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {

element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
element.style.setProperty(STUDIO_OFFSET_Y_PROP, "10px");
// Old projects bake the offset by referencing the vars in the inline
// `translate` longhand — that's what makes the offset "applied" and thus the
// valid drag base (readAppliedStudioPathOffset). A raw var with no applied
// translate is dormant and reads as zero. Assign the typed `.translate`
// accessor (happy-dom doesn't surface it via setProperty).
element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
element.style.setProperty("transform", "translate(50px, -15px)");

element.getBoundingClientRect = () => {
Expand Down Expand Up @@ -228,6 +234,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
// Simulate GSAP baking a translate into transform each cycle
for (let cycle = 0; cycle < 3; cycle++) {
element.style.setProperty("transform", `translate(${50 * (cycle + 1)}px, 0px)`);
// Mark the offset as APPLIED (the inline translate references the studio
// vars, the form an old project bakes) so readAppliedStudioPathOffset reads
// the var, not zero. Without this the var is dormant and reads as zero.
// Assign the typed `.translate` accessor (happy-dom doesn't surface it via
// setProperty).
element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;

const result = createManualOffsetDragMember({
key: "test",
Expand Down
Loading
Loading