Skip to content

Commit dc244d7

Browse files
fix(studio): drags after a resize, undo or reload land where they were pressed (#5088)
* test(studio): bench moves pressed right after Add keyframe * fix(studio): an undo that reloads the preview keeps the selection, so a drag after it moves * fix(studio): a drag started while a resize saves keeps its animations paused * fix(parsers): an edit pins no position on a keyframed tween it did not touch * fix(studio): a move pressed while the preview changes lands on the new preview, at the time pressed * test(studio): bench waiting presses, a held box resized twice, and reloads at the last playhead * refactor(studio): tidy the waiting press, bound its wait, and freeze edit stamps after early exits * test(studio): load the property panel once so its first test stops timing out * fix(studio): presses queued on a held box keep their release, hover and newest outline * test(studio): judge bench undo by the history groups Studio claims, not by file writes * refactor(studio): one waiting-box lookup in the bench and one raf-pause write per release Behaviour-identical tidy of the waiting-press work: the bench reads the waiting box through one helper, releaseActiveGesture sets the raf pause once, clearPointerState writes the false it always computed, and previewGestureStarts calls the opts setters directly instead of through aliases named like the gesture hook's gated setters. * fix(studio): a queued press lands on the element it pressed and survives a window switch * fix(studio): undo cancels a waiting press, a stuck save stops holding presses, scene swaps hold them * refactor(studio): keep the complexity waiver on clearPointerState itself * fix(studio): undo of a queued press leaves nothing waiting, and hosts keep their own undo * fix(studio): a waiting press keeps the box still after a blur, and undo reports what it took back * style(studio): bring the PR's comments within the comment limits * fix(studio): bank the four bench cases this fixes and meet the comment, complexity and duplication gates * test(studio): pin that a waiting press is never a pending edit, and name the new duplication waivers
1 parent 34552a2 commit dc244d7

63 files changed

Lines changed: 2479 additions & 1200 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.fallowrc.jsonc‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,15 @@
524524
"packages/studio/src/components/editor/domEditOverlayStartGesture.test.ts",
525525
"packages/studio/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts",
526526
"packages/studio/src/components/editor/rotateInlineSave.test.ts",
527+
// Waiting-press and drag tests: each case spells out its own press, reload and release; a shared
528+
// helper would hide the sequence each one asserts.
529+
"packages/studio/src/components/editor/domEditOverlayStartGesture.blocked.test.ts",
530+
"packages/studio/src/components/editor/dragUndoPaint.test.ts",
531+
"packages/studio/src/components/editor/manualOffsetDrag.test.ts",
532+
"packages/studio/src/components/editor/gestureHoldsPreviewReload.test.tsx",
533+
// Preview reload tests: each case spells out its own mount, reload and swap sequence.
534+
"packages/studio/src/player/hooks/useTimelinePlayer.shadowReload.test.ts",
535+
"packages/studio/src/components/nle/NLEContext.test.ts",
527536
"packages/studio/src/hooks/useDomGeometryCommits.test.tsx",
528537
// extractionCache.test.ts: pre-existing parallel describe setup and publish-case arrange/assert blocks.
529538
"packages/engine/src/services/extractionCache.test.ts",

‎packages/parsers/src/gsapConstants.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export interface HoldScope {
147147
const tweenSignature = (a: GsapAnimation) =>
148148
JSON.stringify([a.targetSelector, a.method, a.position, a.duration, a.properties, a.keyframes]);
149149

150-
/** The tweens an edit touched (not in `previous`; none without it), and what the script's holds already pin. */
150+
/** The tweens an edit touched (not in `previous`; all without it), and what the script's holds already pin. */
151151
export function holdScope(
152152
before: readonly GsapAnimation[],
153153
previous: readonly GsapAnimation[] | null,
@@ -160,14 +160,14 @@ export function holdScope(
160160
held.set(a.targetSelector, props);
161161
}
162162
return {
163-
touched: (a) => previous !== null && !kept.has(tweenSignature(a)),
163+
touched: (a) => previous === null || !kept.has(tweenSignature(a)),
164164
held: (selector) => held.get(selector) ?? new Map(),
165165
};
166166
}
167167

168168
/**
169-
* What a Studio hold pins from t=0: a keyframed tween's 0% position props before a later start, and the position and
170-
* size of a lone key (GSAP renders none) only where this edit made it or a hold already pins it, minus earlier tweens.
169+
* What a Studio hold pins from t=0, only for a tween this edit touched or a hold already pins: the 0% position props
170+
* before a later start, and the position and size of a lone key (GSAP renders none), minus earlier tweens.
171171
*/
172172
export function keyframeHoldForAnimation(
173173
animation: GsapAnimation,
@@ -196,9 +196,10 @@ export function keyframeHoldForAnimation(
196196
// An untouched tween keeps only the hold it made: one with its own first value.
197197
const kept = (property: string, value: unknown) =>
198198
touched ? pinned.has(property) : pinned.get(property) === value;
199+
const startsLate = start > 0.001;
199200
const pins = (group: PropertyGroupName, property: string, value: unknown) =>
200-
(group === "position" && start > 0.001) ||
201-
((group === "position" || group === "size") && ((lone && touched) || kept(property, value)));
201+
(group === "position" || group === "size") &&
202+
(((lone || (group === "position" && startsLate)) && touched) || kept(property, value));
202203
const hold: Record<string, number> = {};
203204
for (const [property, value] of Object.entries(atStart.properties)) {
204205
if (!pins(classifyPropertyGroup(property), property, value) || typeof value !== "number")

‎packages/parsers/src/gsapParser.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1787,6 +1787,15 @@ describe("keyframe mutations", () => {
17871787
expect((out2.match(/hf-hold/g) ?? []).length).toBe(1); // still just one
17881788
});
17891789

1790+
it.each([
1791+
["recast", syncPositionHoldsBeforeKeyframes],
1792+
["acorn", syncPositionHoldsBeforeKeyframesAcorn],
1793+
])("%s: an edit to another element pins no position on a tween it did not touch", (_, sync) => {
1794+
const before = `${posTweenAt(1.2)}\ntl.to("#q", { x: 10, duration: 1 }, 0);`;
1795+
const after = before.replace("x: 10", "x: 40");
1796+
expect(sync(after, before)).toBe(after);
1797+
});
1798+
17901799
it("adds no hold for a tween that already starts at t=0", () => {
17911800
expect(syncPositionHoldsBeforeKeyframes(posTweenAt(0))).not.toContain("hf-hold");
17921801
});

‎packages/studio-server/src/routes/files.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2830,6 +2830,30 @@ ${authored}
28302830
expect(result.after).toContain("tl.to(kicker,");
28312831
});
28322832

2833+
it.each([
2834+
["#b", false],
2835+
["#p", true],
2836+
])("shifting %s pins #p before its first key: %s", async (targetSelector, held) => {
2837+
const dir = createProjectDir();
2838+
writeHtml(
2839+
dir,
2840+
"index.html",
2841+
`<!DOCTYPE html><html><body><script data-hyperframes-gsap>
2842+
const tl = gsap.timeline({ paused: true });
2843+
tl.to("#p", { keyframes: { "0%": { x: -150 }, "100%": { x: 0 } }, duration: 2 }, 1.2);
2844+
tl.to("#b", { duration: 1, x: 200 }, 2);
2845+
</script></body></html>`,
2846+
);
2847+
const app = new Hono();
2848+
registerFileRoutes(app, createAdapter(dir));
2849+
const res = await app.request("http://localhost/projects/demo/gsap-mutations/index.html", {
2850+
method: "POST",
2851+
headers: { "Content-Type": "application/json" },
2852+
body: JSON.stringify({ type: "shift-positions", targetSelector, delta: 1 }),
2853+
});
2854+
expect(((await res.json()) as { after: string }).after.includes("hf-hold")).toBe(held);
2855+
});
2856+
28332857
it("shift-positions-batch equals sequential single shifts (atomic multi-clip)", async () => {
28342858
const TWO_TWEENS = `<!DOCTYPE html><html><body><script data-hyperframes-gsap>
28352859
const tl = gsap.timeline({ paused: true });

‎packages/studio-server/src/routes/files.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1527,7 +1527,7 @@ function clipRetimeOf(mutation: GsapMutationRequest): RetimeSlot | null {
15271527
function retimeRun(script: string, run: readonly RetimeSlot[], root: ParentNode | undefined) {
15281528
const live = run.filter((slot): slot is ClipTweenRetime => slot !== "no-op");
15291529
const retimed = retimeClipTweensInScript(script, live, root);
1530-
const synced = syncPositionHoldsBeforeKeyframes(retimed.script);
1530+
const synced = syncPositionHoldsBeforeKeyframes(retimed.script, script);
15311531
let next = 0;
15321532
const changed = run.map((slot) => slot !== "no-op" && retimed.changed[next++]!);
15331533
if (synced !== retimed.script) changed[changed.length - 1] = true;

‎packages/studio/src/App.tsx‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useOwnPreviewIframe, usePreviewIframeStore } from "./player/store/previewIframeStore";
22
import { buildProjectApiPath } from "./utils/projectRouting";
3-
import { useState, useCallback, useRef, useMemo } from "react";
3+
import { useState, useCallback, useRef, useMemo, type SetStateAction } from "react";
44
import { useStableHandlers } from "./hooks/useStableHandlers";
55
import { useHistoryFlags, useToolbarSession } from "./hooks/useShellSlices";
66
import { useDismissingTabSetter, useRightPanelIntent } from "./hooks/useRightPanelIntents";
@@ -62,6 +62,7 @@ import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
6262
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
6363
import { readStudioUrlStateFromWindow, resolveMasterCompositionPath } from "./utils/studioUrlState";
6464
import { useActiveComposition } from "./hooks/useActiveComposition";
65+
import { requestPreviewReload } from "./player/previewReloading";
6566
const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
6667

6768
export interface StudioAppProps {
@@ -79,7 +80,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
7980
const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
8081
const previewIframe = useOwnPreviewIframe();
8182
const [compositionLoading, setCompositionLoading] = useState(true);
82-
const [refreshKey, setRefreshKey] = useState(0);
83+
const [refreshKey, setRefreshKeyState] = useState(0);
8384
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
8485
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
8586
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -104,7 +105,11 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
104105
const handleDomZIndexReorderCommitRef = useRef<TimelineZIndexReorderCommit | null>(null);
105106
const pendingTimelineEditPathRef = useRef(new Set<string>());
106107
const isGestureRecordingRef = useRef(false);
107-
const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
108+
const setRefreshKey = useCallback((next: SetStateAction<number>) => {
109+
requestPreviewReload();
110+
setRefreshKeyState(next);
111+
}, []);
112+
const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), [setRefreshKey]);
108113
const fileManagerResult = useFileManager({
109114
projectId,
110115
showToast,
@@ -144,7 +149,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
144149
recordEdit: editHistory.recordEdit,
145150
previewIframeRef,
146151
activeCompPathRef,
147-
reloadPreview: () => setRefreshKey((k) => k + 1),
152+
reloadPreview,
148153
});
149154
const previewPersistence = useStableHandlers(previewPersistenceResult, projectId);
150155
const externalFileChanges = useStudioExternalFileChanges({

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useZOrderCrossedFlash, ZOrderCrossedFlash } from "./useZOrderCrossedFla
1010
import { useCanvasContextMenuState } from "./useCanvasContextMenuState";
1111
import {
1212
type BlockedMoveState,
13+
type WaitingPressState,
1314
type DomEditGroupPathOffsetCommit,
1415
type FocusableDomEditOverlay,
1516
type MoveCommitOptions,
@@ -166,6 +167,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
166167
const gestureRef = useRef<GestureState | null>(null);
167168
const groupGestureRef = useRef<GroupGestureState | null>(null);
168169
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
170+
const waitingPressRef = useRef<WaitingPressState | null>(null);
169171
const suppressNextBoxClickRef = useRef(false);
170172
const snapGuidesRef = useRef<SnapGuidesState | null>(null);
171173
const rafPausedRef = useRef(false);
@@ -273,6 +275,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
273275
});
274276

275277
const gestures = createDomEditOverlayGestureHandlers({
278+
activeCompositionPathRef,
276279
overlayRef,
277280
iframeRef,
278281
boxRef,
@@ -283,6 +286,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
283286
gestureRef,
284287
groupGestureRef,
285288
blockedMoveRef,
289+
waitingPressRef,
286290
rafPausedRef,
287291
suppressNextBoxClickRef,
288292
setOverlayRect,
@@ -299,17 +303,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
299303
});
300304

301305
useEffect(() => {
302-
if (readOnly) gestures.clearPointerState(selectionRef);
306+
if (readOnly) gestures.clearPointerState(selectionRef, true);
303307
}, [gestures, readOnly, selectionRef]);
304308
// A gesture that loses its pointer, the window or the overlay is cancelled, so its mark goes too.
305-
const cancelGestureRef = useRef(() => {});
306-
cancelGestureRef.current = () => gestures.clearPointerState(selectionRef);
309+
const cancelGestureRef = useRef((_dropQueued?: boolean) => {});
310+
cancelGestureRef.current = (dropQueued) => gestures.clearPointerState(selectionRef, dropQueued);
307311
useMountEffect(() => {
308312
const cancel = () => cancelGestureRef.current();
309313
window.addEventListener("blur", cancel);
310314
return () => {
311315
window.removeEventListener("blur", cancel);
312-
cancel();
316+
cancelGestureRef.current(true);
313317
};
314318
});
315319

@@ -493,7 +497,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
493497
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
494498
onPointerUp={marquee.onPointerUp}
495499
onPointerCancel={marquee.onPointerCancel}
496-
onLostPointerCapture={() => cancelGestureRef.current()}
500+
onLostPointerCapture={gestures.onLostPointerCapture}
497501
onContextMenu={hostInput ? undefined : handleContextMenu}
498502
>
499503
{!hostInput && hoverSelection && hoverRect && compRect.width > 0 && (

0 commit comments

Comments
 (0)