@hyperframes/studio 0.8.102 → 0.8.104
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-BuRiFtrF.js → hyperframes-player-DAoaajuM.js} +1 -1
- package/dist/assets/{index-BT3sauee.js → index-BDK4rTMb.js} +1 -1
- package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
- package/dist/assets/{index-C2utDZt-.js → index-De2aO4XM.js} +179 -179
- package/dist/assets/{index-BM35fjFf.js → index-WV9LroAD.js} +1 -1
- package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
- package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
- package/dist/chunk-DI4LCJHL.js.map +1 -0
- package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
- package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
- package/dist/index.d.ts +183 -170
- package/dist/index.html +2 -2
- package/dist/index.js +550 -389
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditOverlay.tsx +7 -7
- package/src/components/editor/PropertyPanel.tsx +5 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
- package/src/components/editor/domEditOverlayGestures.ts +14 -20
- package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
- package/src/components/editor/domEditOverlayStartGesture.ts +17 -27
- package/src/components/editor/manualEdits.test.ts +3 -9
- package/src/components/editor/manualEdits.ts +0 -1
- package/src/components/editor/manualEditsDom.ts +0 -10
- package/src/components/editor/manualEditsDomPatches.test.ts +1 -45
- package/src/components/editor/manualEditsDomPatches.ts +1 -23
- package/src/components/editor/manualEditsSnapshot.ts +2 -0
- package/src/components/editor/manualEditsTypes.ts +2 -0
- package/src/components/editor/manualOffsetDrag.test.ts +57 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -32
- package/src/components/editor/plainTranslate.test.ts +38 -22
- package/src/components/editor/plainTranslate.ts +75 -40
- package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
- package/src/components/editor/previewReadOnlyContext.tsx +3 -1
- package/src/components/editor/propertyPanelTransformCommit.test.ts +80 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +11 -6
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/components/editor/rotateInlineSave.test.ts +111 -0
- package/src/components/editor/rotationDraft.test.ts +111 -0
- package/src/components/editor/rotationDraft.ts +145 -0
- package/src/components/editor/useColorGradingController.test.ts +36 -1
- package/src/components/editor/useColorGradingController.ts +2 -2
- package/src/components/editor/useDomEditNudge.test.tsx +34 -0
- package/src/components/editor/useDomEditNudge.ts +12 -5
- package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
- package/src/hooks/draggedGsapPosition.ts +28 -0
- package/src/hooks/elementOffsetStager.ts +26 -6
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
- package/src/hooks/plainRotation.test.ts +122 -0
- package/src/hooks/plainRotation.ts +42 -0
- package/src/hooks/useDomEditSession.ts +5 -5
- package/src/hooks/useDomGeometryCommit.test.tsx +75 -0
- package/src/hooks/useDomGeometryCommit.ts +17 -17
- package/src/hooks/useDomGeometryCommits.test.tsx +56 -1
- package/src/hooks/useDomGeometryCommits.ts +28 -36
- package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
- package/src/hooks/useGsapAwareEditing.ts +43 -42
- package/src/hooks/useInlineTextEdit.test.tsx +33 -3
- package/src/hooks/useInlineTextEdit.ts +2 -2
- package/src/hooks/usePreviewInteraction.test.ts +32 -0
- package/src/hooks/usePreviewInteraction.ts +3 -2
- package/src/player/components/TimelineClipFades.test.tsx +103 -3
- package/src/player/components/TimelineClipFades.tsx +53 -50
- package/src/styles/theme.css +1 -1
- package/src/utils/projectRouting.test.ts +9 -0
- package/src/utils/projectRouting.ts +3 -1
- package/dist/chunk-S65AQH6M.js.map +0 -1
- /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
- /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
- /package/dist/{domEditingLayers-NU6Z5QWA.js.map → domEditingLayers-AQBDFNUC.js.map} +0 -0
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import type {
|
|
19
19
|
BlockedMoveState,
|
|
20
20
|
DomEditGroupPathOffsetCommit,
|
|
21
|
+
MoveCommitOptions,
|
|
21
22
|
GestureState,
|
|
22
23
|
GroupGestureState,
|
|
23
24
|
} from "./domEditOverlayGestures";
|
|
@@ -55,11 +56,12 @@ export interface UseDomEditNudgeParams {
|
|
|
55
56
|
groupGestureRef: RefObject<GroupGestureState | null>;
|
|
56
57
|
blockedMoveRef: RefObject<BlockedMoveState | null>;
|
|
57
58
|
onManualDragStartRef: RefObject<(() => void) | undefined>;
|
|
59
|
+
onBlockedMoveRef: RefObject<(selection: DomEditSelection, reason?: string) => void>;
|
|
58
60
|
onPathOffsetCommitRef: RefObject<
|
|
59
61
|
(
|
|
60
62
|
s: DomEditSelection,
|
|
61
63
|
n: { x: number; y: number },
|
|
62
|
-
m?:
|
|
64
|
+
m?: MoveCommitOptions,
|
|
63
65
|
) => Promise<unknown> | void
|
|
64
66
|
>;
|
|
65
67
|
onGroupPathOffsetCommitRef: RefObject<
|
|
@@ -149,11 +151,14 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
149
151
|
const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
|
|
150
152
|
selection: member.selection,
|
|
151
153
|
next: applyManualOffsetNudgeCommit(member, session.accum),
|
|
154
|
+
plainTranslate: member.plainTranslate,
|
|
152
155
|
}));
|
|
153
156
|
const p = paramsRef.current;
|
|
154
157
|
const commit = session.isGroup
|
|
155
158
|
? p.onGroupPathOffsetCommitRef.current(updates)
|
|
156
|
-
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next
|
|
159
|
+
: p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
|
|
160
|
+
plainTranslate: updates[0].plainTranslate,
|
|
161
|
+
});
|
|
157
162
|
void Promise.resolve(commit)
|
|
158
163
|
.catch(() => {
|
|
159
164
|
for (const member of session.members) {
|
|
@@ -179,7 +184,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
179
184
|
// Build drag members for the current target set — the same member snapshot a
|
|
180
185
|
// pointer drag starts from (startGesture / startGroupDrag), so the nudge
|
|
181
186
|
// commit converts offsets → GSAP x/y with identical math.
|
|
182
|
-
const beginSession = (): NudgeSession | null => {
|
|
187
|
+
const beginSession = (event: KeyboardEvent): NudgeSession | "refused" | null => {
|
|
183
188
|
const p = paramsRef.current;
|
|
184
189
|
const groupItems = p.groupOverlayItemsRef.current;
|
|
185
190
|
const isGroup = groupItems.length > 1;
|
|
@@ -192,7 +197,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
192
197
|
const result = createManualOffsetDragMember({ ...target, gesture: "nudge" });
|
|
193
198
|
if (!result.ok) {
|
|
194
199
|
restoreManualOffsetDragMembers(members);
|
|
195
|
-
|
|
200
|
+
if (!event.repeat) p.onBlockedMoveRef.current(result.selection, result.reason);
|
|
201
|
+
return "refused";
|
|
196
202
|
}
|
|
197
203
|
members.push(result.member);
|
|
198
204
|
}
|
|
@@ -207,7 +213,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
207
213
|
if (shouldIgnoreNudgeKey(p, event)) return;
|
|
208
214
|
const delta = resolveCanvasNudgeDelta(event);
|
|
209
215
|
if (!delta) return;
|
|
210
|
-
const session = sessionRef.current ?? beginSession();
|
|
216
|
+
const session = sessionRef.current ?? beginSession(event);
|
|
217
|
+
if (session === "refused") return void event.preventDefault();
|
|
211
218
|
if (!session) return;
|
|
212
219
|
sessionRef.current = session;
|
|
213
220
|
event.preventDefault();
|
|
@@ -9,21 +9,18 @@ import { type DomEditSelection } from "./domEditing";
|
|
|
9
9
|
import {
|
|
10
10
|
applyManualOffsetDragCommit,
|
|
11
11
|
applyManualOffsetDragDraft,
|
|
12
|
-
applyRotationDraftViaGsap,
|
|
13
12
|
endManualOffsetDragMembers,
|
|
14
13
|
restoreManualOffsetDragMembers,
|
|
15
14
|
} from "./manualOffsetDrag";
|
|
15
|
+
import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
|
|
16
16
|
import {
|
|
17
17
|
applyStudioBoxSize,
|
|
18
18
|
applyStudioBoxSizeDraft,
|
|
19
|
-
applyStudioRotation,
|
|
20
|
-
applyStudioRotationDraft,
|
|
21
19
|
endStudioManualEditGesture,
|
|
22
20
|
isStudioManualEditGestureCurrent,
|
|
23
21
|
readStudioBoxSize,
|
|
24
22
|
restoreStudioBoxSize,
|
|
25
23
|
restoreStudioPathOffset,
|
|
26
|
-
restoreStudioRotation,
|
|
27
24
|
} from "./manualEdits";
|
|
28
25
|
import {
|
|
29
26
|
type GroupOverlayItem,
|
|
@@ -166,9 +163,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
166
163
|
actualAngle: g.actualRotation,
|
|
167
164
|
snap: e.shiftKey,
|
|
168
165
|
});
|
|
169
|
-
|
|
170
|
-
applyStudioRotationDraft(sel.element, rotated);
|
|
171
|
-
}
|
|
166
|
+
applyRotationDraft(sel.element, rotated.angle, g.plainRotation);
|
|
172
167
|
return;
|
|
173
168
|
}
|
|
174
169
|
|
|
@@ -336,6 +331,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
336
331
|
const updates = groupG.members.map((member) => ({
|
|
337
332
|
selection: member.selection,
|
|
338
333
|
next: applyManualOffsetDragCommit(member, dx, dy),
|
|
334
|
+
plainTranslate: member.plainTranslate,
|
|
339
335
|
}));
|
|
340
336
|
logDrag("drop", {
|
|
341
337
|
pointer: `${Math.round(rawDx)},${Math.round(rawDy)}`,
|
|
@@ -421,24 +417,22 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
421
417
|
actualAngle: g.actualRotation,
|
|
422
418
|
snap: e.shiftKey,
|
|
423
419
|
});
|
|
424
|
-
const restoreRotation = () =>
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
420
|
+
const restoreRotation = () =>
|
|
421
|
+
restoreRotationDraft(
|
|
422
|
+
sel.element,
|
|
423
|
+
g.actualRotation,
|
|
424
|
+
g.initialRotation,
|
|
425
|
+
g.plainRotation !== null,
|
|
426
|
+
);
|
|
431
427
|
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
|
|
432
428
|
restoreRotation();
|
|
433
429
|
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
|
434
430
|
return;
|
|
435
431
|
}
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
|
|
432
|
+
// Hold the final angle while the commit lands.
|
|
433
|
+
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
|
|
434
|
+
const commit = g.plainRotation ? { ...finalRotation, plain: g.plainRotation } : finalRotation;
|
|
435
|
+
void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
|
|
442
436
|
.catch((error) => {
|
|
443
437
|
logGestureCommitFailure("rotate commit failed", error);
|
|
444
438
|
if (
|
|
@@ -476,7 +470,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
476
470
|
box.style.top = `${nextBoxTop}px`;
|
|
477
471
|
}
|
|
478
472
|
void Promise.resolve(
|
|
479
|
-
opts.onPathOffsetCommitRef.current(sel, finalOffset, {
|
|
473
|
+
opts.onPathOffsetCommitRef.current(sel, finalOffset, {
|
|
474
|
+
altKey: e.altKey,
|
|
475
|
+
plainTranslate: g.pathOffsetMember.plainTranslate,
|
|
476
|
+
}),
|
|
480
477
|
)
|
|
481
478
|
.catch(() => {
|
|
482
479
|
if (
|
|
@@ -555,11 +552,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
555
552
|
restoreGestureOverlayRect(g);
|
|
556
553
|
}
|
|
557
554
|
if (g?.mode === "rotation" && sel) {
|
|
558
|
-
|
|
555
|
+
restoreRotationDraft(
|
|
559
556
|
sel.element,
|
|
560
|
-
g.actualRotation
|
|
557
|
+
g.actualRotation,
|
|
558
|
+
g.initialRotation,
|
|
559
|
+
g.plainRotation !== null,
|
|
561
560
|
);
|
|
562
|
-
restoreStudioRotation(sel.element, g.initialRotation);
|
|
563
561
|
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
|
564
562
|
}
|
|
565
563
|
opts.blockedMoveRef.current = null;
|
|
@@ -8,6 +8,34 @@ import { roundTo3 } from "../utils/rounding";
|
|
|
8
8
|
* it without pulling the GSAP commit graph into its module scope.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
const cssValue = (style: CSSStyleDeclaration, prop: string) => {
|
|
12
|
+
const value = style.getPropertyValue(prop).trim();
|
|
13
|
+
return value === "none" ? "" : value;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Without GSAP, the rotation GSAP will parse from the CSS `rotate`, `scale` and `transform`; with
|
|
17
|
+
// `withRotate` false, only the part `scale` and `transform` draw. GSAP folds them into one transform,
|
|
18
|
+
// and a list the browser rejects (e.g. `rotate: x 30deg`) leaves it only the plain transform.
|
|
19
|
+
export function readCssRotation(element: HTMLElement, withRotate = true): number {
|
|
20
|
+
const view = element.ownerDocument.defaultView;
|
|
21
|
+
if (!view) return 0;
|
|
22
|
+
const style = view.getComputedStyle(element);
|
|
23
|
+
const rotate = withRotate ? cssValue(style, "rotate") : "";
|
|
24
|
+
const scale = cssValue(style, "scale");
|
|
25
|
+
const transform = cssValue(style, "transform");
|
|
26
|
+
const angle = (list: string) => {
|
|
27
|
+
if (!list) return 0;
|
|
28
|
+
const m = new view.DOMMatrix(list);
|
|
29
|
+
return (Math.atan2(m.b, m.a) * 180) / Math.PI;
|
|
30
|
+
};
|
|
31
|
+
const folded = [rotate && `rotate(${rotate})`, scale && `scale(${scale.split(/\s+/).join(",")})`];
|
|
32
|
+
try {
|
|
33
|
+
return angle([...folded, transform].join(" ").trim());
|
|
34
|
+
} catch {
|
|
35
|
+
return angle(transform);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
11
39
|
/**
|
|
12
40
|
* Translate a studio drag offset into absolute GSAP x/y, accounting for the
|
|
13
41
|
* element's rotation and its drag-start base pose. Reads the drag-start
|
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers";
|
|
7
7
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
8
8
|
import { translatePatch } from "../components/editor/plainTranslate";
|
|
9
|
-
import {
|
|
9
|
+
import { gsapHoldsTranslate } from "./gsapRuntimeKeyframes";
|
|
10
|
+
import { markStudioSaveErrorAlreadyToasted } from "../utils/studioSaveDiagnostics";
|
|
11
|
+
|
|
12
|
+
const GSAP_TOOK_OVER =
|
|
13
|
+
"The animation took over this layer's position during the move, so it was not saved.";
|
|
10
14
|
|
|
11
15
|
const ELEMENT_OFFSET_REFUSED: Record<ElementOffsetRefusal, string> = {
|
|
12
16
|
anchored: "This layer is anchored from its right or bottom edge. Move it in the Code tab.",
|
|
@@ -23,15 +27,29 @@ export interface ElementOffsetStagerDeps {
|
|
|
23
27
|
readOnlyPreview?: boolean;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
function gsapOf(el: HTMLElement): { set: (t: Element, v: object) => void } | undefined {
|
|
31
|
+
return (el.ownerDocument.defaultView as { gsap?: { set: (t: Element, v: object) => void } })
|
|
32
|
+
?.gsap;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
/** The drag draft moved GSAP's x/y; left/top carries the move now, so put them back. */
|
|
27
36
|
function settleGsapDraftAtGestureStart(el: HTMLElement): void {
|
|
28
|
-
const gsap = (el
|
|
29
|
-
?.gsap;
|
|
37
|
+
const gsap = gsapOf(el);
|
|
30
38
|
const x = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "");
|
|
31
39
|
const y = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "");
|
|
32
40
|
if (gsap && Number.isFinite(x) && Number.isFinite(y)) gsap.set(el, { x, y });
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
export function refuseGsapTakeover(
|
|
44
|
+
el: HTMLElement,
|
|
45
|
+
showToast: ElementOffsetStagerDeps["showToast"],
|
|
46
|
+
) {
|
|
47
|
+
if (!gsapHoldsTranslate(el)) return;
|
|
48
|
+
gsapOf(el)?.set(el, { x: 0, y: 0, xPercent: 0, yPercent: 0 });
|
|
49
|
+
showToast(GSAP_TOOK_OVER, "error");
|
|
50
|
+
throw markStudioSaveErrorAlreadyToasted(new Error(GSAP_TOOK_OVER));
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
let plainMoveCounter = 0;
|
|
36
54
|
|
|
37
55
|
/** GSAP does not position the element: `next` is its whole translate, live now and saved as drawn. */
|
|
@@ -58,17 +76,19 @@ function stagePlainTranslate(
|
|
|
58
76
|
return { save, rollback };
|
|
59
77
|
}
|
|
60
78
|
|
|
61
|
-
/** Applies a move on the element itself live now: its translate
|
|
62
|
-
*
|
|
79
|
+
/** Applies a move on the element itself live now: its translate on the plain route, else
|
|
80
|
+
* left/top for a shared-tween element. Throws, after a toast, when it cannot. */
|
|
63
81
|
export function stageElementOffset(
|
|
64
82
|
{ commitPositionPatchToHtml, showToast, readOnlyPreview }: ElementOffsetStagerDeps,
|
|
65
83
|
selection: DomEditSelection,
|
|
66
84
|
next: { x: number; y: number },
|
|
85
|
+
plainTranslate: boolean,
|
|
67
86
|
coalesceKey?: string,
|
|
68
87
|
): { save: () => Promise<void>; rollback: () => void } {
|
|
69
88
|
const el = selection.element;
|
|
70
89
|
if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
|
|
71
|
-
if (
|
|
90
|
+
if (plainTranslate) {
|
|
91
|
+
refuseGsapTakeover(el, showToast);
|
|
72
92
|
return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey);
|
|
73
93
|
}
|
|
74
94
|
const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
|
|
@@ -35,7 +35,11 @@ import {
|
|
|
35
35
|
pickClosestToPlayhead,
|
|
36
36
|
readGsapPositionFromIframe,
|
|
37
37
|
} from "./gsapPositionDetection";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
hasNonHoldTweenForElement,
|
|
40
|
+
POSITION_CHANNELS,
|
|
41
|
+
ROTATION_CHANNELS,
|
|
42
|
+
} from "./gsapRuntimeKeyframes";
|
|
39
43
|
import { getAnimationsForElement } from "./gsapElementMatch";
|
|
40
44
|
import {
|
|
41
45
|
animationWritesAnyProperty,
|
|
@@ -44,8 +48,6 @@ import {
|
|
|
44
48
|
} from "./gsapEditOutcome";
|
|
45
49
|
|
|
46
50
|
const POSITION_CHANNEL_SET = new Set<string>(POSITION_CHANNELS);
|
|
47
|
-
|
|
48
|
-
const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
|
|
49
51
|
const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
|
|
50
52
|
|
|
51
53
|
// ── Property-group tween resolution ───────────────────────────────────────
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
|
|
12
12
|
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
|
|
13
13
|
import { roundTo3 } from "../utils/rounding";
|
|
14
|
+
import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* A GSAP tween's `vars` object — intentionally open: it mixes channel values
|
|
@@ -439,11 +440,44 @@ function gsapWritesChannels(el: Element, channels: string[]): boolean {
|
|
|
439
440
|
);
|
|
440
441
|
}
|
|
441
442
|
|
|
443
|
+
// GSAP's CSSPlugin also takes rotate, rotateX/Y/Z for rotation.
|
|
444
|
+
export const ROTATION_CHANNELS: string[] = [
|
|
445
|
+
...["rotation", "rotationX", "rotationY", "rotationZ"],
|
|
446
|
+
...["rotate", "rotateX", "rotateY", "rotateZ"],
|
|
447
|
+
];
|
|
448
|
+
|
|
449
|
+
const gsapRendersTransform = (el: Element) =>
|
|
450
|
+
!!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform;
|
|
451
|
+
|
|
442
452
|
/** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
|
|
443
453
|
* transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
|
|
444
454
|
export function gsapWritesPosition(el: Element): boolean {
|
|
445
|
-
|
|
446
|
-
|
|
455
|
+
return gsapRendersTransform(el) || gsapWritesChannels(el, MOVE_CHANNELS);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** `gsapWritesPosition` for a rotate: everything else turns by its own CSS `rotate`. */
|
|
459
|
+
export function gsapWritesRotation(el: Element): boolean {
|
|
460
|
+
return gsapRendersTransform(el) || gsapWritesChannels(el, ROTATION_CHANNELS);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function gsapHoldsTranslate(el: Element): boolean {
|
|
464
|
+
const cache = (el as { _gsap?: Record<string, unknown> })._gsap;
|
|
465
|
+
return ["x", "y", "xPercent", "yPercent"].some(
|
|
466
|
+
(key) => !!Number.parseFloat(String(cache?.[key])),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const BOX_CHANNELS = [
|
|
471
|
+
...BOX_SIZE_STYLE_PROPS.map((prop) =>
|
|
472
|
+
prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()),
|
|
473
|
+
),
|
|
474
|
+
"scaleX",
|
|
475
|
+
"scaleY",
|
|
476
|
+
];
|
|
477
|
+
|
|
478
|
+
/** GSAP owns this element's box: its position, or any property the CSS box writer sets. Else a resize writes CSS. */
|
|
479
|
+
export function gsapWritesBox(el: Element): boolean {
|
|
480
|
+
return gsapWritesPosition(el) || gsapWritesChannels(el, BOX_CHANNELS);
|
|
447
481
|
}
|
|
448
482
|
|
|
449
483
|
/** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
+
import {
|
|
6
|
+
applyStudioRotation,
|
|
7
|
+
reapplyPositionEditsAfterSeek,
|
|
8
|
+
} from "../components/editor/manualEdits";
|
|
9
|
+
import { STUDIO_ROTATION_ATTR } from "../components/editor/manualEditsTypes";
|
|
10
|
+
import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
|
|
11
|
+
import { savePlainRotation } from "./plainRotation";
|
|
12
|
+
import { readShownRotation } from "../components/editor/plainTranslate";
|
|
13
|
+
|
|
14
|
+
function rotate(
|
|
15
|
+
element: HTMLElement,
|
|
16
|
+
angle: number,
|
|
17
|
+
options: { save?: () => Promise<void>; readOnlyPreview?: boolean } = {},
|
|
18
|
+
) {
|
|
19
|
+
element.id = "box";
|
|
20
|
+
document.body.append(element);
|
|
21
|
+
const commitPositionPatchToHtml = vi.fn<ElementOffsetStagerDeps["commitPositionPatchToHtml"]>(
|
|
22
|
+
options.save ?? (() => Promise.resolve()),
|
|
23
|
+
);
|
|
24
|
+
const selection = { element, id: "box", selector: "#box" } as unknown as DomEditSelection;
|
|
25
|
+
const saved = savePlainRotation(
|
|
26
|
+
{ commitPositionPatchToHtml, readOnlyPreview: options.readOnlyPreview },
|
|
27
|
+
selection,
|
|
28
|
+
{ angle },
|
|
29
|
+
);
|
|
30
|
+
return { saved, commitPositionPatchToHtml, selection };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("savePlainRotation", () => {
|
|
34
|
+
it("saves the element's own rotate, less what its transform turns, with no Studio marks", async () => {
|
|
35
|
+
const element = document.createElement("div");
|
|
36
|
+
element.style.transform = "rotate(10deg)";
|
|
37
|
+
const { saved, commitPositionPatchToHtml, selection } = rotate(element, 55);
|
|
38
|
+
await saved;
|
|
39
|
+
expect(element.style.getPropertyValue("rotate")).toBe("45deg");
|
|
40
|
+
expect(commitPositionPatchToHtml).toHaveBeenCalledWith(
|
|
41
|
+
selection,
|
|
42
|
+
[{ type: "inline-style", property: "rotate", value: "45deg" }],
|
|
43
|
+
expect.objectContaining({ label: "Rotate layer" }),
|
|
44
|
+
);
|
|
45
|
+
expect(element.hasAttribute(STUDIO_ROTATION_ATTR)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("drops a legacy Studio rotation in the same write, so a seek does not put its angle back", async () => {
|
|
49
|
+
const element = document.createElement("div");
|
|
50
|
+
applyStudioRotation(element, { angle: 15 });
|
|
51
|
+
const { saved, commitPositionPatchToHtml } = rotate(element, 40);
|
|
52
|
+
await saved;
|
|
53
|
+
const patches = commitPositionPatchToHtml.mock.calls[0]![1];
|
|
54
|
+
expect(patches).toContainEqual({
|
|
55
|
+
type: "attribute",
|
|
56
|
+
property: STUDIO_ROTATION_ATTR,
|
|
57
|
+
value: null,
|
|
58
|
+
});
|
|
59
|
+
expect(patches.at(-1)).toEqual({ type: "inline-style", property: "rotate", value: "40deg" });
|
|
60
|
+
reapplyPositionEditsAfterSeek(document);
|
|
61
|
+
expect(element.style.getPropertyValue("rotate")).toBe("40deg");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("puts the live rotate back when the save fails", async () => {
|
|
65
|
+
const element = document.createElement("div");
|
|
66
|
+
element.style.setProperty("rotate", "12deg");
|
|
67
|
+
const { saved } = rotate(element, 30, { save: () => Promise.reject(new Error("offline")) });
|
|
68
|
+
await expect(saved).rejects.toThrow("offline");
|
|
69
|
+
expect(element.style.getPropertyValue("rotate")).toBe("12deg");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("writes nothing in a read-only preview", async () => {
|
|
73
|
+
const element = document.createElement("div");
|
|
74
|
+
const { saved, commitPositionPatchToHtml } = rotate(element, 30, { readOnlyPreview: true });
|
|
75
|
+
await saved;
|
|
76
|
+
expect(element.style.getPropertyValue("rotate")).toBe("");
|
|
77
|
+
expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("saves a transform-centred element's turn in its transform and puts it back on a failed save", async () => {
|
|
81
|
+
const element = document.createElement("div");
|
|
82
|
+
element.style.transform = "translate(-120px, -80px)";
|
|
83
|
+
const { saved, commitPositionPatchToHtml } = rotate(element, 25, {
|
|
84
|
+
save: () => Promise.reject(new Error("offline")),
|
|
85
|
+
});
|
|
86
|
+
expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
|
|
87
|
+
{
|
|
88
|
+
type: "inline-style",
|
|
89
|
+
property: "transform",
|
|
90
|
+
value: "translate(-120px, -80px) rotate(25deg)",
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
93
|
+
await expect(saved).rejects.toThrow("offline");
|
|
94
|
+
expect(element.style.getPropertyValue("transform")).toBe("translate(-120px, -80px)");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("makes an inline element inline-block, as a turn needs, and puts it back on a failed save", async () => {
|
|
98
|
+
const element = document.createElement("span");
|
|
99
|
+
element.style.setProperty("display", "inline");
|
|
100
|
+
const { saved, commitPositionPatchToHtml } = rotate(element, 20, {
|
|
101
|
+
save: () => Promise.reject(new Error("offline")),
|
|
102
|
+
});
|
|
103
|
+
expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
|
|
104
|
+
{ type: "inline-style", property: "display", value: "inline-block" },
|
|
105
|
+
{ type: "inline-style", property: "rotate", value: "20deg" },
|
|
106
|
+
]);
|
|
107
|
+
await expect(saved).rejects.toThrow("offline");
|
|
108
|
+
expect(element.style.getPropertyValue("display")).toBe("inline");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("readShownRotation", () => {
|
|
113
|
+
it("shows an element's own CSS turn, and GSAP's legacy reading once GSAP turns it", () => {
|
|
114
|
+
const element = document.createElement("div");
|
|
115
|
+
document.body.append(element);
|
|
116
|
+
element.style.setProperty("rotate", "25deg");
|
|
117
|
+
expect(readShownRotation(element)).toEqual({ angle: 25 });
|
|
118
|
+
Object.assign(element, { _gsap: { renderTransform: () => {} } });
|
|
119
|
+
applyStudioRotation(element, { angle: 15 });
|
|
120
|
+
expect(readShownRotation(element)).toEqual({ angle: 15 });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
2
|
+
import { captureStudioRotation, clearStudioRotation } from "../components/editor/manualEdits";
|
|
3
|
+
import { buildClearRotationPatches } from "../components/editor/manualEditsDomPatches";
|
|
4
|
+
import { STUDIO_ROTATION_ATTR } from "../components/editor/manualEditsTypes";
|
|
5
|
+
import {
|
|
6
|
+
applyCssRotation,
|
|
7
|
+
restorePlainRotation,
|
|
8
|
+
type RotationCommit,
|
|
9
|
+
} from "../components/editor/rotationDraft";
|
|
10
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
11
|
+
import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
|
|
12
|
+
|
|
13
|
+
let plainRotateCounter = 0;
|
|
14
|
+
|
|
15
|
+
/** GSAP does not turn the element: `next` is its whole angle, drawn and saved as its CSS turn. */
|
|
16
|
+
export function savePlainRotation(
|
|
17
|
+
{ commitPositionPatchToHtml, readOnlyPreview }: Omit<ElementOffsetStagerDeps, "showToast">,
|
|
18
|
+
selection: DomEditSelection,
|
|
19
|
+
next: RotationCommit,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
if (readOnlyPreview) return Promise.resolve();
|
|
22
|
+
const { element } = selection;
|
|
23
|
+
const before = captureStudioRotation(element);
|
|
24
|
+
// Legacy marks go in the same write, or a seek puts their angle back; clearing them voids the press read.
|
|
25
|
+
const patches: PatchOperation[] = element.hasAttribute(STUDIO_ROTATION_ATTR)
|
|
26
|
+
? buildClearRotationPatches(element)
|
|
27
|
+
: [];
|
|
28
|
+
if (patches.length) clearStudioRotation(element);
|
|
29
|
+
const drawn = applyCssRotation(element, next.angle, patches.length ? undefined : next.plain);
|
|
30
|
+
patches.push(...drawn);
|
|
31
|
+
const turn = drawn.at(-1)!;
|
|
32
|
+
return commitPositionPatchToHtml(selection, patches, {
|
|
33
|
+
label: "Rotate layer",
|
|
34
|
+
coalesceKey: `rotate:${++plainRotateCounter}`,
|
|
35
|
+
coalesceMs: Number.POSITIVE_INFINITY,
|
|
36
|
+
}).catch((error) => {
|
|
37
|
+
if (element.style.getPropertyValue(turn.property) === turn.value) {
|
|
38
|
+
restorePlainRotation(element, before);
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -24,8 +24,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
24
24
|
import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
|
|
25
25
|
import type { RecordEditInput } from "./domEditDeleteMembers";
|
|
26
26
|
import type { DomEditTimelineParams } from "./useDomSelectionTypes";
|
|
27
|
-
// Re-exported: the delete rule lives in its own module
|
|
28
|
-
// own test) have always imported it from here.
|
|
27
|
+
// Re-exported: the delete rule lives in its own module; callers and its test import it from here.
|
|
29
28
|
export { membersForDelete };
|
|
30
29
|
|
|
31
30
|
export interface UseDomEditSessionParams extends DomEditTimelineParams {
|
|
@@ -232,6 +231,7 @@ export function useDomEditSession({
|
|
|
232
231
|
stageElementPositionOffset,
|
|
233
232
|
commitPositionPatchToHtml,
|
|
234
233
|
handleDomBoxSizeCommit,
|
|
234
|
+
handleDomRotationCommit: handleDomCssRotationCommit,
|
|
235
235
|
handleDomManualEditsReset,
|
|
236
236
|
handleDomEditElementsDelete,
|
|
237
237
|
handleDomZIndexReorderCommit,
|
|
@@ -256,9 +256,8 @@ export function useDomEditSession({
|
|
|
256
256
|
readOnlyPreview,
|
|
257
257
|
onTrySdkPersist: sdkSession
|
|
258
258
|
? (selection, operations, originalContent, targetPath, options) => {
|
|
259
|
-
// Decoupled tripwire,
|
|
260
|
-
//
|
|
261
|
-
// nodes); the paths let a cross-file edit skip instead of a false not-found.
|
|
259
|
+
// Decoupled tripwire, regardless of the cutover flag. originalContent lets the runtime-node
|
|
260
|
+
// filter drop hf-ids absent from source; the paths skip a cross-file edit, not a not-found.
|
|
262
261
|
runResolverShadow(sdkSession, selection.hfId, operations, originalContent, {
|
|
263
262
|
targetPath,
|
|
264
263
|
compositionPath: activeCompPath,
|
|
@@ -491,6 +490,7 @@ export function useDomEditSession({
|
|
|
491
490
|
trackGsapInteractionFailure,
|
|
492
491
|
stageElementPositionOffset,
|
|
493
492
|
handleDomBoxSizeCommit,
|
|
493
|
+
handleDomRotationCommit: handleDomCssRotationCommit,
|
|
494
494
|
commitPositionPatchToHtml,
|
|
495
495
|
addGsapAnimation,
|
|
496
496
|
convertToKeyframes,
|
|
@@ -176,6 +176,28 @@ describe("useDomGeometryCommit, from the package entry", () => {
|
|
|
176
176
|
unmount();
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
it("saves a GSAP-free resize as its inline size, with no GSAP script and no animation read", async () => {
|
|
180
|
+
const calls = stubPatchServer();
|
|
181
|
+
const { element, recordEdit, hook, unmount } = renderHost();
|
|
182
|
+
|
|
183
|
+
await expect(
|
|
184
|
+
hook().commitBoxSize(makeSelection("card", element), { width: 300, height: 90 }),
|
|
185
|
+
).resolves.toEqual({ ok: true });
|
|
186
|
+
|
|
187
|
+
expect(element.style.getPropertyValue("width")).toBe("300px");
|
|
188
|
+
expect(calls.patches).toEqual([
|
|
189
|
+
expect.objectContaining({
|
|
190
|
+
operations: expect.arrayContaining([
|
|
191
|
+
{ type: "inline-style", property: "width", value: "300px" },
|
|
192
|
+
{ type: "inline-style", property: "height", value: "90px" },
|
|
193
|
+
]),
|
|
194
|
+
}),
|
|
195
|
+
]);
|
|
196
|
+
expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
|
|
197
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
198
|
+
unmount();
|
|
199
|
+
});
|
|
200
|
+
|
|
179
201
|
it("puts a GSAP-free element's translate back when its move cannot be saved", async () => {
|
|
180
202
|
stubPatchServer(500);
|
|
181
203
|
const { element, hook, unmount } = renderHost();
|
|
@@ -188,6 +210,57 @@ describe("useDomGeometryCommit, from the package entry", () => {
|
|
|
188
210
|
unmount();
|
|
189
211
|
});
|
|
190
212
|
|
|
213
|
+
it("refuses a CSS move GSAP has folded into its x/y, writes nothing, and hands the translate back", async () => {
|
|
214
|
+
const calls = stubPatchServer();
|
|
215
|
+
const { element, hook, unmount } = renderHost();
|
|
216
|
+
element.style.setProperty("translate", "none");
|
|
217
|
+
Object.assign(element, { _gsap: { renderTransform: () => {}, x: "94px", y: "66px" } });
|
|
218
|
+
const set = vi.fn();
|
|
219
|
+
Object.assign(element.ownerDocument.defaultView!, { gsap: { set } });
|
|
220
|
+
|
|
221
|
+
await expect(
|
|
222
|
+
hook().commitPathOffset(
|
|
223
|
+
makeSelection("card", element),
|
|
224
|
+
{ x: 1, y: 2 },
|
|
225
|
+
{ plainTranslate: true },
|
|
226
|
+
),
|
|
227
|
+
).rejects.toThrow(/animation took over/);
|
|
228
|
+
delete (element.ownerDocument.defaultView as { gsap?: unknown }).gsap;
|
|
229
|
+
expect(calls.patches).toEqual([]);
|
|
230
|
+
expect(set).toHaveBeenCalledWith(element, { x: 0, y: 0, xPercent: 0, yPercent: 0 });
|
|
231
|
+
unmount();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("saves a CSS move GSAP has only parsed, with nothing folded into its x/y", async () => {
|
|
235
|
+
const calls = stubPatchServer();
|
|
236
|
+
const { element, hook, unmount } = renderHost();
|
|
237
|
+
Object.assign(element, {
|
|
238
|
+
_gsap: { renderTransform: () => {}, x: "0px", y: "0px", xPercent: 0 },
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
await expect(
|
|
242
|
+
hook().commitPathOffset(
|
|
243
|
+
makeSelection("card", element),
|
|
244
|
+
{ x: 1, y: 2 },
|
|
245
|
+
{ plainTranslate: true },
|
|
246
|
+
),
|
|
247
|
+
).resolves.toEqual({ ok: true });
|
|
248
|
+
expect(calls.patches).toHaveLength(1);
|
|
249
|
+
unmount();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("leaves a later move's translate alone when an earlier move's save fails", async () => {
|
|
253
|
+
stubPatchServer(500);
|
|
254
|
+
const { element, hook, unmount } = renderHost();
|
|
255
|
+
element.style.setProperty("translate", "40px 30px");
|
|
256
|
+
|
|
257
|
+
const saving = hook().commitPathOffset(makeSelection("card", element), { x: 1, y: 2 });
|
|
258
|
+
element.style.setProperty("translate", "7px 8px");
|
|
259
|
+
await expect(saving).rejects.toThrow();
|
|
260
|
+
expect(element.style.getPropertyValue("translate")).toBe("7px 8px");
|
|
261
|
+
unmount();
|
|
262
|
+
});
|
|
263
|
+
|
|
191
264
|
it("keeps the same commits across renders, so the overlay's handlers stay put", () => {
|
|
192
265
|
stubServer();
|
|
193
266
|
const { hook, rerender, unmount } = renderHost();
|
|
@@ -216,6 +289,8 @@ describe("useDomGeometryCommit, from the package entry", () => {
|
|
|
216
289
|
const showToast = vi.fn();
|
|
217
290
|
const restore = vi.fn();
|
|
218
291
|
const { element, recordEdit, hook, unmount } = renderHost({ showToast });
|
|
292
|
+
// GSAP renders the card's transform, so its resize reads animations first.
|
|
293
|
+
Object.assign(element, { _gsap: { renderTransform: () => undefined } });
|
|
219
294
|
|
|
220
295
|
await expect(
|
|
221
296
|
hook().commitBoxSize(
|