Skip to content

Commit 8bf425e

Browse files
committed
feat(studio): single-source manual offset + rotation via the GSAP timeline
Dragging or rotating an element writes into the GSAP timeline (the single source of truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and the live preview moves via gsap.set so what you see equals what is written and renders. Removes the dual-channel CSS-var/transform reconciliation behind the fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/ keyframe commit, dropped the handleDom*Commit CSS fallbacks). Subcompositions edit the same single-source way, which surfaced and fixes: - resolve a subcomp element's source file via the composition-id map (the runtime drops the source linkage when inlining the subcomposition); - a selected element's selection box AND motion path use basic visibility, not the occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover); - soft reload rebuilds ONLY the committed composition's timeline, leaving other compositions' timelines intact (no cross-composition revert); - read keyframes from the element's OWN composition timeline (scan all timelines, not the first unstable key); - delete-all uses a soft reload too, so editing no longer hard-reloads the iframe.
1 parent 3b5a100 commit 8bf425e

23 files changed

Lines changed: 607 additions & 244 deletions

packages/studio/src/components/editor/DomEditOverlay.test.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,6 @@ describe("DomEditOverlay", () => {
282282
};
283283

284284
let currentSelection: DomEditSelection | null = selection;
285-
const onToggleRecording = vi.fn();
286285
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
287286
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
288287
HTMLDivElement.prototype.setPointerCapture = () => {};
@@ -298,8 +297,6 @@ describe("DomEditOverlay", () => {
298297
hoverSelection: null,
299298
onSelectionChange: (next: DomEditSelection) => setSelected(next),
300299
}),
301-
recordingState: "idle",
302-
onToggleRecording,
303300
});
304301
}
305302

@@ -340,16 +337,6 @@ describe("DomEditOverlay", () => {
340337
"drag",
341338
expect.objectContaining({ button: 0 }),
342339
);
343-
const recordButton = host.querySelector(
344-
'[aria-label="Record gesture (R)"]',
345-
) as HTMLButtonElement;
346-
expect(recordButton).toBeTruthy();
347-
348-
act(() => {
349-
recordButton.click();
350-
});
351-
352-
expect(onToggleRecording).toHaveBeenCalledTimes(1);
353340

354341
act(() => {
355342
root.unmount();

packages/studio/src/components/editor/DomEditOverlay.tsx

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
1414
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
1515
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
1616
import { GridOverlay } from "./GridOverlay";
17-
import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl";
17+
import type { GestureRecordingState } from "./GestureRecordControl";
1818

1919
// Re-exports for external consumers — preserving existing import paths.
2020
export {
@@ -87,8 +87,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
8787
onGroupPathOffsetCommit,
8888
onBoxSizeCommit,
8989
onRotationCommit,
90-
recordingState,
91-
onToggleRecording,
9290
}: DomEditOverlayProps) {
9391
const overlayRef = useRef<HTMLDivElement | null>(null);
9492
const boxRef = useRef<HTMLDivElement | null>(null);
@@ -434,13 +432,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
434432
/>
435433
</div>
436434
)}
437-
{onToggleRecording && (
438-
<GestureRecordBadge
439-
rect={overlayRect}
440-
recordingState={recordingState}
441-
onToggleRecording={onToggleRecording}
442-
/>
443-
)}
444435
<div
445436
key={selectionKey}
446437
ref={boxRef}

packages/studio/src/components/editor/MotionPathOverlay.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useDomEditContext } from "../../contexts/DomEditContext";
44
import { usePlayerStore } from "../../player/store/playerStore";
55
import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
66
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
7-
import { isElementVisibleInPreview } from "./domEditOverlayGeometry";
7+
import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
88
import {
99
buildMotionPathGeometry,
1010
nearestPointOnPath,
@@ -174,7 +174,11 @@ function useMotionPathData(
174174
/* cross-origin guard */
175175
}
176176
const live = isPreviewHtmlElement(target, el) ? target : null;
177-
const vis = live ? isElementVisibleInPreview(live) : true;
177+
// Basic visibility (display/visibility/opacity), NOT the occlusion heuristic:
178+
// the motion path belongs to the explicitly-selected element, so an opacity-1
179+
// backgroundless scene "covering" it must not suppress the path — same reason
180+
// the selection overlay uses isElementVisibleForOverlay (see useDomEditOverlayRects).
181+
const vis = live ? isElementVisibleForOverlay(live) : true;
178182
setVisibleInPreview((prev) => (prev === vis ? prev : vis));
179183
if (live) {
180184
const h = elementHome(live);
@@ -250,6 +254,9 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
250254
// modifies it rather than adding a keyframe.
251255
const activeKeyframePct = usePlayerStore((s) => s.activeKeyframePct);
252256
const dragRef = useRef<DragState | null>(null);
257+
// Park-on-click is debounced so a double-click cancels the seek (see onUp).
258+
const parkTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
259+
useEffect(() => () => clearTimeout(parkTimerRef.current), []);
253260

254261
// Create mode: a selected element with no positional motion. A double-click on
255262
// the canvas authors a new motionPath from the element to that point.
@@ -373,6 +380,7 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
373380
ref: MotionNodeRef,
374381
) => {
375382
if (!interactive) return;
383+
if (e.button !== 0) return; // primary button only — right-click is the context menu
376384
e.stopPropagation();
377385
(e.target as Element).setPointerCapture(e.pointerId);
378386
dragRef.current = {
@@ -412,8 +420,16 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
412420
// activeKeyframePct) instead of creating a new one.
413421
if (d.ref.type === "keyframe") {
414422
usePlayerStore.getState().setActiveKeyframePct(d.ref.pct);
415-
const anim = selectedGsapAnimations?.find((a) => a.id === animId);
416-
if (anim) parkPlayheadOnKeyframe(anim, d.ref.pct);
423+
const ref = d.ref;
424+
// Debounce the playhead seek: a double-click cancels it (e.detail >= 2),
425+
// so only a lone single-click parks the playhead on the keyframe.
426+
clearTimeout(parkTimerRef.current);
427+
if (e.detail < 2) {
428+
parkTimerRef.current = setTimeout(() => {
429+
const anim = selectedGsapAnimations?.find((a) => a.id === animId);
430+
if (anim) parkPlayheadOnKeyframe(anim, ref.pct);
431+
}, 250);
432+
}
417433
}
418434
return; // no commit
419435
}

packages/studio/src/components/editor/domEditOverlayStartGesture.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { type DomEditSelection } from "./domEditing";
66
import {
77
createManualOffsetDragMember,
8+
readGsapRotation,
89
restoreManualOffsetDragMembers,
910
type ManualOffsetDragMember,
1011
} from "./manualOffsetDrag";
@@ -115,7 +116,10 @@ export function startGesture(
115116
return false;
116117

117118
const size = readStudioBoxSize(sel.element);
118-
const rotation = readStudioRotation(sel.element);
119+
// Single-source rotation base = the live GSAP transform rotation plus any legacy
120+
// `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
121+
// element's actual visual angle and commits an absolute angle to the timeline.
122+
const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
119123
const actualWidth = size.width > 0 ? size.width : rect.width / rect.editScaleX;
120124
const actualHeight = size.height > 0 ? size.height : rect.height / rect.editScaleY;
121125
let initialPathOffset = captureStudioPathOffset(sel.element);

packages/studio/src/components/editor/domEditingDom.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,31 @@ export function getElementDepth(el: HTMLElement): number {
141141

142142
// ─── Composition source resolution ───────────────────────────────────────────
143143

144+
// The runtime INLINES subcompositions and strips the source-file linkage from the
145+
// mounted root (it keeps `data-composition-id` but drops `data-composition-src`/
146+
// `-file`), so a subcomp element's DOM ancestors no longer say which file it came
147+
// from. This project-global map (composition-id → source file, built once from
148+
// index.html's clips — see NLELayout) recovers it. The studio loads one project at a
149+
// time, so module scope is the right lifetime; it's empty until set, in which case
150+
// resolution falls back to the historical attribute-only behavior.
151+
let compositionSourceMap: Map<string, string> = new Map();
152+
153+
export function setCompositionSourceMap(map: Map<string, string>): void {
154+
compositionSourceMap = map;
155+
}
156+
157+
function sourceFromCompositionId(ownerRoot: HTMLElement | null): string | undefined {
158+
if (!ownerRoot || compositionSourceMap.size === 0) return undefined;
159+
// The runtime may rename the mounted id to a runtime-unique one, preserving the
160+
// authored id on `data-hf-original-composition-id` — prefer that, then the current id.
161+
const authored = ownerRoot.getAttribute("data-hf-original-composition-id");
162+
const current = ownerRoot.getAttribute("data-composition-id");
163+
return (
164+
(authored ? compositionSourceMap.get(authored) : undefined) ??
165+
(current ? compositionSourceMap.get(current) : undefined)
166+
);
167+
}
168+
144169
export function getSourceFileForElement(
145170
el: HTMLElement,
146171
activeCompositionPath: string | null,
@@ -152,6 +177,7 @@ export function getSourceFileForElement(
152177
sourceHost?.getAttribute("data-composition-src") ??
153178
ownerRoot?.getAttribute("data-composition-file") ??
154179
ownerRoot?.getAttribute("data-composition-src") ??
180+
sourceFromCompositionId(ownerRoot) ??
155181
activeCompositionPath ??
156182
"index.html";
157183

packages/studio/src/components/editor/manualEdits.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export {
1616
endStudioManualEditGesture,
1717
isStudioManualEditGestureCurrent,
1818
readStudioPathOffset,
19+
readAppliedStudioPathOffset,
1920
readStudioBoxSize,
2021
readStudioRotation,
2122
applyStudioPathOffset,

packages/studio/src/components/editor/manualEditsDom.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,19 @@ export function readStudioPathOffset(element: HTMLElement): { x: number; y: numb
7070
};
7171
}
7272

73+
/**
74+
* The path offset ACTUALLY applied right now. The `--hf-studio-offset` vars can
75+
* linger after GSAP re-bakes the element's transform (`translate:"none"`), so the
76+
* raw var isn't a safe drag base — using it re-commits a phantom offset and flings
77+
* the element off-screen. The offset only counts when the inline `translate` is the
78+
* studio var-translate; otherwise it's dormant and the applied offset is zero.
79+
*/
80+
export function readAppliedStudioPathOffset(element: HTMLElement): { x: number; y: number } {
81+
return (element.style.translate || "").includes(STUDIO_OFFSET_X_PROP)
82+
? readStudioPathOffset(element)
83+
: { x: 0, y: 0 };
84+
}
85+
7386
export function readStudioBoxSize(element: HTMLElement): { width: number; height: number } {
7487
return {
7588
width: readPxCustomProperty(element, STUDIO_WIDTH_PROP),

packages/studio/src/components/editor/manualOffsetDrag.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,12 @@ describe("createManualOffsetDragMember uses raw CSS var offset", () => {
193193

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

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

232244
const result = createManualOffsetDragMember({
233245
key: "test",

packages/studio/src/components/editor/manualOffsetDrag.ts

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,71 @@ import {
55
beginStudioManualEditGesture,
66
captureStudioPathOffset,
77
endStudioManualEditGesture,
8-
readStudioPathOffset,
8+
readAppliedStudioPathOffset,
99
restoreStudioPathOffset,
1010
type StudioPathOffsetSnapshot,
1111
} from "./manualEdits";
12+
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
13+
14+
interface OffsetDragGsap {
15+
set: (el: Element, vars: Record<string, number | string>) => void;
16+
getProperty: (el: Element, prop: string) => number;
17+
}
18+
19+
function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
20+
const win = element.ownerDocument.defaultView as
21+
| (Window & { gsap?: Partial<OffsetDragGsap> })
22+
| null;
23+
const gsap = win?.gsap;
24+
return gsap?.set && gsap.getProperty ? (gsap as OffsetDragGsap) : null;
25+
}
26+
27+
/**
28+
* Live drag preview through the GSAP channel — the SAME channel the commit
29+
* lands in (a `tl.set`/keyframe on the timeline), so what the user sees while
30+
* dragging equals what gets written (plan R3/R4). Reuses the commit's
31+
* base+delta+rotation math so preview and commit agree by construction. Returns
32+
* true when handled via gsap; false when gsap is unavailable (caller falls back
33+
* to the CSS draft).
34+
*/
35+
function applyOffsetDragDraftViaGsap(
36+
element: HTMLElement,
37+
offset: { x: number; y: number },
38+
): boolean {
39+
const gsap = getOffsetDragGsap(element);
40+
if (!gsap) return false;
41+
// GSAP owns the transform; neutralize the CSS translate longhand so the two
42+
// channels can't compose into a doubled position.
43+
element.style.setProperty("translate", "none");
44+
const fallbackBase = {
45+
x: Number(gsap.getProperty(element, "x")) || 0,
46+
y: Number(gsap.getProperty(element, "y")) || 0,
47+
};
48+
const { newX, newY } = computeDraggedGsapPosition(element, offset, fallbackBase);
49+
gsap.set(element, { x: newX, y: newY });
50+
return true;
51+
}
52+
53+
/**
54+
* Live rotation preview through the GSAP channel — the SAME channel the commit
55+
* lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
56+
* GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
57+
* the two channels from composing. `angle` is the absolute target rotation. Returns
58+
* false when gsap is unavailable (caller falls back to the CSS draft).
59+
*/
60+
export function applyRotationDraftViaGsap(element: HTMLElement, angle: number): boolean {
61+
const gsap = getOffsetDragGsap(element);
62+
if (!gsap) return false;
63+
element.style.setProperty("rotate", "none");
64+
gsap.set(element, { rotation: angle });
65+
return true;
66+
}
67+
68+
/** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */
69+
export function readGsapRotation(element: HTMLElement): number {
70+
const gsap = getOffsetDragGsap(element);
71+
return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0;
72+
}
1273

1374
const DEFAULT_OFFSET_PROBE_PX = 100;
1475
const MIN_PROBE_VECTOR_LENGTH_PX = 0.01;
@@ -241,7 +302,10 @@ export function createManualOffsetDragMember(input: {
241302
element: HTMLElement;
242303
rect: ManualOffsetDragRect;
243304
}): ManualOffsetDragMemberResult {
244-
const initialOffset = readStudioPathOffset(input.element);
305+
// Base the drag on the offset ACTUALLY applied, never the raw (possibly dormant)
306+
// var — see readAppliedStudioPathOffset. This keeps the commit purely relative
307+
// (applied + delta) so a stale offset can't fling the element off-screen.
308+
const initialOffset = readAppliedStudioPathOffset(input.element);
245309
input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
246310
input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
247311

@@ -335,7 +399,12 @@ export function applyManualOffsetDragDraft(
335399
dy: number,
336400
): { x: number; y: number } {
337401
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
338-
applyStudioPathOffsetDraft(member.element, offset);
402+
// Position is single-sourced on the GSAP timeline; preview through gsap.set so
403+
// the live draft matches the committed `tl.set`/keyframe. CSS draft only when
404+
// gsap is unavailable (no preview iframe runtime).
405+
if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
406+
applyStudioPathOffsetDraft(member.element, offset);
407+
}
339408
return offset;
340409
}
341410

@@ -345,7 +414,13 @@ export function applyManualOffsetDragCommit(
345414
dy: number,
346415
): { x: number; y: number } {
347416
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
348-
applyStudioPathOffset(member.element, offset);
417+
// Optimistic visual through the GSAP channel (same as the live draft and the
418+
// committed `tl.set`), so the element holds its dropped position until the
419+
// source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
420+
// CSS apply only when gsap is unavailable.
421+
if (!applyOffsetDragDraftViaGsap(member.element, offset)) {
422+
applyStudioPathOffset(member.element, offset);
423+
}
349424
return offset;
350425
}
351426

0 commit comments

Comments
 (0)