@hyperframes/studio 0.8.95 → 0.8.96

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.95",
3
+ "version": "0.8.96",
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.95",
64
- "@hyperframes/parsers": "0.8.95",
65
- "@hyperframes/sdk": "0.8.95",
66
- "@hyperframes/player": "0.8.95",
67
- "@hyperframes/studio-server": "0.8.95",
68
- "@hyperframes/shader-transitions": "0.8.95"
63
+ "@hyperframes/core": "0.8.96",
64
+ "@hyperframes/player": "0.8.96",
65
+ "@hyperframes/parsers": "0.8.96",
66
+ "@hyperframes/sdk": "0.8.96",
67
+ "@hyperframes/studio-server": "0.8.96",
68
+ "@hyperframes/shader-transitions": "0.8.96"
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.95"
84
+ "@hyperframes/producer": "0.8.96"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -126,6 +126,69 @@ describe("DomEditCropHandles clip lift/restore", () => {
126
126
  expect(a.style.getPropertyValue("clip-path")).toBe("inset(10px 30px 10px 10px)");
127
127
  });
128
128
 
129
+ it.each([
130
+ { name: "a crop edge", clip: "inset(10px)", handle: "Crop right", dx: -20, dy: 0 },
131
+ {
132
+ name: "the reposition handle on fractional insets",
133
+ clip: "inset(20px 0.42px 40px 0.03px)",
134
+ handle: "Reposition crop",
135
+ dx: 0,
136
+ dy: 10,
137
+ },
138
+ ])("commits nothing when $name is dragged back to where it started", (c) => {
139
+ const onStyleCommit = vi.fn();
140
+ render(makeEl("a", c.clip), onStyleCommit);
141
+ const handle = document.querySelector<HTMLButtonElement>(`[aria-label="${c.handle}"]`)!;
142
+ for (const [type, d] of [
143
+ ["pointerdown", 0],
144
+ ["pointermove", 1],
145
+ ["pointermove", 0],
146
+ ["pointerup", 0],
147
+ ] as const) {
148
+ const at = { clientX: 100 + d * c.dx, clientY: 50 + d * c.dy };
149
+ act(() =>
150
+ handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 3, ...at })),
151
+ );
152
+ }
153
+ expect(onStyleCommit).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it("commits where the pointer was released, even before its last move renders", () => {
157
+ const onStyleCommit = vi.fn();
158
+ render(makeEl("a", "inset(10px)"), onStyleCommit);
159
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
160
+ act(() => {
161
+ for (const [type, clientX] of [
162
+ ["pointerdown", 100],
163
+ ["pointermove", 90],
164
+ ["pointermove", 80],
165
+ ["pointerup", 80],
166
+ ] as const) {
167
+ handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 4, clientX }));
168
+ }
169
+ });
170
+ expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
171
+ });
172
+
173
+ it("keeps a first crop on deselect when the release beats the last render", async () => {
174
+ const a = makeEl("a", "");
175
+ const { root } = render(a, (property, value) => void a.style.setProperty(property, value));
176
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
177
+ act(() => {
178
+ for (const [type, clientX] of [
179
+ ["pointerdown", 100],
180
+ ["pointermove", 90],
181
+ ["pointermove", 80],
182
+ ["pointerup", 80],
183
+ ] as const) {
184
+ handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 5, clientX }));
185
+ }
186
+ });
187
+ await act(async () => undefined);
188
+ act(() => root.unmount());
189
+ expect(a.style.getPropertyValue("clip-path")).toBe("inset(0px 20px 0px 0px)");
190
+ });
191
+
129
192
  it("re-lifts when the crop commit rejects", async () => {
130
193
  const a = makeEl("a", "inset(10px)");
131
194
  const onStyleCommit = vi.fn((property: string, value: string) => {
@@ -18,7 +18,7 @@ interface CropGestureState {
18
18
  startX: number;
19
19
  startY: number;
20
20
  startInsets: ClipPathInsetSides;
21
- didMove: boolean;
21
+ insets: ClipPathInsetSides;
22
22
  /** Element frame captured at gesture start: pointer deltas rotate into it. */
23
23
  angleDeg: number;
24
24
  scaleX: number;
@@ -186,7 +186,7 @@ export function DomEditCropHandles({
186
186
  startX: event.clientX,
187
187
  startY: event.clientY,
188
188
  startInsets: state.insets,
189
- didMove: false,
189
+ insets: state.insets,
190
190
  angleDeg: frame.angleDeg,
191
191
  scaleX: frame.scaleX,
192
192
  scaleY: frame.scaleY,
@@ -217,7 +217,7 @@ export function DomEditCropHandles({
217
217
  gesture.edge === "move"
218
218
  ? resolveCropInsetFromMoveDrag(drag)
219
219
  : resolveCropInsetFromEdgeDrag({ ...drag, edge: gesture.edge, width, height });
220
- gesture.didMove = true;
220
+ gesture.insets = nextInsets;
221
221
  setState((prev) => ({ ...prev, insets: nextInsets }));
222
222
  };
223
223
 
@@ -234,7 +234,6 @@ export function DomEditCropHandles({
234
234
  const finishCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
235
235
  const gesture = endCropGesture(event);
236
236
  if (!gesture) return;
237
- if (!gesture.didMove) return;
238
237
  // Commit to the file. The commit path re-applies the value to the live
239
238
  // element synchronously, so re-lift in the same turn to keep showing the full
240
239
  // content + dim while selected. Re-lift again on rejection so a failed commit
@@ -243,12 +242,10 @@ export function DomEditCropHandles({
243
242
  const reLift = () => {
244
243
  if (liftedRef.current) el.style.setProperty("clip-path", "none");
245
244
  };
246
- const committedValue = buildInsetClipPathSides(state.insets, state.radius);
247
- const cropped =
248
- state.insets.top > 0 ||
249
- state.insets.right > 0 ||
250
- state.insets.bottom > 0 ||
251
- state.insets.left > 0;
245
+ const { insets } = gesture;
246
+ const committedValue = buildInsetClipPathSides(insets, state.radius);
247
+ if (committedValue === buildInsetClipPathSides(gesture.startInsets, state.radius)) return;
248
+ const cropped = insets.top > 0 || insets.right > 0 || insets.bottom > 0 || insets.left > 0;
252
249
  const commit = onStyleCommit?.("clip-path", committedValue);
253
250
  // handleDomStyleCommit applies the persisted value to the live element
254
251
  // synchronously before its first await. Restore the crop-mode lift in this
@@ -105,7 +105,7 @@ export function DomEditGroupChrome({
105
105
  cursor: !allowBodyDrag ? undefined : canManipulate && groupCanMove ? "move" : "default",
106
106
  }}
107
107
  onPointerDown={(e) => {
108
- if (!canManipulate || e.shiftKey || !allowBodyDrag) return;
108
+ if (!canManipulate || !allowBodyDrag || (e.shiftKey && !groupCanMove)) return;
109
109
  gestures.startGroupDrag(e);
110
110
  }}
111
111
  onMouseDown={onBoxMouseDown}
@@ -218,12 +218,12 @@ export function DomEditSelectionChrome({
218
218
  e.stopPropagation();
219
219
  return;
220
220
  }
221
- if (!allowBodyDrag || !canManipulate || e.shiftKey) return;
221
+ if (!allowBodyDrag || !canManipulate) return;
222
222
  if (selection.capabilities.canApplyManualOffset) {
223
223
  gestures.startGesture("drag", e);
224
224
  return;
225
225
  }
226
- gestures.startBlockedMove(e, selection);
226
+ if (!e.shiftKey) gestures.startBlockedMove(e, selection);
227
227
  }}
228
228
  onMouseDown={onBoxMouseDown}
229
229
  onClick={onBoxClick}
@@ -18,6 +18,20 @@ export type GestureKind = "drag" | "resize" | "rotate";
18
18
  export type ResizeHandle = "nw" | "ne" | "sw" | "se";
19
19
 
20
20
  export const BLOCKED_MOVE_THRESHOLD_PX = 4;
21
+
22
+ export interface AxisLockedDelta {
23
+ dx: number;
24
+ dy: number;
25
+ lockedAxis?: "x" | "y";
26
+ }
27
+
28
+ export function lockDragToDominantAxis(dx: number, dy: number, shiftKey: boolean): AxisLockedDelta {
29
+ if (!shiftKey) return { dx, dy };
30
+ return Math.abs(dx) >= Math.abs(dy)
31
+ ? { dx, dy: 0, lockedAxis: "y" }
32
+ : { dx: 0, dy, lockedAxis: "x" };
33
+ }
34
+
21
35
  const ROTATION_COMMIT_EPSILON_DEGREES = 0.05;
22
36
  const ROTATION_SNAP_DEGREES = 15;
23
37
  /**
@@ -73,6 +87,7 @@ export interface GestureState {
73
87
  snapContext?: SnapContext;
74
88
  lastSnappedDx?: number;
75
89
  lastSnappedDy?: number;
90
+ travelled?: boolean;
76
91
  /** Corner the resize gesture grabbed (resize gestures only). */
77
92
  resizeHandle?: ResizeHandle;
78
93
  /** Last anchoring translation applied during a corner resize (overlay px). */
@@ -96,6 +111,7 @@ export interface GroupGestureState {
96
111
  snapContext?: SnapContext;
97
112
  lastSnappedDx?: number;
98
113
  lastSnappedDy?: number;
114
+ travelled?: boolean;
99
115
  }
100
116
 
101
117
  export interface BlockedMoveState {
@@ -6,7 +6,12 @@ import {
6
6
  SNAP_THRESHOLD_PX,
7
7
  } from "./snapEngine";
8
8
  import { applyManualOffsetDragDraft } from "./manualOffsetDrag";
9
- import type { GroupGestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
9
+ import {
10
+ lockDragToDominantAxis,
11
+ type AxisLockedDelta,
12
+ type GroupGestureState,
13
+ type UseDomEditOverlayGesturesOptions,
14
+ } from "./domEditOverlayGestures";
10
15
  import type { GroupOverlayItem } from "./domEditOverlayGeometry";
11
16
  import {
12
17
  findNonRigidMembers,
@@ -33,7 +38,7 @@ export function createGroupDragMover(
33
38
  const snapGroupDelta = (
34
39
  groupG: GroupGestureState,
35
40
  e: React.PointerEvent<HTMLDivElement>,
36
- proposed: { dx: number; dy: number },
41
+ proposed: AxisLockedDelta,
37
42
  ) => {
38
43
  const sc = groupG.snapContext;
39
44
  if (!sc?.snapEnabled || sc.targets.length === 0) return proposed;
@@ -52,6 +57,7 @@ export function createGroupDragMover(
52
57
  gridEdges: sc.gridEdges ?? undefined,
53
58
  threshold: SNAP_THRESHOLD_PX,
54
59
  disabled: e.altKey,
60
+ lockedAxis: proposed.lockedAxis,
55
61
  });
56
62
  const movingRect = {
57
63
  ...groupBounds,
@@ -75,10 +81,11 @@ export function createGroupDragMover(
75
81
  lastGesture = groupG;
76
82
  lastGroupPositions = {};
77
83
  }
78
- const { dx, dy } = snapGroupDelta(groupG, e, {
79
- dx: e.clientX - groupG.startX,
80
- dy: e.clientY - groupG.startY,
81
- });
84
+ const { dx, dy } = snapGroupDelta(
85
+ groupG,
86
+ e,
87
+ lockDragToDominantAxis(e.clientX - groupG.startX, e.clientY - groupG.startY, e.shiftKey),
88
+ );
82
89
  groupG.lastSnappedDx = dx;
83
90
  groupG.lastSnappedDy = dy;
84
91
 
@@ -449,6 +449,62 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
449
449
  });
450
450
  });
451
451
 
452
+ describe("a stylesheet translate after a committed GSAP drag", () => {
453
+ function draggedTitle(css: string, oldOffsetChannel = false, withGsap = true) {
454
+ const window = new Window();
455
+ const style = window.document.createElement("style");
456
+ style.textContent = css;
457
+ window.document.head.append(style);
458
+ const element = window.document.createElement("h1");
459
+ element.id = "title";
460
+ if (oldOffsetChannel) {
461
+ element.setAttribute("data-hf-studio-path-offset", "true");
462
+ element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
463
+ element.style.setProperty(STUDIO_OFFSET_Y_PROP, "0px");
464
+ element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
465
+ }
466
+ window.document.body.append(element);
467
+ element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
468
+ (window as unknown as { gsap: unknown }).gsap = withGsap && {
469
+ set: (el: HTMLElement, vars: { x?: number; y?: number }) =>
470
+ el.style.setProperty("transform", `translate(${vars.x ?? 0}px, ${vars.y ?? 0}px)`),
471
+ getProperty: () => 0,
472
+ };
473
+ const result = createManualOffsetDragMember({
474
+ key: "title",
475
+ selection: { element } as never,
476
+ element,
477
+ rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
478
+ });
479
+ if (!result.ok) throw new Error("member not created");
480
+ applyManualOffsetDragCommit(result.member, 40, 30);
481
+ if (withGsap) expect(element.style.getPropertyValue("translate")).toBe("none");
482
+ endManualOffsetDragMembers([result.member]);
483
+ return element;
484
+ }
485
+
486
+ it("stays masked, so it is not applied on top of the committed transform", () => {
487
+ expect(
488
+ draggedTitle("#title { translate: 0 -200px; }").style.getPropertyValue("translate"),
489
+ ).toBe("none");
490
+ });
491
+
492
+ it("stays masked for an element still on the old offset channel", () => {
493
+ const element = draggedTitle("#title { translate: 0 -200px; }", true);
494
+ expect(element.hasAttribute("data-hf-studio-path-offset")).toBe(false);
495
+ expect(element.style.getPropertyValue("translate")).toBe("none");
496
+ });
497
+
498
+ it("is not masked when GSAP does not own the position", () => {
499
+ const element = draggedTitle("#title { translate: 0 -200px; }", true, false);
500
+ expect(element.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
501
+ });
502
+
503
+ it("is not left behind when nothing sits under the draft's mask", () => {
504
+ expect(draggedTitle("").style.getPropertyValue("translate")).toBe("");
505
+ });
506
+ });
507
+
452
508
  describe("resumeGsapTimelines", () => {
453
509
  it("unpauses exactly the timelines the drag start paused, then re-seeks the player", () => {
454
510
  const window = new Window();
@@ -514,6 +514,7 @@ export function applyManualOffsetNudgeCommit(
514
514
 
515
515
  function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
516
516
  restoreStudioPathOffset(member.element, member.initialPathOffset);
517
+ getOffsetDragGsap(member.element)?.set(member.element, { ...member.baseGsap });
517
518
  endStudioManualEditGesture(member.element, member.gestureToken);
518
519
  }
519
520
 
@@ -525,6 +526,18 @@ export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]
525
526
  }
526
527
  }
527
528
 
529
+ // Drop the draft's `translate: none` so the soft reload starts clean (#1673). When GSAP owns
530
+ // the position it folded any translate into its transform, so one still showing applies twice.
531
+ function settleDraftTranslate(element: HTMLElement, gsapOwnsPosition: boolean): void {
532
+ if (element.style.getPropertyValue("translate") === "none") {
533
+ element.style.removeProperty("translate");
534
+ }
535
+ if (!gsapOwnsPosition) return;
536
+ const view = element.ownerDocument.defaultView;
537
+ const underneath = view?.getComputedStyle(element).getPropertyValue("translate");
538
+ if (underneath && underneath !== "none") element.style.setProperty("translate", "none");
539
+ }
540
+
528
541
  /** Teardown after a COMMITTED drag. */
529
542
  export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
530
543
  for (const member of members) {
@@ -533,23 +546,17 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
533
546
  member.element.removeAttribute("data-hf-drag-initial-offset-y");
534
547
  member.element.removeAttribute("data-hf-drag-gsap-base-x");
535
548
  member.element.removeAttribute("data-hf-drag-gsap-base-y");
536
- // Clear the draft's `translate: none` so the soft reload starts clean —
537
- // otherwise button-less pointermoves after the reload compute deltas
538
- // from a stale base and fling the element off-screen (#1673).
539
549
  // Do NOT clearProps:"transform" — that nukes the committed GSAP position
540
550
  // and causes a visual snap-back before the soft reload re-applies it.
541
- if (member.element.style.getPropertyValue("translate") === "none") {
542
- member.element.style.removeProperty("translate");
543
- }
551
+ const gsapOwnsPosition = Boolean(getOffsetDragGsap(member.element));
544
552
  // Migration: when GSAP owns the position (the committed value lives in the
545
553
  // GSAP transform), the legacy `--hf-studio-offset` CSS channel is obsolete.
546
554
  // Clear it on the LIVE element — otherwise the leftover `translate:
547
555
  // var(--hf-studio-offset)` composes with the GSAP transform and the element
548
556
  // renders offset by the stale value until a full page reload (the source is
549
557
  // already stripped). clearStudioPathOffset leaves `transform` untouched.
550
- if (getOffsetDragGsap(member.element)) {
551
- clearStudioPathOffset(member.element);
552
- }
558
+ if (gsapOwnsPosition) clearStudioPathOffset(member.element);
559
+ settleDraftTranslate(member.element, gsapOwnsPosition);
553
560
  resumeGsapTimelines(member.element);
554
561
  }
555
562
  }