@hyperframes/studio 0.8.123 → 0.8.125

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.
Files changed (48) hide show
  1. package/dist/assets/{hyperframes-player-yABdb6yk.js → hyperframes-player-CmIh-wq9.js} +1 -1
  2. package/dist/assets/{index-D77MIjSJ.js → index-4LRR6o2F.js} +1 -1
  3. package/dist/assets/{index-Dt7lWtY7.js → index-CrYoEZ5p.js} +139 -139
  4. package/dist/assets/{index-LrNlT5Tg.css → index-D11atkBt.css} +1 -1
  5. package/dist/assets/{index-DWjF2Kxw.js → index-yCK8osBg.js} +1 -1
  6. package/dist/index.d.ts +43 -35
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +283 -143
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +8 -8
  12. package/src/components/OpenInDesktopButton.dom.test.tsx +27 -10
  13. package/src/components/OpenInDesktopButton.tsx +22 -15
  14. package/src/components/editor/domEditOverlayGestures.ts +2 -0
  15. package/src/components/editor/domEditOverlayStartGesture.ts +6 -0
  16. package/src/components/editor/dragUndoPaint.test.ts +170 -2
  17. package/src/components/editor/gestureUndoRevert.ts +104 -0
  18. package/src/components/editor/manualOffsetDrag.ts +1 -12
  19. package/src/components/editor/useDomEditNudge.ts +1 -1
  20. package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
  21. package/src/hooks/gestureTransaction.test.ts +60 -0
  22. package/src/hooks/gestureTransaction.ts +5 -2
  23. package/src/hooks/gsapGestureOutcome.ts +9 -2
  24. package/src/hooks/gsapResizeDropPoint.test.ts +39 -1
  25. package/src/hooks/gsapResizeElementSize.test.tsx +27 -0
  26. package/src/hooks/gsapResizeIntercept.ts +55 -68
  27. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  28. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +29 -1
  29. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  30. package/src/hooks/useGsapAwareEditing.ts +21 -25
  31. package/src/hooks/useGsapAwareGroupMove.ts +4 -6
  32. package/src/hooks/useGsapScriptCommits.test.tsx +42 -1
  33. package/src/hooks/useGsapScriptCommits.ts +12 -3
  34. package/src/player/components/AudibleVideoClipContent.test.tsx +67 -0
  35. package/src/player/components/AudibleVideoClipContent.tsx +5 -4
  36. package/src/player/components/AudioWaveform.tsx +2 -1
  37. package/src/player/components/ClipPeakMarks.tsx +5 -2
  38. package/src/player/components/OutOfSyncBadge.tsx +1 -1
  39. package/src/player/components/Timeline.trackPadding.test.tsx +1 -3
  40. package/src/player/components/TimelineCanvas.tsx +1 -0
  41. package/src/player/components/TimelineClip.filmstrip.test.tsx +27 -0
  42. package/src/player/components/TimelineClipFades.test.tsx +2 -1
  43. package/src/player/components/timelineClipDragPreview.test.ts +31 -0
  44. package/src/player/components/timelineClipDragPreview.ts +42 -37
  45. package/src/player/hooks/useTimelinePlayer.seek.test.ts +11 -0
  46. package/src/styles/components.css +33 -3
  47. package/src/utils/studioPendingEdits.test.ts +52 -0
  48. package/src/utils/studioPendingEdits.ts +55 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.123",
3
+ "version": "0.8.125",
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.123",
64
- "@hyperframes/parsers": "0.8.123",
65
- "@hyperframes/player": "0.8.123",
66
- "@hyperframes/sdk": "0.8.123",
67
- "@hyperframes/shader-transitions": "0.8.123",
68
- "@hyperframes/studio-server": "0.8.123"
63
+ "@hyperframes/parsers": "0.8.125",
64
+ "@hyperframes/player": "0.8.125",
65
+ "@hyperframes/core": "0.8.125",
66
+ "@hyperframes/sdk": "0.8.125",
67
+ "@hyperframes/shader-transitions": "0.8.125",
68
+ "@hyperframes/studio-server": "0.8.125"
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.123"
84
+ "@hyperframes/producer": "0.8.125"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  /**
4
- * "Edit with Framey": shown only where the preview server answers on macOS; a press opens the project in the app,
4
+ * "Edit with Framey": shown where the preview server has the app or its download to offer; a press opens the project in the app,
5
5
  * or introduces Framey with the download while the app cannot take it or is missing.
6
6
  */
7
7
  import React, { act } from "react";
@@ -45,7 +45,7 @@ async function mount(): Promise<HTMLElement> {
45
45
 
46
46
  const downloadHref = () =>
47
47
  [...document.querySelectorAll("a")]
48
- .find((a) => a.textContent?.includes("Get HyperFrames Studio"))
48
+ .find((a) => a.textContent?.includes("Get the desktop app"))
49
49
  ?.getAttribute("href");
50
50
 
51
51
  const button = () =>
@@ -68,31 +68,48 @@ it("stays hidden where the server has no hand-off (the desktop's own Studio)", a
68
68
  expect(button()).toBeNull();
69
69
  });
70
70
 
71
- it("stays hidden when the server says it cannot open the app (not macOS)", async () => {
72
- serve(Response.json({ available: false }));
71
+ it("stays hidden when the app has no build for this machine (Windows)", async () => {
72
+ serve(Response.json({ available: false, handoff: true, downloadUrl: null }));
73
73
  await mount();
74
74
  expect(button()).toBeNull();
75
75
  });
76
76
 
77
+ it("with the app installed but no download (Windows), shows only once the app can take the project", async () => {
78
+ serve(Response.json({ available: true, handoff: false, downloadUrl: null }));
79
+ await mount();
80
+ expect(button()).toBeNull();
81
+ });
82
+
83
+ it("opens the project in an installed app that has no download (Windows)", async () => {
84
+ serve(Response.json({ available: true, handoff: true, downloadUrl: null }), {
85
+ opened: true,
86
+ app: "the HyperFrames desktop app",
87
+ });
88
+ await mount();
89
+ await act(async () => button()!.click());
90
+ expect(posts).toBe(1);
91
+ });
92
+
77
93
  it("while the app cannot take a project yet, introduces Framey with the download and opens nothing", async () => {
78
- serve(Response.json({ available: true, handoff: false }), { opened: true });
94
+ const linux = `${DOWNLOAD}?os=linux`;
95
+ serve(Response.json({ available: true, handoff: false, downloadUrl: linux }), { opened: true });
79
96
  await mount();
80
97
  await act(async () => button()!.click());
81
98
  expect(posts).toBe(0);
82
99
  expect(document.body.textContent).toContain("Meet Framey");
83
- expect(downloadHref()).toBe(DOWNLOAD);
100
+ expect(downloadHref()).toBe(linux);
84
101
  });
85
102
 
86
103
  it("opens the project in the app and says so", async () => {
87
- serve(Response.json({ available: true, handoff: true }), {
104
+ serve(Response.json({ available: true, handoff: true, downloadUrl: DOWNLOAD }), {
88
105
  opened: true,
89
- bundleId: "dev.hyperframes.desktop",
106
+ app: "the HyperFrames desktop app",
90
107
  });
91
108
  await mount();
92
109
  await act(async () => button()!.click());
93
110
  expect(posts).toBe(1);
94
111
  expect(showToast).toHaveBeenCalledWith(
95
- "Framey is opening this project in HyperFrames Studio",
112
+ "Framey is opening this project in the HyperFrames desktop app",
96
113
  "info",
97
114
  );
98
115
  expect(button()!.dataset.opening).toBe("true");
@@ -100,7 +117,7 @@ it("opens the project in the app and says so", async () => {
100
117
  });
101
118
 
102
119
  it("introduces Framey and offers the download when the app is missing", async () => {
103
- serve(Response.json({ available: true, handoff: true }), {
120
+ serve(Response.json({ available: true, handoff: true, downloadUrl: DOWNLOAD }), {
104
121
  opened: false,
105
122
  reason: "not-installed",
106
123
  downloadUrl: DOWNLOAD,
@@ -8,7 +8,6 @@ import { Button, buttonBase, buttonSizes, buttonVariants, cn, Popover } from "./
8
8
  // "Edit with Framey": the preview server (`/api/open-in-desktop`) says whether to show it and whether the app can
9
9
  // take the project yet (its gate is the CLI's HANDOFF_READY); until then a press introduces Framey with the download.
10
10
  const ROUTE = "/api/open-in-desktop";
11
- const DOWNLOAD_URL = "https://hyperframes.dev/studio/download";
12
11
  /** How far the pupils travel towards the pointer, in the glyph's own units. */
13
12
  const GAZE = 2.2;
14
13
  /** Long enough for the flight to finish before the label comes back. */
@@ -16,15 +15,20 @@ const OPENING_MS = 1600;
16
15
 
17
16
  type OpenResult = { opened: true } | { opened: false; reason: string; downloadUrl: string };
18
17
 
19
- /** Null hides the button: no such route (the app's own Studio), or not macOS. */
20
- function useDesktopRoute(): { handoff: boolean } | null {
21
- const [route, setRoute] = useState<{ handoff: boolean } | null>(null);
18
+ type DesktopRoute = { handoff: boolean; downloadUrl: string | null };
19
+
20
+ /** Null hides the button: no route (the app's own Studio), or nothing here to open or download. */
21
+ function useDesktopRoute(): DesktopRoute | null {
22
+ const [route, setRoute] = useState<DesktopRoute | null>(null);
22
23
  useEffect(() => {
23
24
  let live = true;
24
25
  studioApiFetch(ROUTE)
25
26
  .then((res) => (res.ok ? res.json() : null))
26
- .then((body: { available?: unknown; handoff?: unknown } | null) => {
27
- if (live && body?.available === true) setRoute({ handoff: body.handoff === true });
27
+ .then((body: { available?: unknown; handoff?: unknown; downloadUrl?: unknown } | null) => {
28
+ if (!live || body?.available !== true) return;
29
+ const handoff = body.handoff === true;
30
+ const downloadUrl = typeof body.downloadUrl === "string" ? body.downloadUrl : null;
31
+ if (handoff || downloadUrl) setRoute({ handoff, downloadUrl });
28
32
  })
29
33
  .catch(() => {});
30
34
  return () => {
@@ -74,7 +78,7 @@ export function OpenInDesktopButton() {
74
78
  const open = async () => {
75
79
  if (!route.handoff) {
76
80
  trackStudioEvent("toolbar_action", { action: "open_in_desktop" });
77
- setDownloadUrl(DOWNLOAD_URL);
81
+ setDownloadUrl(route.downloadUrl);
78
82
  return;
79
83
  }
80
84
  if (opening) return;
@@ -84,7 +88,7 @@ export function OpenInDesktopButton() {
84
88
  const res = await studioApiFetch(ROUTE, { method: "POST" });
85
89
  const result = (await res.json()) as OpenResult;
86
90
  if (result.opened) {
87
- showToast("Framey is opening this project in HyperFrames Studio", "info");
91
+ showToast("Framey is opening this project in the HyperFrames desktop app", "info");
88
92
  await new Promise((done) => setTimeout(done, OPENING_MS));
89
93
  } else setDownloadUrl(result.downloadUrl);
90
94
  } catch {
@@ -114,11 +118,16 @@ export function OpenInDesktopButton() {
114
118
  variant="ghost"
115
119
  data-testid="header-open-in-desktop"
116
120
  data-opening={opening || undefined}
117
- title="Open this project in HyperFrames Studio, the desktop app, and edit it with Framey"
118
- className="hf-framey-trigger gap-1.5 pl-1.5"
119
- icon={<FrameyGlyph size={20} className="hf-framey" eyeRef={eye} />}
121
+ title="Open this project in the HyperFrames desktop app and edit it with Framey"
122
+ className="hf-framey-trigger gap-1.5 border border-accent/40 bg-accent/10 pl-1 font-semibold text-text-0 enabled:hover:bg-accent/20"
123
+ icon={<FrameyGlyph size={24} className="hf-framey" eyeRef={eye} />}
120
124
  >
121
125
  {opening ? "Opening in the app…" : "Edit with Framey"}
126
+ {!opening && (
127
+ <span className="rounded-full bg-accent px-1.5 text-[10px] leading-4 font-bold text-on-accent">
128
+ New
129
+ </span>
130
+ )}
122
131
  </Button>
123
132
  }
124
133
  >
@@ -126,9 +135,7 @@ export function OpenInDesktopButton() {
126
135
  <FrameyGlyph size={36} />
127
136
  <p className="text-step-12 font-medium text-text-0">Meet Framey</p>
128
137
  </div>
129
- <p className="mt-2 text-text-3">
130
- I live in HyperFrames Studio, the free desktop app for macOS:
131
- </p>
138
+ <p className="mt-2 text-balance text-text-3">I live in the free HyperFrames desktop app:</p>
132
139
  <ul className="mt-1.5 grid gap-1 text-text-1">
133
140
  <li>Chat to change anything in the video</li>
134
141
  <li>Draw on a frame to point at it</li>
@@ -145,7 +152,7 @@ export function OpenInDesktopButton() {
145
152
  className={cn(buttonBase, buttonVariants.primary, buttonSizes.md)}
146
153
  onClick={() => trackStudioEvent("toolbar_action", { action: "get_desktop_app" })}
147
154
  >
148
- Get HyperFrames Studio
155
+ Get the desktop app
149
156
  </a>
150
157
  </div>
151
158
  </Popover>
@@ -6,6 +6,7 @@ import type {
6
6
  StudioRotationSnapshot,
7
7
  } from "./manualEdits";
8
8
  import type { ManualOffsetDragMember } from "./manualOffsetDrag";
9
+ import type { StudioElementLook } from "./gestureUndoRevert";
9
10
  import type { CssRotationTarget, RotationCommit } from "./rotationDraft";
10
11
  import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
11
12
  import type { SnapContext } from "./snapTargetCollection";
@@ -57,6 +58,7 @@ export interface GestureState {
57
58
  initialPathOffset: StudioPathOffsetSnapshot;
58
59
  initialRotation: StudioRotationSnapshot;
59
60
  initialBoxSize: StudioBoxSizeSnapshot;
61
+ initialLook: StudioElementLook;
60
62
  pathOffsetMember?: ManualOffsetDragMember;
61
63
  originLeft: number;
62
64
  originTop: number;
@@ -9,6 +9,7 @@ import {
9
9
  restoreManualOffsetDragMembers,
10
10
  type ManualOffsetDragMember,
11
11
  } from "./manualOffsetDrag";
12
+ import { readElementLook } from "./gestureUndoRevert";
12
13
  import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
13
14
  import {
14
15
  beginStudioManualEditGesture,
@@ -179,6 +180,10 @@ export function startGesture(
179
180
  Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
180
181
  const contentScaleY =
181
182
  Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
183
+ const initialLook = readElementLook(
184
+ sel.element,
185
+ kind !== "drag" && !editsPlainCss(sel.element, kind === "rotate" ? "rotate" : "resize"),
186
+ );
182
187
  let initialPathOffset = captureStudioPathOffset(sel.element);
183
188
  let manualEditDragToken: string | undefined;
184
189
  let pathOffsetMember: ManualOffsetDragMember | undefined;
@@ -274,6 +279,7 @@ export function startGesture(
274
279
  initialPathOffset,
275
280
  initialRotation: captureStudioRotation(sel.element),
276
281
  initialBoxSize: captureStudioBoxSize(sel.element),
282
+ initialLook,
277
283
  pathOffsetMember,
278
284
  originLeft: rect.left,
279
285
  originTop: rect.top,
@@ -1,15 +1,40 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, expect, it, vi } from "vitest";
3
+ import { savePlainRotation } from "../../hooks/plainRotation";
3
4
  import type { DomEditSelection } from "./domEditing";
4
5
  import type { GestureState } from "./domEditOverlayGestures";
5
6
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
7
  import {
8
+ adoptingStudioPendingEdit,
7
9
  hasStudioPendingEdits,
8
10
  paintBackNewestStudioPendingEdit,
11
+ type StudioEditInFlight,
9
12
  } from "../../utils/studioPendingEdits";
10
13
 
14
+ const gsapOwns = vi.hoisted(() => ({ on: false }));
15
+ vi.mock("../../hooks/gsapRuntimeKeyframes", async (importOriginal) => ({
16
+ ...(await importOriginal<typeof import("../../hooks/gsapRuntimeKeyframes")>()),
17
+ editsPlainCss: () => !gsapOwns.on,
18
+ }));
19
+
20
+ /** GSAP's own transform values per element, as `gsap.set` / `gsap.getProperty` see them. */
21
+ function fakeGsap(start: Record<string, number>) {
22
+ const values = new Map<Element, Record<string, number>>();
23
+ const of = (el: Element) => values.get(el) ?? values.set(el, { ...start }).get(el)!;
24
+ gsapOwns.on = true;
25
+ Object.assign(window, {
26
+ gsap: {
27
+ set: (el: Element, props: Record<string, number>) => Object.assign(of(el), props),
28
+ getProperty: (el: Element, prop: string) => of(el)[prop] ?? 0,
29
+ },
30
+ });
31
+ return of;
32
+ }
33
+
11
34
  afterEach(() => {
12
35
  document.body.innerHTML = "";
36
+ gsapOwns.on = false;
37
+ Reflect.deleteProperty(window, "gsap");
13
38
  });
14
39
 
15
40
  const ref = <T>(current: T) => ({ current });
@@ -24,7 +49,7 @@ const pointer = (x: number, y: number) => ({
24
49
  });
25
50
 
26
51
  /** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
27
- function dragWithSaveRunning(save: Promise<void>) {
52
+ function dragWithSaveRunning(save: Promise<void> | (() => Promise<void>)) {
28
53
  const element = document.createElement("div");
29
54
  element.style.setProperty("translate", "40px 30px");
30
55
  document.body.append(element);
@@ -39,7 +64,7 @@ function dragWithSaveRunning(save: Promise<void>) {
39
64
  rafPausedRef: ref(false),
40
65
  onManualDragStartRef: ref(vi.fn()),
41
66
  onBlockedMoveRef: ref(vi.fn()),
42
- onPathOffsetCommitRef: ref(vi.fn(() => save)),
67
+ onPathOffsetCommitRef: ref(vi.fn(typeof save === "function" ? save : () => save)),
43
68
  snapGuidesRef: ref(null),
44
69
  groupGestureRef: ref(null),
45
70
  blockedMoveRef: ref(null),
@@ -69,3 +94,146 @@ it("a drag whose save is still running can be painted back at once, and shown ag
69
94
  await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
70
95
  expect(paintBackNewestStudioPendingEdit()).toBeNull();
71
96
  });
97
+
98
+ /** Resizes or rotates a plain 240x160 box; the commit draws what it saves at once and its save waits for `save`. */
99
+ function gestureWithSaveRunning(kind: "resize" | "rotate", save: Promise<void>) {
100
+ const element = document.createElement("div");
101
+ element.setAttribute(
102
+ "style",
103
+ "position: absolute; width: 240px; height: 160px; clip-path: inset(10px)",
104
+ );
105
+ document.body.append(element);
106
+ const selection = {
107
+ element,
108
+ capabilities: { canApplyManualSize: true, canApplyManualRotation: true },
109
+ };
110
+ const handlers = createDomEditOverlayGestureHandlers({
111
+ selectionRef: ref(selection as unknown as DomEditSelection),
112
+ overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
113
+ boxRef: ref(document.createElement("div")),
114
+ overlayRef: ref(null),
115
+ iframeRef: ref(null),
116
+ gestureRef: ref<GestureState | null>(null),
117
+ rafPausedRef: ref(false),
118
+ onBoxSizeCommitRef: ref(
119
+ vi.fn(() => {
120
+ element.style.setProperty("clip-path", "inset(15px)");
121
+ return save;
122
+ }),
123
+ ),
124
+ onRotationCommitRef: ref((sel: DomEditSelection, next: never) =>
125
+ savePlainRotation({ commitPositionPatchToHtml: () => save.then(() => undefined) }, sel, next),
126
+ ),
127
+ snapGuidesRef: ref(null),
128
+ groupGestureRef: ref(null),
129
+ blockedMoveRef: ref(null),
130
+ setOverlayRect: vi.fn(),
131
+ suppressNextBoxClickRef: ref(false),
132
+ hoverSelectionRef: ref(null),
133
+ onCanvasMouseDown: vi.fn(),
134
+ } as never);
135
+ const start = kind === "resize" ? pointer(240, 160) : pointer(240, 80);
136
+ expect(handlers.startGesture(kind, start as never, { resizeHandle: "se" })).toBe(true);
137
+ const end = kind === "resize" ? pointer(300, 200) : pointer(120, 200);
138
+ handlers.onPointerMove(end as never);
139
+ handlers.onPointerUp(end as never);
140
+ return element;
141
+ }
142
+
143
+ it.each(["resize", "rotate"] as const)(
144
+ "a %s whose save is still running can be painted back at once, and shown again",
145
+ async (kind) => {
146
+ let saved!: () => void;
147
+ const element = gestureWithSaveRunning(kind, new Promise<void>((resolve) => (saved = resolve)));
148
+ const before = "position: absolute; width: 240px; height: 160px; clip-path: inset(10px)";
149
+ const edited = element.getAttribute("style");
150
+ expect(edited).not.toBe(before);
151
+
152
+ const shown = paintBackNewestStudioPendingEdit();
153
+ expect(element.getAttribute("style")).toBe(before);
154
+ expect(paintBackNewestStudioPendingEdit()).toBeNull();
155
+ shown!.showAgain();
156
+ expect(element.getAttribute("style")).toBe(edited);
157
+
158
+ saved();
159
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
160
+ },
161
+ );
162
+
163
+ it("a drag of a box GSAP positions is painted back to where GSAP had it, and shown again", async () => {
164
+ const gsapOf = fakeGsap({ x: 5, y: 7 });
165
+ let saved!: () => void;
166
+ const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
167
+ const moved = { ...gsapOf(element) };
168
+ expect(moved).not.toMatchObject({ x: 5, y: 7 });
169
+
170
+ const shown = paintBackNewestStudioPendingEdit();
171
+ expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
172
+ shown!.showAgain();
173
+ expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
174
+
175
+ saved();
176
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
177
+ });
178
+
179
+ it("a rotate of a box GSAP turns is painted back to GSAP's angle at press, and shown again", async () => {
180
+ const gsapOf = fakeGsap({ rotation: 10 });
181
+ let saved!: () => void;
182
+ const element = gestureWithSaveRunning(
183
+ "rotate",
184
+ new Promise<void>((resolve) => (saved = resolve)),
185
+ );
186
+ const turned = gsapOf(element).rotation;
187
+ expect(turned).not.toBe(10);
188
+
189
+ const shown = paintBackNewestStudioPendingEdit();
190
+ expect(gsapOf(element).rotation).toBe(10);
191
+ shown!.showAgain();
192
+ expect(gsapOf(element).rotation).toBe(turned);
193
+
194
+ saved();
195
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
196
+ });
197
+
198
+ it("a GSAP drag saved as a left/top offset stays painted back while that offset is drawn", async () => {
199
+ fakeGsap({ x: 5, y: 7 });
200
+ let saved!: () => void;
201
+ let drawn!: Promise<void>;
202
+ const element = dragWithSaveRunning(() => {
203
+ const edit = adoptingStudioPendingEdit()!;
204
+ drawn = new Promise<void>((resolve) => (saved = resolve)).then(() =>
205
+ edit.drawKeepingUndone(() => void element.style.setProperty("left", "99px")),
206
+ );
207
+ return drawn;
208
+ });
209
+
210
+ const shown = paintBackNewestStudioPendingEdit();
211
+ saved();
212
+ await drawn;
213
+ expect(element.style.getPropertyValue("left")).toBe("");
214
+ shown!.showAgain();
215
+ expect(element.style.getPropertyValue("left")).toBe("99px");
216
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
217
+ });
218
+
219
+ it("keeps a GSAP drag's base through every repaint of its undo, so its save adds the drag once", async () => {
220
+ const gsapOf = fakeGsap({ x: 5, y: 7 });
221
+ let edit!: StudioEditInFlight;
222
+ let release!: () => void;
223
+ const element = dragWithSaveRunning(() => {
224
+ edit = adoptingStudioPendingEdit()!;
225
+ return new Promise<void>((resolve) => (release = resolve));
226
+ });
227
+ const moved = { ...gsapOf(element) };
228
+
229
+ const shown = paintBackNewestStudioPendingEdit();
230
+ edit.drawKeepingUndone(() => undefined);
231
+ expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
232
+ expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
233
+ shown!.showAgain();
234
+ expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
235
+ expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
236
+
237
+ release();
238
+ await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
239
+ });
@@ -0,0 +1,104 @@
1
+ import {
2
+ captureStudioPathOffset,
3
+ restoreStudioPathOffset,
4
+ type StudioPathOffsetSnapshot,
5
+ } from "./manualEdits";
6
+ import {
7
+ getOffsetDragGsap,
8
+ stampGestureBase,
9
+ type ManualOffsetDragMember,
10
+ } from "./manualOffsetDrag";
11
+ import type { StudioEditRevert } from "../../utils/studioPendingEdits";
12
+
13
+ interface MemberPosition {
14
+ offset: StudioPathOffsetSnapshot;
15
+ gsap: { x: number; y: number } | null;
16
+ placement: string[];
17
+ }
18
+
19
+ // Where an element-offset move draws the element when no transform channel can take it.
20
+ const PLACEMENT = ["position", "left", "top"];
21
+ const placementOf = (element: HTMLElement) =>
22
+ PLACEMENT.map((prop) => element.style.getPropertyValue(prop));
23
+
24
+ function showPlacement(element: HTMLElement, placement: string[]): void {
25
+ PLACEMENT.forEach((prop, i) => {
26
+ if (placement[i]) element.style.setProperty(prop, placement[i]!);
27
+ else element.style.removeProperty(prop);
28
+ });
29
+ }
30
+
31
+ function readMemberPosition(member: ManualOffsetDragMember): MemberPosition {
32
+ const gsap = member.plainTranslate ? null : getOffsetDragGsap(member.element);
33
+ return {
34
+ offset: captureStudioPathOffset(member.element),
35
+ gsap: gsap && {
36
+ x: Number(gsap.getProperty(member.element, "x")),
37
+ y: Number(gsap.getProperty(member.element, "y")),
38
+ },
39
+ placement: placementOf(member.element),
40
+ };
41
+ }
42
+
43
+ function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosition): void {
44
+ restoreStudioPathOffset(member.element, position.offset);
45
+ showPlacement(member.element, position.placement);
46
+ if (position.gsap) getOffsetDragGsap(member.element)?.set(member.element, { ...position.gsap });
47
+ // The restore drops the gesture's base stamps; its save still reads them.
48
+ if (!member.plainTranslate)
49
+ stampGestureBase(member.element, member.initialOffset, member.baseGsap);
50
+ }
51
+
52
+ /** Undo's live revert of a move: its members at gesture start. */
53
+ export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert {
54
+ const startPlacement = members.map((member) => placementOf(member.element));
55
+ return () => {
56
+ const shown = members.map(readMemberPosition);
57
+ members.forEach((member, i) =>
58
+ showMemberPosition(member, {
59
+ offset: member.initialPathOffset,
60
+ gsap: member.plainTranslate ? null : member.baseGsap,
61
+ placement: startPlacement[i]!,
62
+ }),
63
+ );
64
+ return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!));
65
+ };
66
+ }
67
+
68
+ export interface StudioElementLook {
69
+ style: string | null;
70
+ gsap: Record<string, number> | null;
71
+ }
72
+
73
+ const GSAP_LOOK_PROPS = ["x", "y", "rotation", "scaleX", "scaleY"];
74
+
75
+ /** Reads GSAP only for an element it owns: reading a plain one makes GSAP bake its CSS into a transform. */
76
+ export function readElementLook(element: HTMLElement, gsapOwned: boolean): StudioElementLook {
77
+ const gsap = gsapOwned ? getOffsetDragGsap(element) : null;
78
+ return {
79
+ style: element.getAttribute("style"),
80
+ gsap:
81
+ gsap &&
82
+ Object.fromEntries(
83
+ GSAP_LOOK_PROPS.map((prop) => [prop, Number(gsap.getProperty(element, prop))]),
84
+ ),
85
+ };
86
+ }
87
+
88
+ function showElementLook(element: HTMLElement, look: StudioElementLook): void {
89
+ if (look.style === null) element.removeAttribute("style");
90
+ else element.setAttribute("style", look.style);
91
+ if (look.gsap) getOffsetDragGsap(element)?.set(element, { ...look.gsap });
92
+ }
93
+
94
+ /** Undo's live revert of a resize or rotate: the element as it looked at press. */
95
+ export function elementLookRevert(
96
+ element: HTMLElement,
97
+ start: StudioElementLook,
98
+ ): StudioEditRevert {
99
+ return () => {
100
+ const shown = readElementLook(element, start.gsap !== null);
101
+ showElementLook(element, start);
102
+ return () => showElementLook(element, shown);
103
+ };
104
+ }
@@ -13,7 +13,6 @@ import {
13
13
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14
14
  import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
15
15
  import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
16
- import type { StudioEditRevert } from "../../utils/studioPendingEdits";
17
16
 
18
17
  interface OffsetDragGsap {
19
18
  set: (el: Element, vars: Record<string, number | string>) => void;
@@ -448,7 +447,7 @@ export function applyManualOffsetDragDraft(
448
447
  * the element flies off-screen the instant you drop it. The member holds the
449
448
  * true gesture-start values in JS, immune to the re-render.
450
449
  */
451
- function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
450
+ export function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
452
451
  el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
453
452
  el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
454
453
  el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
@@ -510,16 +509,6 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
510
509
  endStudioManualEditGesture(member.element, member.gestureToken);
511
510
  }
512
511
 
513
- /** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
514
- export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
515
- if (!members.every((member) => member.plainTranslate)) return null;
516
- return () => {
517
- const shown = members.map((member) => captureStudioPathOffset(member.element));
518
- for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
519
- return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
520
- };
521
- }
522
-
523
512
  /** Roll back a FAILED drag to the exact gesture-start state. */
524
513
  export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
525
514
  for (const member of members) {
@@ -32,10 +32,10 @@ import {
32
32
  applyManualOffsetNudgeDraft,
33
33
  createManualOffsetDragMember,
34
34
  endManualOffsetDragMembers,
35
- manualOffsetMoveRevert,
36
35
  restoreManualOffsetDragMembers,
37
36
  type ManualOffsetDragMember,
38
37
  } from "./manualOffsetDrag";
38
+ import { manualOffsetMoveRevert } from "./gestureUndoRevert";
39
39
  import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
40
40
  import {
41
41
  CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
@@ -12,8 +12,8 @@ import {
12
12
  applyManualOffsetDragDraft,
13
13
  endManualOffsetDragMembers,
14
14
  restoreManualOffsetDragMembers,
15
- manualOffsetMoveRevert,
16
15
  } from "./manualOffsetDrag";
16
+ import { manualOffsetMoveRevert, elementLookRevert } from "./gestureUndoRevert";
17
17
  import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
18
18
  import {
19
19
  applyStudioBoxSize,
@@ -443,7 +443,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
443
443
  // Hold the final angle while the commit lands.
444
444
  applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
445
445
  const commit = { ...finalRotation, plain: g.plainRotation };
446
- void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
446
+ const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
447
+ const saved = Promise.resolve(edit.adopt(() => opts.onRotationCommitRef.current(sel, commit)))
447
448
  .then((result) => trackPreviewEditResult("rotate", "drag", result))
448
449
  .catch((error) => {
449
450
  logGestureCommitFailure("rotate commit failed", error);
@@ -454,6 +455,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
454
455
  restoreRotation();
455
456
  })
456
457
  .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
458
+ edit.settle(saved);
457
459
  } else if (g.kind === "drag") {
458
460
  // A moved drag (taps returned earlier) must not let the release click
459
461
  // re-select whatever now sits under the pointer — dropping over a
@@ -535,9 +537,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
535
537
  restoreStudioBoxSize(sel.element, g.initialBoxSize);
536
538
  if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
537
539
  };
538
- void Promise.resolve(
539
- opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member),
540
- )
540
+ const commitSize = () =>
541
+ opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member);
542
+ const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
543
+ const saved = Promise.resolve(edit.adopt(commitSize))
541
544
  .then((result) => trackPreviewEditResult("resize", "drag", result))
542
545
  .catch((error) => {
543
546
  logGestureCommitFailure("resize commit failed", error);
@@ -546,6 +549,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
546
549
  if (member) endManualOffsetDragMembers([member]);
547
550
  else endStudioManualEditGesture(sel.element, g.manualEditDragToken);
548
551
  });
552
+ edit.settle(saved);
549
553
  logResizeSettle(sel.element, "post-release");
550
554
  }
551
555
  };