@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
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
applyStudioPathOffset,
|
|
11
11
|
applyStudioPathOffsetDraft,
|
|
12
12
|
applyStudioRotation,
|
|
13
|
-
applyStudioRotationDraft,
|
|
14
13
|
beginStudioManualEditGesture,
|
|
15
14
|
captureStudioBoxSize,
|
|
16
15
|
captureStudioRotation,
|
|
@@ -210,16 +209,11 @@ describe("studio manual edits", () => {
|
|
|
210
209
|
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
|
|
211
210
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
212
211
|
|
|
213
|
-
applyStudioRotationDraft(card, { angle: -12.2604 });
|
|
214
|
-
expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
|
|
215
|
-
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
|
|
216
|
-
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
217
|
-
|
|
218
212
|
const snapshot = captureStudioRotation(card);
|
|
219
|
-
|
|
213
|
+
applyStudioRotation(card, { angle: 45 });
|
|
220
214
|
restoreStudioRotation(card, snapshot);
|
|
221
|
-
expect(readStudioRotation(card)).toEqual({ angle:
|
|
222
|
-
expect(card.style.getPropertyValue(
|
|
215
|
+
expect(readStudioRotation(card)).toEqual({ angle: 24.247 });
|
|
216
|
+
expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.247deg");
|
|
223
217
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
224
218
|
});
|
|
225
219
|
|
|
@@ -538,13 +538,3 @@ export function applyStudioRotation(element: HTMLElement, rotation: { angle: num
|
|
|
538
538
|
composeStudioRotationValue(element, `var(${STUDIO_ROTATION_PROP}, 0deg)`),
|
|
539
539
|
);
|
|
540
540
|
}
|
|
541
|
-
|
|
542
|
-
export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle: number }): void {
|
|
543
|
-
promoteInlineForTransform(element);
|
|
544
|
-
writeStudioRotationVars(element, rotation, { updateBase: false });
|
|
545
|
-
element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
|
|
546
|
-
element.style.setProperty(
|
|
547
|
-
"rotate",
|
|
548
|
-
composeStudioRotationValue(element, `${roundTo3(rotation.angle)}deg`),
|
|
549
|
-
);
|
|
550
|
-
}
|
|
@@ -46,7 +46,6 @@ import {
|
|
|
46
46
|
buildClearPathOffsetPatches,
|
|
47
47
|
buildBoxSizePatches,
|
|
48
48
|
buildClearBoxSizePatches,
|
|
49
|
-
buildRotationPatches,
|
|
50
49
|
buildClearRotationPatches,
|
|
51
50
|
buildMotionPatches,
|
|
52
51
|
buildClearMotionPatches,
|
|
@@ -335,45 +334,7 @@ describe("anchored-corner combined patch: [...buildBoxSizePatches, ...buildPathO
|
|
|
335
334
|
|
|
336
335
|
/* ── Rotation ────────────────────────────────────────────────────────────── */
|
|
337
336
|
|
|
338
|
-
describe("
|
|
339
|
-
function populatedRotEl(): HTMLElement {
|
|
340
|
-
const e = div();
|
|
341
|
-
e.style.setProperty(STUDIO_ROTATION_PROP, "45");
|
|
342
|
-
e.style.setProperty("rotate", "45deg");
|
|
343
|
-
e.style.setProperty("transform-origin", "left center");
|
|
344
|
-
e.style.setProperty("display", "block");
|
|
345
|
-
e.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, "0deg");
|
|
346
|
-
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "0deg");
|
|
347
|
-
e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "center center");
|
|
348
|
-
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "flex");
|
|
349
|
-
return e;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
it("populated: captures rotation styles, attrs, and transform-display marker in declaration order", () => {
|
|
353
|
-
const ops = buildRotationPatches(populatedRotEl());
|
|
354
|
-
expect(ops).toEqual([
|
|
355
|
-
{ type: "inline-style", property: STUDIO_ROTATION_PROP, value: "45" },
|
|
356
|
-
{ type: "inline-style", property: "rotate", value: "45deg" },
|
|
357
|
-
{ type: "inline-style", property: "transform-origin", value: "left center" },
|
|
358
|
-
{ type: "inline-style", property: "display", value: "block" },
|
|
359
|
-
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
|
|
360
|
-
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: "0deg" },
|
|
361
|
-
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: "0deg" },
|
|
362
|
-
{
|
|
363
|
-
type: "attribute",
|
|
364
|
-
property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
|
365
|
-
value: "center center",
|
|
366
|
-
},
|
|
367
|
-
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "flex" },
|
|
368
|
-
]);
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it("empty: bare element yields only the rotation marker", () => {
|
|
372
|
-
expect(buildRotationPatches(div())).toEqual([
|
|
373
|
-
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
|
|
374
|
-
]);
|
|
375
|
-
});
|
|
376
|
-
|
|
337
|
+
describe("buildClearRotationPatches", () => {
|
|
377
338
|
it("clear: restores rotate and transform-origin from orig attrs, nulls draft attr", () => {
|
|
378
339
|
const e = div();
|
|
379
340
|
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "30deg");
|
|
@@ -405,11 +366,6 @@ describe("buildRotationPatches / buildClearRotationPatches", () => {
|
|
|
405
366
|
const ops = buildClearRotationPatches(e);
|
|
406
367
|
expect(ops.find((o) => o.property === "rotate")?.value).toBeNull();
|
|
407
368
|
});
|
|
408
|
-
|
|
409
|
-
it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
|
|
410
|
-
const e = populatedRotEl();
|
|
411
|
-
assertClearCoversKeys(buildRotationPatches(e), buildClearRotationPatches(e));
|
|
412
|
-
});
|
|
413
369
|
});
|
|
414
370
|
|
|
415
371
|
/* ── Motion ──────────────────────────────────────────────────────────────── */
|
|
@@ -118,7 +118,7 @@ export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperatio
|
|
|
118
118
|
|
|
119
119
|
/* ── Box size patches ────────────────────────────────────────────── */
|
|
120
120
|
|
|
121
|
-
const BOX_SIZE_STYLE_PROPS = [
|
|
121
|
+
export const BOX_SIZE_STYLE_PROPS = [
|
|
122
122
|
"width",
|
|
123
123
|
"height",
|
|
124
124
|
"min-width",
|
|
@@ -185,28 +185,6 @@ export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[]
|
|
|
185
185
|
|
|
186
186
|
/* ── Rotation patches ────────────────────────────────────────────── */
|
|
187
187
|
|
|
188
|
-
const ROTATION_STYLE_PROPS = [
|
|
189
|
-
STUDIO_ROTATION_PROP,
|
|
190
|
-
"rotate",
|
|
191
|
-
"transform-origin",
|
|
192
|
-
"display",
|
|
193
|
-
] as const;
|
|
194
|
-
|
|
195
|
-
const ROTATION_ORIG_ATTRS = [
|
|
196
|
-
STUDIO_ORIGINAL_ROTATE_ATTR,
|
|
197
|
-
STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
|
|
198
|
-
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
|
199
|
-
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
|
200
|
-
] as const;
|
|
201
|
-
|
|
202
|
-
export function buildRotationPatches(element: HTMLElement): PatchOperation[] {
|
|
203
|
-
const ops: PatchOperation[] = [];
|
|
204
|
-
collectInlineStyleOps(element, ROTATION_STYLE_PROPS, ops);
|
|
205
|
-
ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" });
|
|
206
|
-
collectAttributeOps(element, ROTATION_ORIG_ATTRS, ops);
|
|
207
|
-
return ops;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
188
|
export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] {
|
|
211
189
|
const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
|
|
212
190
|
const origRotationTransformOrigin = element.getAttribute(
|
|
@@ -82,6 +82,8 @@ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapsho
|
|
|
82
82
|
export function captureStudioRotation(element: HTMLElement): StudioRotationSnapshot {
|
|
83
83
|
return {
|
|
84
84
|
rotate: element.style.getPropertyValue("rotate"),
|
|
85
|
+
transform: element.style.getPropertyValue("transform"),
|
|
86
|
+
display: element.style.getPropertyValue("display"),
|
|
85
87
|
transformOrigin: element.style.getPropertyValue("transform-origin"),
|
|
86
88
|
studioRotation: element.style.getPropertyValue(STUDIO_ROTATION_PROP),
|
|
87
89
|
marker: element.getAttribute(STUDIO_ROTATION_ATTR),
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
resolveManualOffsetForPointerDelta,
|
|
14
14
|
type ManualOffsetDragMatrix,
|
|
15
15
|
} from "./manualOffsetDrag";
|
|
16
|
+
import { UNREADABLE_TRANSLATE } from "./plainTranslate";
|
|
16
17
|
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
|
|
17
18
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
18
19
|
|
|
@@ -596,6 +597,21 @@ describe("a move of an element GSAP does not position", () => {
|
|
|
596
597
|
return { window, element, gsapCalls, member };
|
|
597
598
|
}
|
|
598
599
|
|
|
600
|
+
it("refuses a translate it can't read instead of guessing, and leaves the element alone", () => {
|
|
601
|
+
const { element } = plainBox();
|
|
602
|
+
element.style.setProperty("translate", "abs(10% - 50px) 0px");
|
|
603
|
+
const result = createManualOffsetDragMember({
|
|
604
|
+
key: "box",
|
|
605
|
+
selection: { element } as never,
|
|
606
|
+
element,
|
|
607
|
+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
|
|
608
|
+
gesture: "drag",
|
|
609
|
+
});
|
|
610
|
+
expect(result.ok).toBe(false);
|
|
611
|
+
expect(result.ok ? "" : result.reason).toBe(UNREADABLE_TRANSLATE);
|
|
612
|
+
expect(element.style.getPropertyValue("translate")).toBe("abs(10% - 50px) 0px");
|
|
613
|
+
});
|
|
614
|
+
|
|
599
615
|
it("drafts and drops the element's own translate in plain px, never touching GSAP", () => {
|
|
600
616
|
const { element, gsapCalls, member } = plainBox({ gsapLoaded: true });
|
|
601
617
|
const m = member();
|
|
@@ -636,3 +652,44 @@ describe("a move of an element GSAP does not position", () => {
|
|
|
636
652
|
expect(member().plainTranslate).toBe(false);
|
|
637
653
|
});
|
|
638
654
|
});
|
|
655
|
+
|
|
656
|
+
describe("a resize's anchor member", () => {
|
|
657
|
+
function anchorMember(tweenVars?: Record<string, number>) {
|
|
658
|
+
const window = new Window();
|
|
659
|
+
const element = window.document.createElement("div");
|
|
660
|
+
element.id = "box";
|
|
661
|
+
window.document.body.append(element);
|
|
662
|
+
const tweens = tweenVars ? [{ vars: tweenVars, targets: () => [element] }] : [];
|
|
663
|
+
Object.assign(window, { __timelines: { main: { getChildren: () => tweens } } });
|
|
664
|
+
element.getBoundingClientRect = () => {
|
|
665
|
+
const [x = 0, y = 0] = element.style
|
|
666
|
+
.getPropertyValue("translate")
|
|
667
|
+
.split(" ")
|
|
668
|
+
.map((v) => Number.parseFloat(v));
|
|
669
|
+
return new window.DOMRect(10 + (x || 0), 20 + (y || 0), 100, 50);
|
|
670
|
+
};
|
|
671
|
+
const result = createManualOffsetDragMember({
|
|
672
|
+
key: "box",
|
|
673
|
+
selection: { element } as never,
|
|
674
|
+
element,
|
|
675
|
+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
|
|
676
|
+
gesture: "resize",
|
|
677
|
+
});
|
|
678
|
+
if (!result.ok) throw new Error("member");
|
|
679
|
+
return result.member;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
it("keeps the centre with the element's own plain translate when GSAP does not own the box", () => {
|
|
683
|
+
const member = anchorMember();
|
|
684
|
+
expect(member.plainTranslate).toBe(true);
|
|
685
|
+
applyManualOffsetDragDraft(member, -10.25, 3.5);
|
|
686
|
+
expect(member.element.style.getPropertyValue("translate")).toBe("-10.25px 3.5px");
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it.each(["width", "maxWidth", "minHeight", "flexBasis", "scale", "scaleX"])(
|
|
690
|
+
"leaves a box whose %s GSAP tweens, which the CSS box writer also sets, to the GSAP writer",
|
|
691
|
+
(channel) => {
|
|
692
|
+
expect(anchorMember({ [channel]: 300 }).plainTranslate).toBe(false);
|
|
693
|
+
},
|
|
694
|
+
);
|
|
695
|
+
});
|
|
@@ -11,15 +11,15 @@ import {
|
|
|
11
11
|
type StudioPathOffsetSnapshot,
|
|
12
12
|
} from "./manualEdits";
|
|
13
13
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
|
-
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
|
-
import { readTranslatePx, writeTranslatePx } from "./plainTranslate";
|
|
14
|
+
import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
|
+
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
|
|
16
16
|
|
|
17
17
|
interface OffsetDragGsap {
|
|
18
18
|
set: (el: Element, vars: Record<string, number | string>) => void;
|
|
19
19
|
getProperty: (el: Element, prop: string) => number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
|
|
22
|
+
export function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
|
|
23
23
|
const win = element.ownerDocument.defaultView as
|
|
24
24
|
| (Window & { gsap?: Partial<OffsetDragGsap> })
|
|
25
25
|
| null;
|
|
@@ -53,27 +53,6 @@ function applyOffsetDragDraftViaGsap(
|
|
|
53
53
|
return true;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
57
|
-
* Live rotation preview through the GSAP channel — the SAME channel the commit
|
|
58
|
-
* lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
|
|
59
|
-
* GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
|
|
60
|
-
* the two channels from composing. `angle` is the absolute target rotation. Returns
|
|
61
|
-
* false when gsap is unavailable (caller falls back to the CSS draft).
|
|
62
|
-
*/
|
|
63
|
-
export function applyRotationDraftViaGsap(element: HTMLElement, angle: number): boolean {
|
|
64
|
-
const gsap = getOffsetDragGsap(element);
|
|
65
|
-
if (!gsap) return false;
|
|
66
|
-
element.style.setProperty("rotate", "none");
|
|
67
|
-
gsap.set(element, { rotation: angle });
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */
|
|
72
|
-
export function readGsapRotation(element: HTMLElement): number {
|
|
73
|
-
const gsap = getOffsetDragGsap(element);
|
|
74
|
-
return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
56
|
const DEFAULT_OFFSET_PROBE_PX = 100;
|
|
78
57
|
const MIN_PROBE_VECTOR_LENGTH_PX = 0.01;
|
|
79
58
|
const MIN_MATRIX_DETERMINANT = 0.000001;
|
|
@@ -337,14 +316,16 @@ export function createManualOffsetDragMember(input: {
|
|
|
337
316
|
selection: DomEditSelection;
|
|
338
317
|
element: HTMLElement;
|
|
339
318
|
rect: ManualOffsetDragRect;
|
|
340
|
-
gesture?: "drag" | "nudge"; //
|
|
319
|
+
gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
|
|
341
320
|
}): ManualOffsetDragMemberResult {
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
// so a stale
|
|
321
|
+
const gsapOwns = input.gesture === "resize" ? gsapWritesBox : gsapWritesPosition;
|
|
322
|
+
const plainTranslate = !!input.gesture && !gsapOwns(input.element);
|
|
323
|
+
// The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen.
|
|
345
324
|
const initialOffset = plainTranslate
|
|
346
325
|
? readTranslatePx(input.element)
|
|
347
326
|
: readAppliedStudioPathOffset(input.element);
|
|
327
|
+
if (!finitePoint(initialOffset))
|
|
328
|
+
return { ok: false, reason: UNREADABLE_TRANSLATE, selection: input.selection };
|
|
348
329
|
const win = input.element.ownerDocument.defaultView as
|
|
349
330
|
| (Window & {
|
|
350
331
|
gsap?: { getProperty?: (el: Element, prop: string) => number };
|
|
@@ -382,10 +363,8 @@ export function createManualOffsetDragMember(input: {
|
|
|
382
363
|
plainTranslate,
|
|
383
364
|
});
|
|
384
365
|
if (!measured.ok) {
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
// GSAP position from the iframe runtime, so visual imprecision during
|
|
388
|
-
// drag is acceptable — the final committed position is always exact.
|
|
366
|
+
// GSAP transforms can defeat the probe: approximate with the preview scale. The commit
|
|
367
|
+
// reads GSAP's real position, so only the drag preview is imprecise.
|
|
389
368
|
const scaleX = input.rect.editScaleX || 1;
|
|
390
369
|
const scaleY = input.rect.editScaleY || 1;
|
|
391
370
|
const w = readTransformWDivisor(input.element);
|
|
@@ -28,18 +28,31 @@ describe("readTranslatePx", () => {
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it.each([
|
|
31
|
-
"min(10px, 5%)
|
|
32
|
-
"
|
|
33
|
-
"calc(10px +)
|
|
34
|
-
"
|
|
35
|
-
])("
|
|
36
|
-
|
|
37
|
-
const before = element.getAttribute("style");
|
|
38
|
-
const read = readTranslatePx(element);
|
|
39
|
-
expect(Number.isFinite(read.x) && Number.isFinite(read.y)).toBe(true);
|
|
40
|
-
expect(element.getAttribute("style")).toBe(before);
|
|
31
|
+
["min(10px, 5%) max(-20%, -40px)", { x: 10, y: -32 }],
|
|
32
|
+
["clamp(0px, 10%, 30px) 5px", { x: 24, y: 5 }],
|
|
33
|
+
["calc(min(50%, 100px) * 2 - (10px + 5%)) calc(-1 * clamp(10%, 1px, 20%))", { x: 178, y: -16 }],
|
|
34
|
+
["calc(2 * 10px) calc(80px / 4)", { x: 20, y: 20 }],
|
|
35
|
+
])("works out %s by arithmetic", (translate, want) => {
|
|
36
|
+
expect(readTranslatePx(box(`translate: ${translate}`))).toEqual(want);
|
|
41
37
|
});
|
|
42
38
|
|
|
39
|
+
it.each(["calc(10px +) 0px", "abs(10% - 50px) 0px", "round(10%, 7px) 0px", "min(10px 5%) 0px"])(
|
|
40
|
+
"reads %s as NaN, never as a guess",
|
|
41
|
+
(translate) => {
|
|
42
|
+
expect(readTranslatePx(box(`translate: ${translate}`)).x).toBeNaN();
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
it.each(["content-box", "fill-box"])(
|
|
47
|
+
"resolves a percent against the content box under transform-box: %s",
|
|
48
|
+
(transformBox) => {
|
|
49
|
+
const element = box(
|
|
50
|
+
`translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box; transform-box: ${transformBox}`,
|
|
51
|
+
);
|
|
52
|
+
expect(readTranslatePx(element)).toEqual({ x: 120, y: 80 });
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
43
56
|
it("counts padding and border in the box a percent resolves against", () => {
|
|
44
57
|
const element = box(
|
|
45
58
|
"translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box",
|
|
@@ -47,16 +60,19 @@ describe("readTranslatePx", () => {
|
|
|
47
60
|
expect(readTranslatePx(element)).toEqual({ x: 135, y: 95 });
|
|
48
61
|
});
|
|
49
62
|
|
|
50
|
-
it("
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
it.each(["-50% -50%", "min(10px, 5%) clamp(0px, 10%, 30px)"])(
|
|
64
|
+
"writes nothing to the element while it reads %s",
|
|
65
|
+
(translate) => {
|
|
66
|
+
const element = box(`translate: ${translate}; transform: rotate(5deg)`);
|
|
67
|
+
const before = element.getAttribute("style");
|
|
68
|
+
const writes: MutationRecord[] = [];
|
|
69
|
+
const observer = new MutationObserver((records) => writes.push(...records));
|
|
70
|
+
observer.observe(element, { attributes: true });
|
|
71
|
+
readTranslatePx(element);
|
|
72
|
+
writes.push(...observer.takeRecords());
|
|
73
|
+
observer.disconnect();
|
|
74
|
+
expect(writes).toEqual([]);
|
|
75
|
+
expect(element.getAttribute("style")).toBe(before);
|
|
76
|
+
},
|
|
77
|
+
);
|
|
62
78
|
});
|
|
@@ -1,34 +1,71 @@
|
|
|
1
1
|
import { roundTo3 } from "../../utils/rounding";
|
|
2
|
-
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
|
-
import {
|
|
2
|
+
import { gsapWritesPosition, gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
|
+
import { readCssRotation } from "../../hooks/draggedGsapPosition";
|
|
4
|
+
import { readStudioPathOffset, readStudioRotation } from "./manualEditsDom";
|
|
4
5
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
5
6
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
6
7
|
|
|
7
8
|
type Point = { x: number; y: number };
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
+
const TOKEN = /\s*(?:(\d*\.?\d+(?:e[-+]?\d+)?)(px|%)?|([a-z]+)\(|([-+*/(),]))/giy;
|
|
11
|
+
const FUNCTIONS: Record<string, (args: number[]) => number> = {
|
|
12
|
+
"(": ([a = Number.NaN]) => a,
|
|
13
|
+
"calc(": ([a = Number.NaN]) => a,
|
|
14
|
+
"min(": (args) => Math.min(...args),
|
|
15
|
+
"max(": (args) => Math.max(...args),
|
|
16
|
+
"clamp(": ([lo = Number.NaN, v = Number.NaN, hi = Number.NaN]) => Math.max(lo, Math.min(v, hi)),
|
|
17
|
+
};
|
|
10
18
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
|
|
19
|
+
/** Lengths become px against `side`; functions keep their "(" so they can't pass for an operator. */
|
|
20
|
+
function tokenize(value: string, side: number): (number | string)[] {
|
|
21
|
+
const tokens: (number | string)[] = [];
|
|
22
|
+
const end = value.trimEnd().length;
|
|
23
|
+
TOKEN.lastIndex = 0;
|
|
24
|
+
for (let m = TOKEN.exec(value); m; m = TOKEN.lastIndex < end ? TOKEN.exec(value) : null) {
|
|
25
|
+
if (m[1]) tokens.push(Number(m[1]) * (m[2] === "%" ? side / 100 : 1));
|
|
26
|
+
else tokens.push(m[3] ? `${m[3].toLowerCase()}(` : (m[4] ?? ""));
|
|
27
|
+
}
|
|
28
|
+
return TOKEN.lastIndex < end ? [] : tokens;
|
|
16
29
|
}
|
|
17
30
|
|
|
18
|
-
/**
|
|
19
|
-
function
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
/** Chrome's computed length (px, %, calc/min/max/clamp, + - * /) in px against `side`; NaN otherwise. */
|
|
32
|
+
function evaluateLength(value: string, side: number): number {
|
|
33
|
+
const tokens = tokenize(value, side);
|
|
34
|
+
let i = 0;
|
|
35
|
+
const next = () => tokens[i++];
|
|
36
|
+
const take = (...ops: string[]) => ops.includes(tokens[i] as string) && next();
|
|
37
|
+
function sum(): number {
|
|
38
|
+
let total = product();
|
|
39
|
+
for (let op = take("+", "-"); op; op = take("+", "-"))
|
|
40
|
+
total += (op === "+" ? 1 : -1) * product();
|
|
41
|
+
return total;
|
|
42
|
+
}
|
|
43
|
+
function product(): number {
|
|
44
|
+
let total = unary();
|
|
45
|
+
for (let op = take("*", "/"); op; op = take("*", "/"))
|
|
46
|
+
total = op === "*" ? total * unary() : total / unary();
|
|
47
|
+
return total;
|
|
27
48
|
}
|
|
28
|
-
|
|
49
|
+
function unary(): number {
|
|
50
|
+
return take("-") ? -unary() : atom();
|
|
51
|
+
}
|
|
52
|
+
function atom(): number {
|
|
53
|
+
const token = next();
|
|
54
|
+
if (typeof token === "number") return token;
|
|
55
|
+
const fn = FUNCTIONS[token ?? ""];
|
|
56
|
+
if (!fn) return Number.NaN;
|
|
57
|
+
const args = [sum()];
|
|
58
|
+
while (take(",")) args.push(sum());
|
|
59
|
+
return take(")") ? fn(args) : Number.NaN;
|
|
60
|
+
}
|
|
61
|
+
const total = tokens.length ? sum() : Number.NaN;
|
|
62
|
+
return i === tokens.length ? total : Number.NaN;
|
|
29
63
|
}
|
|
30
64
|
|
|
31
|
-
|
|
65
|
+
const CONTENT_BOXES = new Set(["content-box", "fill-box"]);
|
|
66
|
+
|
|
67
|
+
/** The side % resolves against: the box `transform-box` names (fill-box is the content box on HTML). */
|
|
68
|
+
function referenceBox(
|
|
32
69
|
cs: CSSStyleDeclaration,
|
|
33
70
|
size: "width" | "height",
|
|
34
71
|
a: string,
|
|
@@ -37,35 +74,29 @@ function borderBox(
|
|
|
37
74
|
const n = (prop: string) => Number.parseFloat(cs.getPropertyValue(prop)) || 0;
|
|
38
75
|
const pad =
|
|
39
76
|
n(`padding-${a}`) + n(`padding-${b}`) + n(`border-${a}-width`) + n(`border-${b}-width`);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// Chrome resolves min()/max()/clamp() inside a transform function to a matrix; an unrendered box has none.
|
|
44
|
-
function resolveInTransform(el: HTMLElement, x: string, y: string): Point {
|
|
45
|
-
const style = el.style;
|
|
46
|
-
const saved = [style.getPropertyValue("transform"), style.getPropertyPriority("transform")];
|
|
47
|
-
style.setProperty("transform", `translate(${x}, ${y})`, "important");
|
|
48
|
-
try {
|
|
49
|
-
const resolved = el.ownerDocument.defaultView?.getComputedStyle(el).transform ?? "";
|
|
50
|
-
const matrix = new DOMMatrixReadOnly(resolved.startsWith("matrix") ? resolved : undefined);
|
|
51
|
-
return { x: matrix.m41, y: matrix.m42 };
|
|
52
|
-
} finally {
|
|
53
|
-
style.setProperty("transform", saved[0] ?? "", saved[1] ?? "");
|
|
54
|
-
}
|
|
77
|
+
const content = n(size) - (cs.boxSizing === "border-box" ? pad : 0);
|
|
78
|
+
return CONTENT_BOXES.has(cs.getPropertyValue("transform-box")) ? content : content + pad;
|
|
55
79
|
}
|
|
56
80
|
|
|
57
81
|
/** The element's CSS `translate` in px, as it renders now. */
|
|
58
82
|
// ponytail: a 3-value translate loses its z on the next move; keep z when a fixture needs it.
|
|
59
83
|
export function readTranslatePx(el: HTMLElement): Point {
|
|
60
84
|
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
61
|
-
|
|
85
|
+
// Computed first: Chrome resolves em, vw and var() there and leaves only % to work out.
|
|
86
|
+
const value = cs?.translate || el.style.getPropertyValue("translate") || "none";
|
|
62
87
|
if (value === "none") return { x: 0, y: 0 };
|
|
63
88
|
const [x = "0px", y = "0px"] = splitTopLevelWhitespace(value);
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
return
|
|
89
|
+
const side = (size: "width" | "height", a: string, b: string) =>
|
|
90
|
+
cs ? referenceBox(cs, size, a, b) : 0;
|
|
91
|
+
return {
|
|
92
|
+
x: evaluateLength(x, side("width", "left", "right")),
|
|
93
|
+
y: evaluateLength(y, side("height", "top", "bottom")),
|
|
94
|
+
};
|
|
67
95
|
}
|
|
68
96
|
|
|
97
|
+
export const UNREADABLE_TRANSLATE =
|
|
98
|
+
"Studio can't read this layer's translate. Move it in the Code tab.";
|
|
99
|
+
|
|
69
100
|
/** Plain px only: GSAP's CSSPlugin splits `translate` on spaces and drops a calc(). */
|
|
70
101
|
function formatTranslatePx(p: Point): string {
|
|
71
102
|
return `${roundTo3(p.x)}px ${roundTo3(p.y)}px`;
|
|
@@ -80,6 +111,10 @@ export function writeTranslatePx(el: HTMLElement, p: Point): void {
|
|
|
80
111
|
}
|
|
81
112
|
|
|
82
113
|
/** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */
|
|
83
|
-
export function readMoveOffset(el: HTMLElement): Point {
|
|
84
|
-
return
|
|
114
|
+
export function readMoveOffset(el: HTMLElement, plainTranslate = !gsapWritesPosition(el)): Point {
|
|
115
|
+
return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function readShownRotation(el: HTMLElement): { angle: number } {
|
|
119
|
+
return gsapWritesRotation(el) ? readStudioRotation(el) : { angle: roundTo3(readCssRotation(el)) };
|
|
85
120
|
}
|
|
@@ -9,6 +9,7 @@ import { __resetForTests } from "../../utils/canvasNudgeGate";
|
|
|
9
9
|
import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
|
|
10
10
|
import "./domEditOverlayTestMocks";
|
|
11
11
|
import { DomEditOverlay } from "./DomEditOverlay";
|
|
12
|
+
import { UNREADABLE_TRANSLATE } from "./plainTranslate";
|
|
12
13
|
|
|
13
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
15
|
|
|
@@ -475,3 +476,32 @@ describe("DomEditOverlay onTextEditingChange", () => {
|
|
|
475
476
|
expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
|
|
476
477
|
});
|
|
477
478
|
});
|
|
479
|
+
|
|
480
|
+
describe("DomEditOverlay on a layer whose translate Studio can't read", () => {
|
|
481
|
+
it("refuses a drag and an arrow nudge out loud, and commits nothing", () => {
|
|
482
|
+
const { spies, overlay, selection } = fixture();
|
|
483
|
+
selection.element.style.setProperty("translate", "abs(10% - 50px) 0px");
|
|
484
|
+
fire(overlay.querySelector(BOX)!, "pointerdown");
|
|
485
|
+
const presses = [false, true, true].map(
|
|
486
|
+
(repeat) =>
|
|
487
|
+
new KeyboardEvent("keydown", {
|
|
488
|
+
key: "ArrowRight",
|
|
489
|
+
bubbles: true,
|
|
490
|
+
cancelable: true,
|
|
491
|
+
repeat,
|
|
492
|
+
}),
|
|
493
|
+
);
|
|
494
|
+
act(() => {
|
|
495
|
+
for (const press of presses) window.dispatchEvent(press);
|
|
496
|
+
vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
|
|
497
|
+
});
|
|
498
|
+
// One toast for the drag and one for the whole held arrow, whose every press is swallowed.
|
|
499
|
+
expect(spies.onBlockedMove.mock.calls).toEqual([
|
|
500
|
+
[selection, UNREADABLE_TRANSLATE],
|
|
501
|
+
[selection, UNREADABLE_TRANSLATE],
|
|
502
|
+
]);
|
|
503
|
+
expect(presses.map((press) => press.defaultPrevented)).toEqual([true, true, true]);
|
|
504
|
+
expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
|
|
505
|
+
expect(selection.element.style.getPropertyValue("translate")).toBe("abs(10% - 50px) 0px");
|
|
506
|
+
});
|
|
507
|
+
});
|
|
@@ -49,10 +49,12 @@ interface ManualEditCapabilities {
|
|
|
49
49
|
*/
|
|
50
50
|
export function useManualEditDisabledFlags(
|
|
51
51
|
capabilities: ManualEditCapabilities | null | undefined,
|
|
52
|
+
offset = { x: 0, y: 0 },
|
|
52
53
|
) {
|
|
53
54
|
const { readOnly } = useContext(PreviewReadOnlyContext);
|
|
55
|
+
const unreadable = !Number.isFinite(offset.x) || !Number.isFinite(offset.y);
|
|
54
56
|
return {
|
|
55
|
-
manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly,
|
|
57
|
+
manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly || unreadable,
|
|
56
58
|
manualSizeEditingDisabled: !capabilities?.canApplyManualSize || readOnly,
|
|
57
59
|
manualRotationEditingDisabled: !capabilities?.canApplyManualRotation || readOnly,
|
|
58
60
|
};
|