@hyperframes/studio 0.8.138 → 0.8.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-CckOoCJ8.js} +1 -1
  2. package/dist/assets/{index-DIJuIBwI.js → index-BmIPrb8I.js} +1 -1
  3. package/dist/assets/{index-BdjA0CLD.js → index-Br1Whc0f.js} +192 -192
  4. package/dist/assets/{index-DXVvWfQG.js → index-DsEkE5_G.js} +1 -1
  5. package/dist/{chunk-4S3AYCOG.js → chunk-E422JDF5.js} +2 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +846 -469
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/editor/DomEditOverlay.tsx +9 -5
  18. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  19. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  20. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  21. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  22. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  23. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  24. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  25. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  26. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  27. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  28. package/src/components/editor/manualEditsDom.ts +7 -7
  29. package/src/components/editor/manualEditsTypes.ts +5 -0
  30. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  31. package/src/components/editor/manualOffsetDrag.ts +5 -0
  32. package/src/components/editor/previewGestureStarts.ts +284 -0
  33. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  34. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  35. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  36. package/src/components/nle/NLEContext.test.ts +26 -0
  37. package/src/components/nle/NLEContext.tsx +8 -3
  38. package/src/hooks/draggedGsapPosition.ts +8 -0
  39. package/src/hooks/editMoment.ts +13 -0
  40. package/src/hooks/gsapDragCommit.ts +2 -1
  41. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  42. package/src/hooks/gsapPositionDetection.ts +5 -3
  43. package/src/hooks/gsapResizeIntercept.ts +11 -4
  44. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  45. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  46. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  47. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  48. package/src/hooks/timelineEditingHelpers.ts +18 -2
  49. package/src/hooks/useDomSelection.test.ts +21 -2
  50. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  51. package/src/hooks/useEditHistoryActions.ts +2 -0
  52. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  53. package/src/hooks/useGsapAwareEditing.ts +148 -136
  54. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  55. package/src/hooks/useGsapScriptCommits.ts +5 -4
  56. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  57. package/src/hooks/usePreviewPersistence.ts +1 -4
  58. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  59. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  60. package/src/index.ts +1 -0
  61. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  62. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  63. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  64. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  65. package/src/player/lib/thumbnailScheduler.ts +1 -1
  66. package/src/player/lib/timelineElementHelpers.ts +0 -11
  67. package/src/player/previewReloading.test.ts +48 -0
  68. package/src/player/previewReloading.ts +48 -0
  69. package/src/utils/rootDuration.ts +5 -4
  70. package/src/utils/studioPendingEdits.test.ts +27 -0
  71. package/src/utils/studioPendingEdits.ts +9 -0
  72. package/src/utils/timelineAssetDrop.test.ts +13 -4
  73. package/src/utils/timelineAssetDrop.ts +6 -2
  74. package/dist/chunk-TDWGXBUT.js.map +0 -1
  75. /package/dist/{chunk-4S3AYCOG.js.map → chunk-E422JDF5.js.map} +0 -0
  76. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  77. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -0,0 +1,284 @@
1
+ import {
2
+ orientedGroupAwareOverlayRect,
3
+ shiftedOverlayRect,
4
+ type OverlayRect,
5
+ } from "./domEditOverlayGeometry";
6
+ import { findElementForSelection, type DomEditSelection } from "./domEditing";
7
+ import { computeOverlayRootScale } from "./domEditOverlayBasis";
8
+ import { resolveCenterResizeScale } from "./domEditResizeLocal";
9
+ import {
10
+ PRESS_WAITING_ATTR,
11
+ type WaitingPressState,
12
+ type GestureKind,
13
+ type ResizeHandle,
14
+ type UseDomEditOverlayGesturesOptions,
15
+ } from "./domEditOverlayGestures";
16
+ import {
17
+ startGesture as _startGesture,
18
+ startGroupDrag as _startGroupDrag,
19
+ } from "./domEditOverlayStartGesture";
20
+ import { giveUpOnPreviewChange, isPreviewChanging } from "../../player/previewReloading";
21
+ import { setStudioWaitingPressCancel } from "../../utils/studioPendingEdits";
22
+ import { playheadMoment } from "../../hooks/editMoment";
23
+ import type { EditMoment } from "./manualEditsTypes";
24
+
25
+ const MAX_SETTLED_WAIT_FRAMES = 60;
26
+ export const PRESS_WAIT_MAX_MS = 10_000;
27
+
28
+ export function createPreviewGestureStarts(
29
+ opts: UseDomEditOverlayGesturesOptions,
30
+ moveActiveGesture: (e: React.PointerEvent<HTMLDivElement>) => void,
31
+ releaseActiveGesture: (e: React.PointerEvent<HTMLDivElement>) => void,
32
+ ) {
33
+ /** Points Cmd+Z at the newest released press still waiting, if any. */
34
+ const offerUndo = () => {
35
+ let p = opts.waitingPressRef.current;
36
+ while (p && !p.released) p = p.after;
37
+ const press = p;
38
+ setStudioWaitingPressCancel(press && (() => dropPress(press)));
39
+ };
40
+
41
+ const clearWaitingMark = () => {
42
+ opts.boxRef.current?.removeAttribute(PRESS_WAITING_ATTR);
43
+ opts.rafPausedRef.current = false;
44
+ };
45
+
46
+ /** Points the press queued right behind `press` at `next` instead. */
47
+ const unlink = (press: WaitingPressState, next: WaitingPressState | null) => {
48
+ for (let p = opts.waitingPressRef.current; p; p = p.after)
49
+ if (p.after === press) p.after = next;
50
+ };
51
+
52
+ const endWaitingPress = (press: WaitingPressState | null = opts.waitingPressRef.current) => {
53
+ for (let p = press; p && !p.ended; p = p.after) {
54
+ p.ended = true;
55
+ cancelAnimationFrame(p.frame);
56
+ }
57
+ if (press === opts.waitingPressRef.current) {
58
+ opts.waitingPressRef.current = null;
59
+ clearWaitingMark();
60
+ }
61
+ // A press queued behind this one keeps waiting, on nothing that has ended.
62
+ else if (press) unlink(press, null);
63
+ offerUndo();
64
+ };
65
+
66
+ /** The newest press goes: the outline shows the press before it, or the box before this one. */
67
+ const showPressBefore = (press: WaitingPressState) => {
68
+ const before = press.after;
69
+ opts.waitingPressRef.current = before;
70
+ const shown = before ?? press;
71
+ const at = before?.moved;
72
+ const [dx, dy] = at ? [at.clientX - shown.startX, at.clientY - shown.startY] : [0, 0];
73
+ shown.draw?.(dx, dy);
74
+ if (!before) clearWaitingMark();
75
+ };
76
+
77
+ /** Takes one press out of the queue. */
78
+ const dropPress = (press: WaitingPressState): boolean => {
79
+ if (press.ended) return false;
80
+ press.ended = true;
81
+ cancelAnimationFrame(press.frame);
82
+ if (press === opts.waitingPressRef.current) showPressBefore(press);
83
+ else unlink(press, press.after);
84
+ offerUndo();
85
+ return true;
86
+ };
87
+
88
+ /** Ends the press still held, if any; presses already released are finished edits and still land. */
89
+ const endHeldPress = () => {
90
+ const held = opts.waitingPressRef.current;
91
+ if (held && !held.released) dropPress(held);
92
+ };
93
+
94
+ const releaseWaitingPress = (press: WaitingPressState, e: React.PointerEvent<HTMLDivElement>) => {
95
+ press.released = e;
96
+ offerUndo();
97
+ };
98
+
99
+ const drawPressedBox = (origin: OverlayRect | null): WaitingPressState["draw"] =>
100
+ origin && ((dx, dy) => opts.setOverlayRect(shiftedOverlayRect(origin, dx, dy)));
101
+
102
+ // A corner resize scales about the element's centre by the pointer's distance from it, as the resize does.
103
+ const drawResizedBox = (
104
+ origin: OverlayRect | null,
105
+ press: { x: number; y: number },
106
+ ): WaitingPressState["draw"] => {
107
+ if (!origin) return null;
108
+ const bounds = opts.overlayRef.current?.getBoundingClientRect();
109
+ const centre = {
110
+ x: (bounds?.left ?? 0) + origin.left + origin.width / 2,
111
+ y: (bounds?.top ?? 0) + origin.top + origin.height / 2,
112
+ };
113
+ return (dx, dy) => {
114
+ const pointer = { x: press.x + dx, y: press.y + dy };
115
+ const scale = resolveCenterResizeScale({ pointer, pointerStart: press, centerStart: centre });
116
+ const [width, height] = [origin.width * scale, origin.height * scale];
117
+ opts.setOverlayRect({
118
+ ...origin,
119
+ left: origin.left + (origin.width - width) / 2,
120
+ top: origin.top + (origin.height - height) / 2,
121
+ width,
122
+ height,
123
+ });
124
+ };
125
+ };
126
+
127
+ // A press on the page a reload is replacing would edit it by its old rules: it starts on the new page instead.
128
+ const startOnShownPreview = (
129
+ e: React.PointerEvent<HTMLElement>,
130
+ pressed: () => HTMLElement[],
131
+ start: (e: React.PointerEvent<HTMLElement>, at: EditMoment, waited: boolean) => boolean,
132
+ draw: WaitingPressState["draw"] = null,
133
+ ): boolean => {
134
+ const at = playheadMoment();
135
+ if (!isPreviewChanging() && !opts.waitingPressRef.current) return start(e, at, false);
136
+ e.preventDefault();
137
+ e.stopPropagation();
138
+ e.currentTarget.setPointerCapture(e.pointerId);
139
+ // The press already holds the pointer, which may be up by the time the gesture starts.
140
+ const held = { setPointerCapture() {} };
141
+ const down = { ...e, currentTarget: held, preventDefault() {}, stopPropagation() {} };
142
+ const press: WaitingPressState = {
143
+ pointerId: e.pointerId,
144
+ startX: e.clientX,
145
+ startY: e.clientY,
146
+ draw,
147
+ frame: 0,
148
+ moved: null,
149
+ released: null,
150
+ after: opts.waitingPressRef.current,
151
+ ended: false,
152
+ };
153
+ opts.waitingPressRef.current = press;
154
+ opts.boxRef.current?.setAttribute(PRESS_WAITING_ATTR, "true");
155
+ opts.rafPausedRef.current = true;
156
+ let shownFrames = 0;
157
+ let settledFrames = 0;
158
+ const since = performance.now();
159
+ /** Counts this frame toward the shown and settled streaks; returns how many pressed elements remain. */
160
+ const countFrame = () => {
161
+ const elements = pressed();
162
+ const live = opts.iframeRef.current?.contentDocument;
163
+ const settled = !isPreviewChanging();
164
+ const shown = settled && elements.every((el) => el.ownerDocument === live);
165
+ shownFrames = shown ? shownFrames + 1 : 0;
166
+ settledFrames = settled ? settledFrames + 1 : 0;
167
+ return elements.length;
168
+ };
169
+ const waitStep = (): "wait" | "lost" | "run" => {
170
+ if (press.after && !press.after.ended) return "wait";
171
+ const found = countFrame();
172
+ // A change that never ends (a save with no reply) would hold every press: run on the page shown.
173
+ const overdue = performance.now() - since > PRESS_WAIT_MAX_MS;
174
+ if (overdue) giveUpOnPreviewChange();
175
+ // The waiting outline stays drawn while replay measures the live element separately.
176
+ if (found === 0 || settledFrames > MAX_SETTLED_WAIT_FRAMES) return "lost";
177
+ return overdue || shownFrames >= 2 ? "run" : "wait";
178
+ };
179
+ const poll = () => {
180
+ if (press.ended) return;
181
+ const step = waitStep();
182
+ if (step === "wait") {
183
+ press.frame = requestAnimationFrame(poll);
184
+ return;
185
+ }
186
+ endWaitingPress(press);
187
+ if (step === "lost" || !start(down as unknown as React.PointerEvent<HTMLElement>, at, true))
188
+ return;
189
+ if (press.moved) moveActiveGesture(press.moved);
190
+ if (press.released) releaseActiveGesture(press.released);
191
+ };
192
+ press.frame = requestAnimationFrame(poll);
193
+ return true;
194
+ };
195
+
196
+ const shownSelection = (selection: DomEditSelection) => {
197
+ const doc = opts.iframeRef.current?.contentDocument;
198
+ if (!doc) return null;
199
+ const element = findElementForSelection(doc, selection, opts.activeCompositionPathRef.current);
200
+ return element && { ...selection, element };
201
+ };
202
+ const shownRect = (element: HTMLElement) => {
203
+ const overlay = opts.overlayRef.current;
204
+ const iframe = opts.iframeRef.current;
205
+ return overlay && iframe && orientedGroupAwareOverlayRect(overlay, iframe, element);
206
+ };
207
+
208
+ const startGroupDrag = (e: React.PointerEvent<HTMLElement>) => {
209
+ const items = opts.groupOverlayItemsRef.current;
210
+ return startOnShownPreview(
211
+ e,
212
+ () =>
213
+ items.flatMap((item) => {
214
+ const selection = shownSelection(item.selection);
215
+ return selection ? [selection.element] : [];
216
+ }),
217
+ (pressed, at, waited) => {
218
+ if (!waited) return _startGroupDrag(pressed, opts, at);
219
+ const overlay = opts.overlayRef.current;
220
+ const iframe = opts.iframeRef.current;
221
+ if (!overlay || !iframe) return false;
222
+ const scale = computeOverlayRootScale(overlay, iframe, iframe.contentDocument);
223
+ const measured = items.flatMap((item) => {
224
+ const selection = shownSelection(item.selection);
225
+ const rect =
226
+ selection && orientedGroupAwareOverlayRect(overlay, iframe, selection.element, scale);
227
+ return selection && rect
228
+ ? [{ ...item, selection, element: selection.element, rect }]
229
+ : [];
230
+ });
231
+ if (measured.length !== items.length) return false;
232
+ return _startGroupDrag(
233
+ pressed,
234
+ { ...opts, groupOverlayItemsRef: { current: measured } },
235
+ at,
236
+ );
237
+ },
238
+ (dx, dy) =>
239
+ opts.setGroupOverlayItems(
240
+ items.map((item) => ({ ...item, rect: shiftedOverlayRect(item.rect, dx, dy) })),
241
+ ),
242
+ );
243
+ };
244
+ const startGesture = (
245
+ kind: GestureKind,
246
+ e: React.PointerEvent<HTMLElement>,
247
+ options?: {
248
+ selection?: DomEditSelection;
249
+ rect?: OverlayRect | null;
250
+ resizeHandle?: ResizeHandle;
251
+ },
252
+ ) => {
253
+ // A replay edits what was pressed, though another element may be selected by then.
254
+ const target = options?.selection ?? opts.selectionRef.current;
255
+ let draw: WaitingPressState["draw"] = null;
256
+ if (kind === "drag") draw = drawPressedBox(opts.overlayRectRef.current);
257
+ if (kind === "resize")
258
+ draw = drawResizedBox(opts.overlayRectRef.current, { x: e.clientX, y: e.clientY });
259
+ return startOnShownPreview(
260
+ e,
261
+ () => {
262
+ const shown = target && shownSelection(target);
263
+ return shown ? [shown.element] : [];
264
+ },
265
+ (pressed, at, waited) => {
266
+ if (!waited) return _startGesture(kind, pressed, opts, { ...options, at });
267
+ const selection = target && shownSelection(target);
268
+ const rect = selection && shownRect(selection.element);
269
+ return (
270
+ !!(selection && rect) &&
271
+ _startGesture(kind, pressed, opts, {
272
+ selection,
273
+ rect,
274
+ resizeHandle: options?.resizeHandle,
275
+ at,
276
+ })
277
+ );
278
+ },
279
+ draw,
280
+ );
281
+ };
282
+
283
+ return { startGesture, startGroupDrag, endWaitingPress, endHeldPress, releaseWaitingPress };
284
+ }
@@ -62,6 +62,7 @@ async function rotateWord(before: (word: HTMLElement) => void = () => {}) {
62
62
  gestureRef: ref<GestureState | null>(null),
63
63
  groupGestureRef: ref(null),
64
64
  blockedMoveRef: ref(null),
65
+ waitingPressRef: ref(null),
65
66
  rafPausedRef: ref(false),
66
67
  suppressNextBoxClickRef: ref(false),
67
68
  setOverlayRect: () => {},
@@ -19,6 +19,7 @@ function gestureHarness(gesture: Partial<GestureState> | null, group: GroupGestu
19
19
  hoverSelectionRef: { current: null },
20
20
  boxRef: { current: null },
21
21
  blockedMoveRef: { current: null },
22
+ waitingPressRef: { current: null },
22
23
  rafPausedRef: { current: false },
23
24
  suppressNextBoxClickRef: { current: false },
24
25
  snapGuidesRef: { current: null },
@@ -1,3 +1,4 @@
1
+ import { createPreviewGestureStarts } from "./previewGestureStarts";
1
2
  import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
2
3
  // fallow-ignore-file code-duplication
3
4
  /**
@@ -28,13 +29,12 @@ import {
28
29
  type GroupOverlayItem,
29
30
  type OverlayRect,
30
31
  orientedOverlayRect,
32
+ shiftedOverlayRect,
31
33
  } from "./domEditOverlayGeometry";
32
34
  import {
33
35
  BLOCKED_MOVE_THRESHOLD_PX,
34
- type GestureKind,
35
36
  type GestureState,
36
37
  type GroupGestureState,
37
- type ResizeHandle,
38
38
  type UseDomEditOverlayGesturesOptions,
39
39
  ROTATED_SNAP_BYPASS_DEGREES,
40
40
  hasDomEditRotationChanged,
@@ -44,11 +44,7 @@ import {
44
44
  } from "./domEditOverlayGestures";
45
45
  import { resolveCenterResizeSize } from "./domEditResizeLocal";
46
46
  import { resolveResizeDraftRect } from "./resizeDraft";
47
- import {
48
- notifyBlockedPress,
49
- startGesture as _startGesture,
50
- startGroupDrag as _startGroupDrag,
51
- } from "./domEditOverlayStartGesture";
47
+ import { notifyBlockedPress } from "./domEditOverlayStartGesture";
52
48
  import { hugRectForElement } from "./domEditOverlayCrop";
53
49
  import {
54
50
  resolveSnapAdjustment,
@@ -76,7 +72,7 @@ function logGestureCommitFailure(message: string, error: unknown): void {
76
72
 
77
73
  export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
78
74
  const setDraftOverlayRect = (next: OverlayRect) => {
79
- opts.setOverlayRect(next);
75
+ if (!opts.waitingPressRef.current) opts.setOverlayRect(next);
80
76
  };
81
77
  const restoreGestureOverlayRect = (g: GestureState) => {
82
78
  setDraftOverlayRect({
@@ -86,14 +82,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
86
82
  height: g.originHeight,
87
83
  editScaleX: g.editScaleX,
88
84
  editScaleY: g.editScaleY,
89
- // Every draft rect must carry the element's rotation: the rotation wrapper
90
- // renders rotate(overlayRect.angle), so an omitted angle straightens the
91
- // chrome for the duration of the draft (the "straightens while moving" bug).
85
+ // Rotation keeps the chrome aligned with the element during its draft.
92
86
  angle: g.actualRotation,
93
87
  });
94
88
  };
95
89
  const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => {
96
- opts.setGroupOverlayItems(next);
90
+ if (!opts.waitingPressRef.current) opts.setGroupOverlayItems(next);
97
91
  };
98
92
 
99
93
  const restoreGroupPathOffsets = (g: GroupGestureState) => {
@@ -101,16 +95,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
101
95
  setDraftGroupOverlayItems(g.originItems);
102
96
  };
103
97
 
104
- const startGroupDrag = (e: React.PointerEvent<HTMLElement>) => _startGroupDrag(e, opts);
105
- const startGesture = (
106
- kind: GestureKind,
107
- e: React.PointerEvent<HTMLElement>,
108
- options?: {
109
- selection?: DomEditSelection;
110
- rect?: OverlayRect | null;
111
- resizeHandle?: ResizeHandle;
112
- },
113
- ) => _startGesture(kind, e, opts, options);
98
+ const { startGesture, startGroupDrag, endWaitingPress, endHeldPress, releaseWaitingPress } =
99
+ createPreviewGestureStarts(
100
+ opts,
101
+ (event) => moveActiveGesture(event),
102
+ (event) => releaseActiveGesture(event),
103
+ );
114
104
 
115
105
  // A press on a box that cannot move says why at once.
116
106
  const startBlockedMove = (e: React.PointerEvent<HTMLElement>, selection: DomEditSelection) => {
@@ -123,12 +113,23 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
123
113
 
124
114
  const moveGroupDrag = createGroupDragMover(opts, setDraftGroupOverlayItems);
125
115
 
126
- // fallow-ignore-next-line complexity
127
116
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
117
+ const waiting = opts.waitingPressRef.current;
118
+ if (waiting && waiting.pointerId === e.pointerId) {
119
+ if (waiting.released || !movesGesture(waiting, e)) return;
120
+ waiting.moved = e;
121
+ waiting.draw?.(e.clientX - waiting.startX, e.clientY - waiting.startY);
122
+ return;
123
+ }
124
+ moveActiveGesture(e);
125
+ };
126
+
127
+ // fallow-ignore-next-line complexity
128
+ const moveActiveGesture = (e: React.PointerEvent<HTMLDivElement>) => {
128
129
  const g = opts.gestureRef.current;
129
130
  const groupG = opts.groupGestureRef.current;
130
131
  const sel = g?.selection ?? opts.selectionRef.current;
131
- const box = opts.boxRef.current;
132
+ const box = opts.waitingPressRef.current ? null : opts.boxRef.current;
132
133
  const blockedMove = opts.blockedMoveRef.current;
133
134
  if (!blockedMove && !g && !groupG) {
134
135
  opts.onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
@@ -248,18 +249,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
248
249
  }
249
250
  if (g.pathOffsetMember) applyManualOffsetDragDraft(g.pathOffsetMember, dx, dy);
250
251
  } else {
251
- if (!box) return;
252
-
253
- // CENTER-ANCHORED size (CapCut model): the element scales proportionally
254
- // about its CENTER — the scale is the pointer's RADIAL distance from the
255
- // element center now over its distance at gesture start. Rotation-invariant
256
- // (a distance ignores the angle) and continuous, so all four corners behave
257
- // identically and there is no per-axis projection or edge-snapping. Base size
258
- // is the element-local px size at gesture start (actualWidth/Height,
259
- // GSAP-scale-aware). Corner drag is ALWAYS proportional; there is no
260
- // free-form stretch gesture. Edge-snapping is intentionally NOT applied:
261
- // with center anchoring both edges move symmetrically, so the corner-anchored
262
- // snap math no longer holds — CapCut does not edge-snap during scale either.
252
+ // Corner resize scales proportionally about the center, without edge snapping.
263
253
  const nextSize = resolveCenterResizeSize({
264
254
  baseWidth: g.actualWidth,
265
255
  baseHeight: g.actualHeight,
@@ -288,33 +278,41 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
288
278
  draftRect,
289
279
  liveInlineStyle: sel.element.getAttribute("style"),
290
280
  });
291
- box.style.left = `${draftRect.left}px`;
292
- box.style.top = `${draftRect.top}px`;
293
- box.style.width = `${draftRect.width}px`;
294
- box.style.height = `${draftRect.height}px`;
281
+ if (box) {
282
+ box.style.left = `${draftRect.left}px`;
283
+ box.style.top = `${draftRect.top}px`;
284
+ box.style.width = `${draftRect.width}px`;
285
+ box.style.height = `${draftRect.height}px`;
286
+ }
295
287
  setDraftOverlayRect(draftRect);
296
288
  }
297
289
  };
298
290
 
299
- // fallow-ignore-next-line complexity
300
291
  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
292
+ const waiting = opts.waitingPressRef.current;
293
+ if (waiting && waiting.pointerId === e.pointerId) {
294
+ releaseWaitingPress(waiting, e);
295
+ opts.suppressNextBoxClickRef.current = true;
296
+ return;
297
+ }
298
+ releaseActiveGesture(e);
299
+ };
300
+
301
+ // fallow-ignore-next-line complexity
302
+ const releaseActiveGesture = (e: React.PointerEvent<HTMLDivElement>) => {
301
303
  opts.snapGuidesRef.current = null;
302
304
  const g = opts.gestureRef.current;
303
305
  const groupG = opts.groupGestureRef.current;
304
306
  const sel = g?.selection ?? opts.selectionRef.current;
305
- const box = opts.boxRef.current;
307
+ const box = opts.waitingPressRef.current ? null : opts.boxRef.current;
306
308
  opts.blockedMoveRef.current = null;
309
+ opts.rafPausedRef.current = opts.waitingPressRef.current !== null;
307
310
 
308
311
  if (groupG) {
309
312
  opts.groupGestureRef.current = null;
310
- opts.rafPausedRef.current = false;
311
313
  const rawDx = e.clientX - groupG.startX;
312
314
  const rawDy = e.clientY - groupG.startY;
313
- // The click that trails every pointerup has to be eaten either way. The
314
- // gesture ref is already cleared above, so by the time it arrives the box
315
- // no longer looks busy, and handleBoxClick hands it to the canvas as an
316
- // ordinary click — which lands between the members, resolves to nothing,
317
- // and deselects the group the drag just moved.
315
+ // Consume the release click so it cannot deselect the moved group.
318
316
  opts.suppressNextBoxClickRef.current = true;
319
317
  if (isTap(groupG, e)) {
320
318
  restoreGroupPathOffsets(groupG);
@@ -331,7 +329,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
331
329
  setDraftGroupOverlayItems(
332
330
  groupG.originItems.map((item) => ({
333
331
  ...item,
334
- rect: { ...item.rect, left: item.rect.left + dx, top: item.rect.top + dy },
332
+ rect: shiftedOverlayRect(item.rect, dx, dy),
335
333
  })),
336
334
  );
337
335
  const updates = groupG.members.map((member) => ({
@@ -367,23 +365,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
367
365
  .finally(() => {
368
366
  logDrag("committed", { at: readDragPositions(groupG.members) });
369
367
  endManualOffsetDragMembers(groupG.members);
370
- // The gesture teardown resumes the paused timelines and re-seeks the
371
- // player, which re-renders from whatever the preview currently holds.
372
- // If the reloaded source has not landed yet that is the OLD position,
373
- // so this is where a snap-back would show.
368
+ // Teardown re-seeks the player, exposing any stale preview position.
374
369
  logDragSettle("settle", groupG.members);
375
370
  });
376
371
  groupEdit.settle(groupSaved);
377
372
  return;
378
373
  }
379
374
 
380
- if (!g || !sel) {
381
- opts.gestureRef.current = null;
382
- opts.rafPausedRef.current = false;
383
- return;
384
- }
385
375
  opts.gestureRef.current = null;
386
- opts.rafPausedRef.current = false;
376
+ if (!g || !sel) return;
387
377
  const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
388
378
 
389
379
  if (g.kind === "drag" && isTap(g, e)) {
@@ -457,10 +447,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
457
447
  .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
458
448
  edit.settle(saved);
459
449
  } else if (g.kind === "drag") {
460
- // A moved drag (taps returned earlier) must not let the release click
461
- // re-select whatever now sits under the pointer — dropping over a
462
- // higher-z element should keep the dragged element selected, not select
463
- // the drop target. Mirrors the resize branch below.
450
+ // A release over another layer must keep this dragged layer selected.
464
451
  opts.suppressNextBoxClickRef.current = true;
465
452
  const dx = g.lastSnappedDx ?? e.clientX - g.startX;
466
453
  const dy = g.lastSnappedDy ?? e.clientY - g.startY;
@@ -509,12 +496,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
509
496
  opts.suppressNextBoxClickRef.current = true;
510
497
  const finalSize = readStudioBoxSize(sel.element);
511
498
  applyStudioBoxSize(sel.element, finalSize);
512
- // Anchored corner resize (NW/NE/SW) also moved the element to keep the
513
- // center planted. Land the size AND the anchor offset in a SINGLE
514
- // box-size commit (one persist, one undo entry). The prior two-commit
515
- // sequence re-stamped the element from source after the size-only persist
516
- // but before the offset persist landed — that one frame (new size, old
517
- // offset) was the release "jump". SE has no anchor member → size only.
499
+ // Save size and center-preserving offset together under one undo entry.
518
500
  const member = g.pathOffsetMember;
519
501
  const anchor = g.lastResizeAnchor;
520
502
  const finalOffset =
@@ -554,8 +536,14 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
554
536
  }
555
537
  };
556
538
 
539
+ /** `dropQueued`: released presses still waiting go too, as when the overlay leaves or turns read-only. */
557
540
  // fallow-ignore-next-line complexity
558
- const clearPointerState = (selectionRef: RefObject<DomEditSelection | null>) => {
541
+ const clearPointerState = (
542
+ selectionRef: RefObject<DomEditSelection | null>,
543
+ dropQueued = false,
544
+ ) => {
545
+ if (dropQueued) endWaitingPress();
546
+ else endHeldPress();
559
547
  opts.snapGuidesRef.current = null;
560
548
  const groupG = opts.groupGestureRef.current;
561
549
  if (groupG) restoreGroupPathOffsets(groupG);
@@ -586,7 +574,14 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
586
574
  opts.blockedMoveRef.current = null;
587
575
  opts.groupGestureRef.current = null;
588
576
  opts.gestureRef.current = null;
589
- opts.rafPausedRef.current = false;
577
+ opts.rafPausedRef.current = opts.waitingPressRef.current !== null;
578
+ };
579
+
580
+ const onLostPointerCapture = (e: React.PointerEvent<HTMLDivElement>) => {
581
+ const waiting = opts.waitingPressRef.current;
582
+ // Pointerup releases capture normally; the queued edit still has to land.
583
+ if (waiting?.pointerId === e.pointerId && waiting.released) return;
584
+ clearPointerState(opts.selectionRef);
590
585
  };
591
586
 
592
587
  return {
@@ -595,6 +590,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
595
590
  startBlockedMove,
596
591
  onPointerMove,
597
592
  onPointerUp,
593
+ onLostPointerCapture,
598
594
  clearPointerState,
599
595
  };
600
596
  }
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { shouldDisableTimelineWhileCompositionLoading, NLEProvider } from "./NLEContext";
6
6
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
7
7
  import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
8
+ import { isPreviewChanging, requestPreviewReload } from "../../player/previewReloading";
8
9
 
9
10
  installReactActEnvironment();
10
11
 
@@ -143,3 +144,28 @@ describe("NLEProvider — composition source-map scoping", () => {
143
144
  host.remove();
144
145
  });
145
146
  });
147
+
148
+ describe("NLEProvider — a requested reload", () => {
149
+ beforeEach(() => {
150
+ vi.stubGlobal(
151
+ "fetch",
152
+ vi.fn(() => Promise.reject(new Error("no network in tests"))),
153
+ );
154
+ });
155
+
156
+ afterEach(() => {
157
+ vi.unstubAllGlobals();
158
+ });
159
+
160
+ it("stops counting as a preview change once the refresh begins, so later presses do not wait", async () => {
161
+ const { host, root } = await mountNleProvider({ projectId: "p", refreshKey: 0 });
162
+ requestPreviewReload();
163
+ expect(isPreviewChanging()).toBe(true);
164
+
165
+ await renderNleProvider(root, { projectId: "p", refreshKey: 1 });
166
+ expect(isPreviewChanging()).toBe(false);
167
+
168
+ act(() => root.unmount());
169
+ host.remove();
170
+ });
171
+ });
@@ -10,6 +10,7 @@ import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
10
10
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
11
11
  import { createStableContext } from "../../utils/hmrStableContext";
12
12
  import { studioApiFetch } from "../../utils/studioApiFetch";
13
+ import { previewReloadBegun } from "../../player/previewReloading";
13
14
 
14
15
  export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
15
16
  return compositionLoading;
@@ -130,9 +131,13 @@ export function NLEProvider({
130
131
  // Lightweight reload: change iframe src instead of destroying the Player.
131
132
  const prevRefreshKeyRef = useRef(refreshKey);
132
133
  useEffect(() => {
133
- if (refreshKey === prevRefreshKeyRef.current) return;
134
- prevRefreshKeyRef.current = refreshKey;
135
- refreshPlayer();
134
+ try {
135
+ if (refreshKey === prevRefreshKeyRef.current) return;
136
+ prevRefreshKeyRef.current = refreshKey;
137
+ refreshPlayer();
138
+ } finally {
139
+ previewReloadBegun();
140
+ }
136
141
  }, [refreshKey, refreshPlayer]);
137
142
 
138
143
  // Steps that follow every load of the live iframe, including a reload promoted in place.
@@ -1,4 +1,5 @@
1
1
  import { roundTo3 } from "../utils/rounding";
2
+ import { STUDIO_EDIT_MOMENT_ATTR, type EditMoment } from "../components/editor/manualEditsTypes";
2
3
 
3
4
  /**
4
5
  * Drag → GSAP position math, shared by the commit path
@@ -41,6 +42,7 @@ export interface DragStamp {
41
42
  origY: number;
42
43
  baseX: number;
43
44
  baseY: number;
45
+ at?: EditMoment;
44
46
  frozen?: boolean;
45
47
  }
46
48
 
@@ -52,9 +54,15 @@ export function readDragStamp(element: HTMLElement): DragStamp {
52
54
  origY: read("data-hf-drag-initial-offset-y") || 0,
53
55
  baseX: read("data-hf-drag-gsap-base-x"),
54
56
  baseY: read("data-hf-drag-gsap-base-y"),
57
+ at: readEditMoment(element),
55
58
  };
56
59
  }
57
60
 
61
+ function readEditMoment(element: HTMLElement): EditMoment | undefined {
62
+ const raw = element.getAttribute(STUDIO_EDIT_MOMENT_ATTR);
63
+ return raw ? (JSON.parse(raw) as EditMoment) : undefined;
64
+ }
65
+
58
66
  /** A gesture's stamp read at its release, for commits that finish after the next gesture re-stamps the element. */
59
67
  export const freezeDragStamp = (element: HTMLElement): DragStamp => ({
60
68
  ...readDragStamp(element),