@hyperframes/studio 0.8.99 → 0.8.100
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-Rml34FRW.js → hyperframes-player-fWt87gyh.js} +1 -1
- package/dist/assets/{index-C_fi-jTe.js → index-D34Oy5DN.js} +1 -1
- package/dist/assets/{index-CVDArg3m.js → index-D6TjRAvz.js} +176 -176
- package/dist/assets/{index-D9YzDuBx.js → index-DofdzdxJ.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +423 -312
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/PropertyPanel.tsx +3 -3
- package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
- package/src/components/editor/manualOffsetDrag.test.ts +70 -0
- package/src/components/editor/manualOffsetDrag.ts +50 -40
- package/src/components/editor/plainTranslate.test.ts +62 -0
- package/src/components/editor/plainTranslate.ts +85 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/hooks/elementOffsetStager.ts +33 -2
- package/src/hooks/gsapRuntimeBridge.ts +1 -17
- package/src/hooks/gsapRuntimeKeyframes.ts +35 -0
- package/src/hooks/useDomEditCommits.test.tsx +12 -3
- package/src/hooks/useDomEditCommits.ts +2 -1
- package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
- package/src/hooks/useDomEditTextCommits.ts +35 -35
- package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +4 -2
- package/src/hooks/useGsapAwareEditing.test.tsx +46 -18
- package/src/hooks/useGsapAwareEditing.ts +11 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.100",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.100",
|
|
64
|
+
"@hyperframes/sdk": "0.8.100",
|
|
65
|
+
"@hyperframes/parsers": "0.8.100",
|
|
66
|
+
"@hyperframes/player": "0.8.100",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.100",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.100"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.100"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -3,7 +3,8 @@ import { memo, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { Move } from "../../icons/SystemIcons";
|
|
4
4
|
import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
|
5
5
|
import { useStudioShellContext } from "../../contexts/StudioContext";
|
|
6
|
-
import { readStudioBoxSize,
|
|
6
|
+
import { readStudioBoxSize, readStudioRotation } from "./manualEdits";
|
|
7
|
+
import { readMoveOffset } from "./plainTranslate";
|
|
7
8
|
import {
|
|
8
9
|
buildElementInfoText,
|
|
9
10
|
EMPTY_STYLES,
|
|
@@ -199,7 +200,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
199
200
|
// selection) so the Timing section shows for pure-GSAP elements with no data-start.
|
|
200
201
|
const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length));
|
|
201
202
|
const showEditableSections = element.capabilities.canEditStyles && sections.style;
|
|
202
|
-
const manualOffset =
|
|
203
|
+
const manualOffset = readMoveOffset(element.element);
|
|
203
204
|
const manualSize = readStudioBoxSize(element.element);
|
|
204
205
|
const resolvedWidth =
|
|
205
206
|
manualSize.width > 0
|
|
@@ -209,7 +210,6 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|
|
209
210
|
manualSize.height > 0
|
|
210
211
|
? manualSize.height
|
|
211
212
|
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
|
|
212
|
-
|
|
213
213
|
const manualRotation = readStudioRotation(element.element);
|
|
214
214
|
|
|
215
215
|
const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "./domEditing";
|
|
4
|
+
import type { GestureState } from "./domEditOverlayGestures";
|
|
5
|
+
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
delete (window as { gsap?: unknown }).gsap;
|
|
9
|
+
delete (window as { __timelines?: unknown }).__timelines;
|
|
10
|
+
document.body.innerHTML = "";
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const PRESS = {
|
|
14
|
+
clientX: 10,
|
|
15
|
+
clientY: 10,
|
|
16
|
+
pointerId: 1,
|
|
17
|
+
button: 0,
|
|
18
|
+
preventDefault() {},
|
|
19
|
+
stopPropagation() {},
|
|
20
|
+
currentTarget: { setPointerCapture() {} },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Presses a drag on the element and returns the gesture it started. */
|
|
24
|
+
function pressDrag(element: HTMLElement): GestureState | null {
|
|
25
|
+
const opts = pressOptions(element);
|
|
26
|
+
expect(
|
|
27
|
+
createDomEditOverlayGestureHandlers(opts as never).startGesture("drag", PRESS as never),
|
|
28
|
+
).toBe(true);
|
|
29
|
+
return opts.gestureRef.current;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pressOptions(element: HTMLElement) {
|
|
33
|
+
const ref = <T>(current: T) => ({ current });
|
|
34
|
+
const selection = { element, capabilities: { canApplyManualOffset: true } };
|
|
35
|
+
return {
|
|
36
|
+
selectionRef: ref(selection as unknown as DomEditSelection),
|
|
37
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
|
|
38
|
+
boxRef: ref(null),
|
|
39
|
+
overlayRef: ref(null),
|
|
40
|
+
iframeRef: ref(null),
|
|
41
|
+
gestureRef: ref<GestureState | null>(null),
|
|
42
|
+
rafPausedRef: ref(false),
|
|
43
|
+
onManualDragStartRef: ref(vi.fn()),
|
|
44
|
+
onBlockedMoveRef: ref(vi.fn()),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("a drag press on a page that loads GSAP", () => {
|
|
49
|
+
it.each([
|
|
50
|
+
["GSAP animates nothing", false],
|
|
51
|
+
["GSAP animates only its parent", true],
|
|
52
|
+
])(
|
|
53
|
+
"%s: the press never asks GSAP about the element, so its translate stays CSS",
|
|
54
|
+
(_, parentTween) => {
|
|
55
|
+
const getProperty = vi.fn(() => 0);
|
|
56
|
+
const set = vi.fn();
|
|
57
|
+
const parent = document.createElement("div");
|
|
58
|
+
const element = document.createElement("div");
|
|
59
|
+
element.style.setProperty("translate", "40px 30px");
|
|
60
|
+
parent.append(element);
|
|
61
|
+
document.body.append(parent);
|
|
62
|
+
const tween = { targets: () => [parent], vars: { x: 100 }, duration: () => 2 };
|
|
63
|
+
const timelines = { main: { getChildren: () => (parentTween ? [tween] : []) } };
|
|
64
|
+
Object.assign(window, { gsap: { getProperty, set }, __timelines: timelines });
|
|
65
|
+
expect(pressDrag(element)?.pathOffsetMember?.plainTranslate).toBe(true);
|
|
66
|
+
expect(getProperty).not.toHaveBeenCalled();
|
|
67
|
+
expect(set).not.toHaveBeenCalled();
|
|
68
|
+
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("a drag press on a centred element without GSAP", () => {
|
|
74
|
+
it("starts from its -50% translate in px and leaves its style as it was", () => {
|
|
75
|
+
const element = document.createElement("div");
|
|
76
|
+
element.style.cssText =
|
|
77
|
+
"position: absolute; left: 50%; top: 50%; width: 240px; height: 160px; translate: -50% -50%";
|
|
78
|
+
document.body.append(element);
|
|
79
|
+
const style = element.getAttribute("style");
|
|
80
|
+
expect(pressDrag(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
|
|
81
|
+
expect(element.getAttribute("style")).toBe(style);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -70,6 +70,7 @@ export function startGroupDrag(
|
|
|
70
70
|
selection: item.selection,
|
|
71
71
|
element: item.element,
|
|
72
72
|
rect: item.rect,
|
|
73
|
+
gesture: "drag",
|
|
73
74
|
});
|
|
74
75
|
if (!result.ok) {
|
|
75
76
|
restoreManualOffsetDragMembers(members);
|
|
@@ -151,10 +152,6 @@ export function startGesture(
|
|
|
151
152
|
return false;
|
|
152
153
|
|
|
153
154
|
const size = readStudioBoxSize(sel.element);
|
|
154
|
-
// Single-source rotation base = the live GSAP transform rotation plus any legacy
|
|
155
|
-
// `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
|
|
156
|
-
// element's actual visual angle and commits an absolute angle to the timeline.
|
|
157
|
-
const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
|
|
158
155
|
// The draft writes CSS width/height, so the resize base must be the CSS
|
|
159
156
|
// layout size. offsetWidth/Height are transform-free; the overlay-rect
|
|
160
157
|
// fallback (rect / editScale) includes the element's own GSAP scale and
|
|
@@ -198,6 +195,7 @@ export function startGesture(
|
|
|
198
195
|
selection: sel,
|
|
199
196
|
element: sel.element,
|
|
200
197
|
rect,
|
|
198
|
+
gesture: "drag",
|
|
201
199
|
});
|
|
202
200
|
if (!result.ok) {
|
|
203
201
|
opts.onBlockedMoveRef.current(result.selection);
|
|
@@ -232,6 +230,10 @@ export function startGesture(
|
|
|
232
230
|
}
|
|
233
231
|
}
|
|
234
232
|
|
|
233
|
+
// Rotation base = GSAP's rotation plus the legacy `--hf-studio-rotation` var. A plain-translate
|
|
234
|
+
// move never asks GSAP: reading a property makes it bake the CSS translate into its transform.
|
|
235
|
+
const gsapRotation = pathOffsetMember?.plainTranslate ? 0 : readGsapRotation(sel.element);
|
|
236
|
+
const rotation = { angle: gsapRotation + readStudioRotation(sel.element).angle };
|
|
235
237
|
const overlayBounds = overlayEl?.getBoundingClientRect();
|
|
236
238
|
const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
|
|
237
239
|
const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
resumeGsapTimelines,
|
|
6
6
|
applyManualOffsetDragDraft,
|
|
7
7
|
applyManualOffsetDragMatrix,
|
|
8
|
+
applyManualOffsetNudgeDraft,
|
|
8
9
|
createManualOffsetDragMember,
|
|
9
10
|
endManualOffsetDragMembers,
|
|
10
11
|
invertManualOffsetDragMatrix,
|
|
@@ -566,3 +567,72 @@ describe("resumeGsapTimelines", () => {
|
|
|
566
567
|
expect(() => resumeGsapTimelines(element)).not.toThrow();
|
|
567
568
|
});
|
|
568
569
|
});
|
|
570
|
+
|
|
571
|
+
describe("a move of an element GSAP does not position", () => {
|
|
572
|
+
function plainBox(options: { gsapLoaded?: boolean } = {}) {
|
|
573
|
+
const window = new Window();
|
|
574
|
+
const element = window.document.createElement("div");
|
|
575
|
+
window.document.body.append(element);
|
|
576
|
+
element.style.setProperty("translate", "40px 30px");
|
|
577
|
+
// A rect that never moves: the probe falls back to the 1:1 preview-scale mapping.
|
|
578
|
+
element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
|
|
579
|
+
const gsapCalls: string[] = [];
|
|
580
|
+
if (options.gsapLoaded) {
|
|
581
|
+
Object.assign(window, {
|
|
582
|
+
gsap: { set: () => gsapCalls.push("set"), getProperty: () => gsapCalls.push("read") },
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const member = (gesture: "drag" | "nudge" = "drag") => {
|
|
586
|
+
const result = createManualOffsetDragMember({
|
|
587
|
+
key: "box",
|
|
588
|
+
selection: { element } as never,
|
|
589
|
+
element,
|
|
590
|
+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
|
|
591
|
+
gesture,
|
|
592
|
+
});
|
|
593
|
+
if (!result.ok) throw new Error(result.reason);
|
|
594
|
+
return result.member;
|
|
595
|
+
};
|
|
596
|
+
return { window, element, gsapCalls, member };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
it("drafts and drops the element's own translate in plain px, never touching GSAP", () => {
|
|
600
|
+
const { element, gsapCalls, member } = plainBox({ gsapLoaded: true });
|
|
601
|
+
const m = member();
|
|
602
|
+
expect(m.plainTranslate).toBe(true);
|
|
603
|
+
applyManualOffsetDragDraft(m, 90.25, 60);
|
|
604
|
+
expect(element.style.getPropertyValue("translate")).toBe("130.25px 90px");
|
|
605
|
+
expect(applyManualOffsetDragCommit(m, 90.25, 60)).toEqual({ x: 130.25, y: 90 });
|
|
606
|
+
endManualOffsetDragMembers([m]);
|
|
607
|
+
expect(element.style.getPropertyValue("translate")).toBe("130.25px 90px");
|
|
608
|
+
expect(gsapCalls).toEqual([]);
|
|
609
|
+
expect(element.hasAttribute("data-hf-drag-gsap-base-x")).toBe(false);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("starts a second move where the first dropped, while the first save is still in flight", () => {
|
|
613
|
+
const { member } = plainBox();
|
|
614
|
+
const first = member();
|
|
615
|
+
applyManualOffsetDragCommit(first, 90, 60);
|
|
616
|
+
const second = member();
|
|
617
|
+
expect(second.initialOffset).toEqual({ x: 130, y: 90 });
|
|
618
|
+
expect(applyManualOffsetDragCommit(second, 40, 0)).toEqual({ x: 170, y: 90 });
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("nudges from the element's translate", () => {
|
|
622
|
+
const { member } = plainBox();
|
|
623
|
+
expect(applyManualOffsetNudgeDraft(member("nudge"), { x: 5, y: -1 })).toEqual({ x: 45, y: 29 });
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("keeps the GSAP writer once GSAP renders the element's transform", () => {
|
|
627
|
+
const { element, member } = plainBox({ gsapLoaded: true });
|
|
628
|
+
Object.assign(element, { _gsap: { renderTransform: () => {} } });
|
|
629
|
+
expect(member().plainTranslate).toBe(false);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("keeps the GSAP writer for an element a timeline hold positions", () => {
|
|
633
|
+
const { window, element, member } = plainBox();
|
|
634
|
+
const hold = { targets: () => [element], vars: { x: 40 }, duration: () => 0 };
|
|
635
|
+
Object.assign(window, { __timelines: { main: { getChildren: () => [hold] } } });
|
|
636
|
+
expect(member().plainTranslate).toBe(false);
|
|
637
|
+
});
|
|
638
|
+
});
|
|
@@ -11,6 +11,8 @@ 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
16
|
|
|
15
17
|
interface OffsetDragGsap {
|
|
16
18
|
set: (el: Element, vars: Record<string, number | string>) => void;
|
|
@@ -97,6 +99,8 @@ export interface ManualOffsetDragMember {
|
|
|
97
99
|
selection: DomEditSelection;
|
|
98
100
|
element: HTMLElement;
|
|
99
101
|
initialOffset: { x: number; y: number };
|
|
102
|
+
/** The move writes the element's own inline `translate`; offsets are then absolute translate px. */
|
|
103
|
+
plainTranslate: boolean;
|
|
100
104
|
/**
|
|
101
105
|
* The element's GSAP x/y at gesture start, captured in JS so a mid-drag
|
|
102
106
|
* re-render (which reverts inline style + wipes the `data-hf-drag-gsap-base-*`
|
|
@@ -242,22 +246,23 @@ function readTransformWDivisor(element: HTMLElement): number {
|
|
|
242
246
|
export function measureManualOffsetDragScreenToOffsetMatrix(
|
|
243
247
|
element: HTMLElement,
|
|
244
248
|
initialOffset: { x: number; y: number },
|
|
245
|
-
options: { probeSize?: number; scaleX?: number; scaleY?: number } = {},
|
|
249
|
+
options: { probeSize?: number; scaleX?: number; scaleY?: number; plainTranslate?: boolean } = {},
|
|
246
250
|
): { ok: true; matrix: ManualOffsetDragMatrix } | { ok: false; reason: string } {
|
|
247
251
|
const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
|
|
248
252
|
if (!Number.isFinite(probeSize) || probeSize <= 0) {
|
|
249
253
|
return { ok: false, reason: "Invalid movement probe size." };
|
|
250
254
|
}
|
|
251
255
|
|
|
256
|
+
const draw = options.plainTranslate ? writeTranslatePx : applyStudioPathOffsetDraft;
|
|
252
257
|
const snapshot = captureStudioPathOffset(element);
|
|
253
258
|
try {
|
|
254
|
-
|
|
259
|
+
draw(element, initialOffset);
|
|
255
260
|
const origin = getRectCenter(element);
|
|
256
261
|
if (!origin) {
|
|
257
262
|
return { ok: false, reason: "Element has no measurable box." };
|
|
258
263
|
}
|
|
259
264
|
|
|
260
|
-
|
|
265
|
+
draw(element, {
|
|
261
266
|
x: initialOffset.x + probeSize,
|
|
262
267
|
y: initialOffset.y,
|
|
263
268
|
});
|
|
@@ -266,7 +271,7 @@ export function measureManualOffsetDragScreenToOffsetMatrix(
|
|
|
266
271
|
return { ok: false, reason: "Element X movement could not be measured." };
|
|
267
272
|
}
|
|
268
273
|
|
|
269
|
-
|
|
274
|
+
draw(element, {
|
|
270
275
|
x: initialOffset.x,
|
|
271
276
|
y: initialOffset.y + probeSize,
|
|
272
277
|
});
|
|
@@ -332,24 +337,25 @@ export function createManualOffsetDragMember(input: {
|
|
|
332
337
|
selection: DomEditSelection;
|
|
333
338
|
element: HTMLElement;
|
|
334
339
|
rect: ManualOffsetDragRect;
|
|
340
|
+
gesture?: "drag" | "nudge"; // a resize's anchor member keeps the legacy offset channel
|
|
335
341
|
}): ManualOffsetDragMemberResult {
|
|
336
|
-
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
const initialOffset =
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
342
|
+
const plainTranslate = !!input.gesture && !gsapWritesPosition(input.element);
|
|
343
|
+
// Base the drag on the offset ACTUALLY applied, never a dormant var (readAppliedStudioPathOffset),
|
|
344
|
+
// so a stale offset can't fling the element off-screen.
|
|
345
|
+
const initialOffset = plainTranslate
|
|
346
|
+
? readTranslatePx(input.element)
|
|
347
|
+
: readAppliedStudioPathOffset(input.element);
|
|
343
348
|
const win = input.element.ownerDocument.defaultView as
|
|
344
349
|
| (Window & {
|
|
345
350
|
gsap?: { getProperty?: (el: Element, prop: string) => number };
|
|
346
351
|
__timelines?: Record<string, { pause?: () => void; paused?: () => boolean }>;
|
|
347
352
|
})
|
|
348
353
|
| null;
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
input.element
|
|
352
|
-
|
|
354
|
+
// getProperty makes GSAP bake the CSS translate into its transform, so a plain move never reads it.
|
|
355
|
+
const gsapX = (!plainTranslate && win?.gsap?.getProperty?.(input.element, "x")) || 0;
|
|
356
|
+
const gsapY = (!plainTranslate && win?.gsap?.getProperty?.(input.element, "y")) || 0;
|
|
357
|
+
const baseGsap = { x: gsapX, y: gsapY };
|
|
358
|
+
if (!plainTranslate) stampGestureBase(input.element, initialOffset, baseGsap);
|
|
353
359
|
|
|
354
360
|
if (win?.__timelines) {
|
|
355
361
|
const paused: string[] = [];
|
|
@@ -373,8 +379,8 @@ export function createManualOffsetDragMember(input: {
|
|
|
373
379
|
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
|
|
374
380
|
scaleX: input.rect.editScaleX,
|
|
375
381
|
scaleY: input.rect.editScaleY,
|
|
382
|
+
plainTranslate,
|
|
376
383
|
});
|
|
377
|
-
const baseGsap = { x: gsapX, y: gsapY };
|
|
378
384
|
if (!measured.ok) {
|
|
379
385
|
// Fallback: when GSAP transforms interfere with probe measurement, use
|
|
380
386
|
// the preview scale as an approximation. The commit path reads the actual
|
|
@@ -390,6 +396,7 @@ export function createManualOffsetDragMember(input: {
|
|
|
390
396
|
selection: input.selection,
|
|
391
397
|
element: input.element,
|
|
392
398
|
initialOffset,
|
|
399
|
+
plainTranslate,
|
|
393
400
|
baseGsap,
|
|
394
401
|
initialPathOffset,
|
|
395
402
|
gestureToken,
|
|
@@ -406,6 +413,7 @@ export function createManualOffsetDragMember(input: {
|
|
|
406
413
|
selection: input.selection,
|
|
407
414
|
element: input.element,
|
|
408
415
|
initialOffset,
|
|
416
|
+
plainTranslate,
|
|
409
417
|
baseGsap,
|
|
410
418
|
initialPathOffset,
|
|
411
419
|
gestureToken,
|
|
@@ -428,18 +436,25 @@ function resolveManualOffsetDragMemberOffset(
|
|
|
428
436
|
});
|
|
429
437
|
}
|
|
430
438
|
|
|
439
|
+
/** Draws the offset where the commit writes it: the inline translate, else GSAP, else the legacy var. */
|
|
440
|
+
function drawMemberOffset(
|
|
441
|
+
member: ManualOffsetDragMember,
|
|
442
|
+
offset: { x: number; y: number },
|
|
443
|
+
commit: boolean,
|
|
444
|
+
): void {
|
|
445
|
+
if (member.plainTranslate) writeTranslatePx(member.element, offset);
|
|
446
|
+
else if (applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) return;
|
|
447
|
+
else if (commit) applyStudioPathOffset(member.element, offset);
|
|
448
|
+
else applyStudioPathOffsetDraft(member.element, offset);
|
|
449
|
+
}
|
|
450
|
+
|
|
431
451
|
export function applyManualOffsetDragDraft(
|
|
432
452
|
member: ManualOffsetDragMember,
|
|
433
453
|
dx: number,
|
|
434
454
|
dy: number,
|
|
435
455
|
): { x: number; y: number } {
|
|
436
456
|
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
|
|
437
|
-
|
|
438
|
-
// the live draft matches the committed `tl.set`/keyframe. CSS draft only when
|
|
439
|
-
// gsap is unavailable (no preview iframe runtime).
|
|
440
|
-
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
|
|
441
|
-
applyStudioPathOffsetDraft(member.element, offset);
|
|
442
|
-
}
|
|
457
|
+
drawMemberOffset(member, offset, false);
|
|
443
458
|
return offset;
|
|
444
459
|
}
|
|
445
460
|
|
|
@@ -451,25 +466,20 @@ export function applyManualOffsetDragDraft(
|
|
|
451
466
|
* the element flies off-screen the instant you drop it. The member holds the
|
|
452
467
|
* true gesture-start values in JS, immune to the re-render.
|
|
453
468
|
*/
|
|
454
|
-
function
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
469
|
+
function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
|
|
470
|
+
el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
|
|
471
|
+
el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
|
|
472
|
+
el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
|
|
473
|
+
el.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
|
|
459
474
|
}
|
|
460
475
|
|
|
461
476
|
function applyManualOffsetCommitValue(
|
|
462
477
|
member: ManualOffsetDragMember,
|
|
463
478
|
offset: { x: number; y: number },
|
|
464
479
|
): { x: number; y: number } {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
// source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
|
|
469
|
-
// CSS apply only when gsap is unavailable.
|
|
470
|
-
if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
|
|
471
|
-
applyStudioPathOffset(member.element, offset);
|
|
472
|
-
}
|
|
480
|
+
if (!member.plainTranslate)
|
|
481
|
+
stampGestureBase(member.element, member.initialOffset, member.baseGsap);
|
|
482
|
+
drawMemberOffset(member, offset, true);
|
|
473
483
|
return offset;
|
|
474
484
|
}
|
|
475
485
|
|
|
@@ -496,9 +506,7 @@ export function applyManualOffsetNudgeDraft(
|
|
|
496
506
|
x: member.initialOffset.x + delta.x,
|
|
497
507
|
y: member.initialOffset.y + delta.y,
|
|
498
508
|
};
|
|
499
|
-
|
|
500
|
-
applyStudioPathOffsetDraft(member.element, offset);
|
|
501
|
-
}
|
|
509
|
+
drawMemberOffset(member, offset, false);
|
|
502
510
|
return offset;
|
|
503
511
|
}
|
|
504
512
|
|
|
@@ -514,7 +522,9 @@ export function applyManualOffsetNudgeCommit(
|
|
|
514
522
|
|
|
515
523
|
function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
516
524
|
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
517
|
-
|
|
525
|
+
if (!member.plainTranslate) {
|
|
526
|
+
getOffsetDragGsap(member.element)?.set(member.element, { ...member.baseGsap });
|
|
527
|
+
}
|
|
518
528
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
519
529
|
}
|
|
520
530
|
|
|
@@ -548,7 +558,7 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
|
|
|
548
558
|
member.element.removeAttribute("data-hf-drag-gsap-base-y");
|
|
549
559
|
// Do NOT clearProps:"transform" — that nukes the committed GSAP position
|
|
550
560
|
// and causes a visual snap-back before the soft reload re-applies it.
|
|
551
|
-
const gsapOwnsPosition = Boolean(getOffsetDragGsap(member.element));
|
|
561
|
+
const gsapOwnsPosition = !member.plainTranslate && Boolean(getOffsetDragGsap(member.element));
|
|
552
562
|
// Migration: when GSAP owns the position (the committed value lives in the
|
|
553
563
|
// GSAP transform), the legacy `--hf-studio-offset` CSS channel is obsolete.
|
|
554
564
|
// Clear it on the LIVE element — otherwise the leftover `translate:
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { readTranslatePx } from "./plainTranslate";
|
|
4
|
+
|
|
5
|
+
function box(style: string): HTMLElement {
|
|
6
|
+
const element = document.createElement("div");
|
|
7
|
+
element.style.cssText = `position: absolute; width: 240px; height: 160px; ${style}`;
|
|
8
|
+
document.body.append(element);
|
|
9
|
+
return element;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("readTranslatePx", () => {
|
|
13
|
+
it.each([
|
|
14
|
+
["40px 30px", { x: 40, y: 30 }],
|
|
15
|
+
["-50% -50%", { x: -120, y: -80 }],
|
|
16
|
+
["25% 25%", { x: 60, y: 40 }],
|
|
17
|
+
["calc(-50% + 12px) calc(10% - 6px)", { x: -108, y: 10 }],
|
|
18
|
+
["30px", { x: 30, y: 0 }],
|
|
19
|
+
])("reads %s against the border box, in px", (translate, want) => {
|
|
20
|
+
expect(readTranslatePx(box(`translate: ${translate}`))).toEqual(want);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each([
|
|
24
|
+
["calc(50% - 20px) 0px", { x: 100, y: 0 }],
|
|
25
|
+
["calc(50% + 20px) calc(50% - 20px)", { x: 140, y: 60 }],
|
|
26
|
+
])("adds and subtracts the terms of %s", (translate, want) => {
|
|
27
|
+
expect(readTranslatePx(box(`translate: ${translate}`))).toEqual(want);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
"min(10px, 5%) 0px",
|
|
32
|
+
"calc(2 * 10px) 0px",
|
|
33
|
+
"calc(10px +) 0px",
|
|
34
|
+
"clamp(0px, 10%, 30px) 5px",
|
|
35
|
+
])("hands %s to Chrome's own resolution and puts the element's style back", (translate) => {
|
|
36
|
+
const element = box(`translate: ${translate}; transform: rotate(5deg)`);
|
|
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);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("counts padding and border in the box a percent resolves against", () => {
|
|
44
|
+
const element = box(
|
|
45
|
+
"translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box",
|
|
46
|
+
);
|
|
47
|
+
expect(readTranslatePx(element)).toEqual({ x: 135, y: 95 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("writes nothing to the element it reads", () => {
|
|
51
|
+
const element = box("translate: -50% -50%");
|
|
52
|
+
const before = element.getAttribute("style");
|
|
53
|
+
const writes: MutationRecord[] = [];
|
|
54
|
+
const observer = new MutationObserver((records) => writes.push(...records));
|
|
55
|
+
observer.observe(element, { attributes: true });
|
|
56
|
+
readTranslatePx(element);
|
|
57
|
+
writes.push(...observer.takeRecords());
|
|
58
|
+
observer.disconnect();
|
|
59
|
+
expect(writes).toEqual([]);
|
|
60
|
+
expect(element.getAttribute("style")).toBe(before);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { roundTo3 } from "../../utils/rounding";
|
|
2
|
+
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
|
+
import { readStudioPathOffset } from "./manualEditsDom";
|
|
4
|
+
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
5
|
+
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
6
|
+
|
|
7
|
+
type Point = { x: number; y: number };
|
|
8
|
+
|
|
9
|
+
const TERM = /^(-?\d*\.?\d+(?:e[-+]?\d+)?)(px|%)$/i;
|
|
10
|
+
|
|
11
|
+
const SIGN: Record<string, number> = { "+": 1, "-": -1 };
|
|
12
|
+
|
|
13
|
+
function termPx(term: string | undefined, side: number): number | null {
|
|
14
|
+
const match = TERM.exec(term ?? "");
|
|
15
|
+
return match ? Number(match[1]) * (match[2] === "%" ? side / 100 : 1) : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** px, %, or Chrome's computed `calc(P% + Lpx)`, against the border-box side; null for min()/max()/clamp(). */
|
|
19
|
+
function resolveLength(value: string, side: number): number | null {
|
|
20
|
+
const parts = (/^calc\((.*)\)$/.exec(value)?.[1] ?? value).split(" ");
|
|
21
|
+
if (parts.length % 2 === 0) return null;
|
|
22
|
+
let total = termPx(parts[0], side);
|
|
23
|
+
for (let i = 1; i < parts.length && total !== null; i += 2) {
|
|
24
|
+
const term = termPx(parts[i + 1], side);
|
|
25
|
+
const sign = SIGN[parts[i] ?? ""];
|
|
26
|
+
total = term === null || !sign ? null : total + sign * term;
|
|
27
|
+
}
|
|
28
|
+
return total;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function borderBox(
|
|
32
|
+
cs: CSSStyleDeclaration,
|
|
33
|
+
size: "width" | "height",
|
|
34
|
+
a: string,
|
|
35
|
+
b: string,
|
|
36
|
+
): number {
|
|
37
|
+
const n = (prop: string) => Number.parseFloat(cs.getPropertyValue(prop)) || 0;
|
|
38
|
+
const pad =
|
|
39
|
+
n(`padding-${a}`) + n(`padding-${b}`) + n(`border-${a}-width`) + n(`border-${b}-width`);
|
|
40
|
+
return n(size) + (cs.boxSizing === "border-box" ? 0 : pad);
|
|
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
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The element's CSS `translate` in px, as it renders now. */
|
|
58
|
+
// ponytail: a 3-value translate loses its z on the next move; keep z when a fixture needs it.
|
|
59
|
+
export function readTranslatePx(el: HTMLElement): Point {
|
|
60
|
+
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
61
|
+
const value = el.style.getPropertyValue("translate") || cs?.translate || "none";
|
|
62
|
+
if (value === "none") return { x: 0, y: 0 };
|
|
63
|
+
const [x = "0px", y = "0px"] = splitTopLevelWhitespace(value);
|
|
64
|
+
const px = cs && resolveLength(x, borderBox(cs, "width", "left", "right"));
|
|
65
|
+
const py = cs && resolveLength(y, borderBox(cs, "height", "top", "bottom"));
|
|
66
|
+
return px != null && py != null ? { x: px, y: py } : resolveInTransform(el, x, y);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Plain px only: GSAP's CSSPlugin splits `translate` on spaces and drops a calc(). */
|
|
70
|
+
function formatTranslatePx(p: Point): string {
|
|
71
|
+
return `${roundTo3(p.x)}px ${roundTo3(p.y)}px`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function translatePatch(p: Point): PatchOperation & { value: string } {
|
|
75
|
+
return { type: "inline-style", property: "translate", value: formatTranslatePx(p) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeTranslatePx(el: HTMLElement, p: Point): void {
|
|
79
|
+
el.style.setProperty("translate", formatTranslatePx(p));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 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 gsapWritesPosition(el) ? readStudioPathOffset(el) : readTranslatePx(el);
|
|
85
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
2
|
-
import { readStudioBoxSize
|
|
2
|
+
import { readStudioBoxSize } from "./manualEdits";
|
|
3
|
+
import { readMoveOffset } from "./plainTranslate";
|
|
3
4
|
import { parsePxMetricValue, type PropertyPanelProps } from "./propertyPanelHelpers";
|
|
4
5
|
|
|
5
6
|
interface TransformCommitDeps {
|
|
@@ -73,7 +74,7 @@ export function createTransformCommitHandlers({
|
|
|
73
74
|
)
|
|
74
75
|
)
|
|
75
76
|
return;
|
|
76
|
-
const current =
|
|
77
|
+
const current = readMoveOffset(element.element);
|
|
77
78
|
await Promise.resolve(
|
|
78
79
|
onSetManualOffset(element, {
|
|
79
80
|
x: axis === "x" ? parsed : current.x,
|
|
@@ -189,7 +189,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
189
189
|
if (!targets) return null;
|
|
190
190
|
const members: ManualOffsetDragMember[] = [];
|
|
191
191
|
for (const target of targets) {
|
|
192
|
-
const result = createManualOffsetDragMember(target);
|
|
192
|
+
const result = createManualOffsetDragMember({ ...target, gesture: "nudge" });
|
|
193
193
|
if (!result.ok) {
|
|
194
194
|
restoreManualOffsetDragMembers(members);
|
|
195
195
|
return null;
|