@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.
- package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-CckOoCJ8.js} +1 -1
- package/dist/assets/{index-DIJuIBwI.js → index-BmIPrb8I.js} +1 -1
- package/dist/assets/{index-BdjA0CLD.js → index-Br1Whc0f.js} +192 -192
- package/dist/assets/{index-DXVvWfQG.js → index-DsEkE5_G.js} +1 -1
- package/dist/{chunk-4S3AYCOG.js → chunk-E422JDF5.js} +2 -2
- package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
- package/dist/chunk-TFLU2MGI.js.map +1 -0
- package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
- package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.html +1 -1
- package/dist/index.js +846 -469
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/App.tsx +9 -4
- package/src/components/editor/DomEditOverlay.tsx +9 -5
- package/src/components/editor/PropertyPanel.test.tsx +600 -748
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
- package/src/components/editor/domEditOverlayGeometry.ts +7 -7
- package/src/components/editor/domEditOverlayGestures.ts +18 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
- package/src/components/editor/dragUndoPaint.test.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
- package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +7 -7
- package/src/components/editor/manualEditsTypes.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +23 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -0
- package/src/components/editor/previewGestureStarts.ts +284 -0
- package/src/components/editor/rotateInlineSave.test.ts +1 -0
- package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
- package/src/components/nle/NLEContext.test.ts +26 -0
- package/src/components/nle/NLEContext.tsx +8 -3
- package/src/hooks/draggedGsapPosition.ts +8 -0
- package/src/hooks/editMoment.ts +13 -0
- package/src/hooks/gsapDragCommit.ts +2 -1
- package/src/hooks/gsapDragPositionCommit.ts +19 -7
- package/src/hooks/gsapPositionDetection.ts +5 -3
- package/src/hooks/gsapResizeIntercept.ts +11 -4
- package/src/hooks/gsapRuntimeBridge.ts +31 -11
- package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
- package/src/hooks/gsapValueAtPlayhead.ts +12 -5
- package/src/hooks/timelineEditingHelpers.test.ts +22 -0
- package/src/hooks/timelineEditingHelpers.ts +18 -2
- package/src/hooks/useDomSelection.test.ts +21 -2
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +2 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
- package/src/hooks/useGsapAwareEditing.ts +148 -136
- package/src/hooks/useGsapAwareGroupMove.ts +126 -113
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
- package/src/hooks/usePreviewPersistence.ts +1 -4
- package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
- package/src/hooks/useTimelineDeleteOps.ts +9 -10
- package/src/index.ts +1 -0
- package/src/player/hooks/useShadowPreviewReload.ts +6 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
- package/src/player/hooks/useTimelinePlayer.ts +8 -5
- package/src/player/lib/thumbnailScheduler.test.ts +20 -1
- package/src/player/lib/thumbnailScheduler.ts +1 -1
- package/src/player/lib/timelineElementHelpers.ts +0 -11
- package/src/player/previewReloading.test.ts +48 -0
- package/src/player/previewReloading.ts +48 -0
- package/src/utils/rootDuration.ts +5 -4
- package/src/utils/studioPendingEdits.test.ts +27 -0
- package/src/utils/studioPendingEdits.ts +9 -0
- package/src/utils/timelineAssetDrop.test.ts +13 -4
- package/src/utils/timelineAssetDrop.ts +6 -2
- package/dist/chunk-TDWGXBUT.js.map +0 -1
- /package/dist/{chunk-4S3AYCOG.js.map → chunk-E422JDF5.js.map} +0 -0
- /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
- /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
|
-
//
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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 = (
|
|
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 =
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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),
|