@hyperframes/studio 0.8.103 → 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.
Files changed (49) hide show
  1. package/dist/assets/{hyperframes-player-DG795Hwu.js → hyperframes-player-DAoaajuM.js} +1 -1
  2. package/dist/assets/{index-BiEtDkae.js → index-BDK4rTMb.js} +1 -1
  3. package/dist/assets/{index-BPJRf2X9.js → index-De2aO4XM.js} +178 -178
  4. package/dist/assets/{index-DuCZ3pQw.js → index-WV9LroAD.js} +1 -1
  5. package/dist/index.d.ts +181 -168
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +428 -305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/editor/DomEditOverlay.tsx +5 -6
  11. package/src/components/editor/PropertyPanel.tsx +3 -3
  12. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
  13. package/src/components/editor/domEditOverlayGestures.ts +13 -19
  14. package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
  15. package/src/components/editor/domEditOverlayStartGesture.ts +15 -25
  16. package/src/components/editor/manualEdits.test.ts +3 -9
  17. package/src/components/editor/manualEdits.ts +0 -1
  18. package/src/components/editor/manualEditsDom.ts +0 -10
  19. package/src/components/editor/manualEditsDomPatches.test.ts +1 -45
  20. package/src/components/editor/manualEditsDomPatches.ts +1 -23
  21. package/src/components/editor/manualEditsSnapshot.ts +2 -0
  22. package/src/components/editor/manualEditsTypes.ts +2 -0
  23. package/src/components/editor/manualOffsetDrag.test.ts +41 -0
  24. package/src/components/editor/manualOffsetDrag.ts +6 -27
  25. package/src/components/editor/plainTranslate.ts +9 -4
  26. package/src/components/editor/propertyPanelTransformCommit.test.ts +29 -0
  27. package/src/components/editor/propertyPanelTransformCommit.ts +8 -5
  28. package/src/components/editor/propertyPanelTypes.ts +1 -0
  29. package/src/components/editor/rotateInlineSave.test.ts +111 -0
  30. package/src/components/editor/rotationDraft.test.ts +111 -0
  31. package/src/components/editor/rotationDraft.ts +145 -0
  32. package/src/components/editor/useDomEditNudge.test.tsx +33 -0
  33. package/src/components/editor/useDomEditNudge.ts +6 -2
  34. package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
  35. package/src/hooks/draggedGsapPosition.ts +28 -0
  36. package/src/hooks/elementOffsetStager.ts +26 -6
  37. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  38. package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
  39. package/src/hooks/plainRotation.test.ts +122 -0
  40. package/src/hooks/plainRotation.ts +42 -0
  41. package/src/hooks/useDomEditSession.ts +5 -5
  42. package/src/hooks/useDomGeometryCommit.test.tsx +75 -0
  43. package/src/hooks/useDomGeometryCommit.ts +17 -17
  44. package/src/hooks/useDomGeometryCommits.test.tsx +56 -1
  45. package/src/hooks/useDomGeometryCommits.ts +28 -36
  46. package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
  47. package/src/hooks/useGsapAwareEditing.ts +43 -42
  48. package/src/hooks/useInlineTextEdit.test.tsx +33 -3
  49. package/src/hooks/useInlineTextEdit.ts +2 -2
@@ -24,8 +24,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
24
24
  import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
25
25
  import type { RecordEditInput } from "./domEditDeleteMembers";
26
26
  import type { DomEditTimelineParams } from "./useDomSelectionTypes";
27
- // Re-exported: the delete rule lives in its own module now, and callers (and its
28
- // own test) have always imported it from here.
27
+ // Re-exported: the delete rule lives in its own module; callers and its test import it from here.
29
28
  export { membersForDelete };
30
29
 
31
30
  export interface UseDomEditSessionParams extends DomEditTimelineParams {
@@ -232,6 +231,7 @@ export function useDomEditSession({
232
231
  stageElementPositionOffset,
233
232
  commitPositionPatchToHtml,
234
233
  handleDomBoxSizeCommit,
234
+ handleDomRotationCommit: handleDomCssRotationCommit,
235
235
  handleDomManualEditsReset,
236
236
  handleDomEditElementsDelete,
237
237
  handleDomZIndexReorderCommit,
@@ -256,9 +256,8 @@ export function useDomEditSession({
256
256
  readOnlyPreview,
257
257
  onTrySdkPersist: sdkSession
258
258
  ? (selection, operations, originalContent, targetPath, options) => {
259
- // Decoupled tripwire, runs regardless of the cutover flag. originalContent lets
260
- // the runtime-node filter suppress hf-ids absent from source (script-created
261
- // nodes); the paths let a cross-file edit skip instead of a false not-found.
259
+ // Decoupled tripwire, regardless of the cutover flag. originalContent lets the runtime-node
260
+ // filter drop hf-ids absent from source; the paths skip a cross-file edit, not a not-found.
262
261
  runResolverShadow(sdkSession, selection.hfId, operations, originalContent, {
263
262
  targetPath,
264
263
  compositionPath: activeCompPath,
@@ -491,6 +490,7 @@ export function useDomEditSession({
491
490
  trackGsapInteractionFailure,
492
491
  stageElementPositionOffset,
493
492
  handleDomBoxSizeCommit,
493
+ handleDomRotationCommit: handleDomCssRotationCommit,
494
494
  commitPositionPatchToHtml,
495
495
  addGsapAnimation,
496
496
  convertToKeyframes,
@@ -176,6 +176,28 @@ describe("useDomGeometryCommit, from the package entry", () => {
176
176
  unmount();
177
177
  });
178
178
 
179
+ it("saves a GSAP-free resize as its inline size, with no GSAP script and no animation read", async () => {
180
+ const calls = stubPatchServer();
181
+ const { element, recordEdit, hook, unmount } = renderHost();
182
+
183
+ await expect(
184
+ hook().commitBoxSize(makeSelection("card", element), { width: 300, height: 90 }),
185
+ ).resolves.toEqual({ ok: true });
186
+
187
+ expect(element.style.getPropertyValue("width")).toBe("300px");
188
+ expect(calls.patches).toEqual([
189
+ expect.objectContaining({
190
+ operations: expect.arrayContaining([
191
+ { type: "inline-style", property: "width", value: "300px" },
192
+ { type: "inline-style", property: "height", value: "90px" },
193
+ ]),
194
+ }),
195
+ ]);
196
+ expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
197
+ expect(recordEdit).toHaveBeenCalledTimes(1);
198
+ unmount();
199
+ });
200
+
179
201
  it("puts a GSAP-free element's translate back when its move cannot be saved", async () => {
180
202
  stubPatchServer(500);
181
203
  const { element, hook, unmount } = renderHost();
@@ -188,6 +210,57 @@ describe("useDomGeometryCommit, from the package entry", () => {
188
210
  unmount();
189
211
  });
190
212
 
213
+ it("refuses a CSS move GSAP has folded into its x/y, writes nothing, and hands the translate back", async () => {
214
+ const calls = stubPatchServer();
215
+ const { element, hook, unmount } = renderHost();
216
+ element.style.setProperty("translate", "none");
217
+ Object.assign(element, { _gsap: { renderTransform: () => {}, x: "94px", y: "66px" } });
218
+ const set = vi.fn();
219
+ Object.assign(element.ownerDocument.defaultView!, { gsap: { set } });
220
+
221
+ await expect(
222
+ hook().commitPathOffset(
223
+ makeSelection("card", element),
224
+ { x: 1, y: 2 },
225
+ { plainTranslate: true },
226
+ ),
227
+ ).rejects.toThrow(/animation took over/);
228
+ delete (element.ownerDocument.defaultView as { gsap?: unknown }).gsap;
229
+ expect(calls.patches).toEqual([]);
230
+ expect(set).toHaveBeenCalledWith(element, { x: 0, y: 0, xPercent: 0, yPercent: 0 });
231
+ unmount();
232
+ });
233
+
234
+ it("saves a CSS move GSAP has only parsed, with nothing folded into its x/y", async () => {
235
+ const calls = stubPatchServer();
236
+ const { element, hook, unmount } = renderHost();
237
+ Object.assign(element, {
238
+ _gsap: { renderTransform: () => {}, x: "0px", y: "0px", xPercent: 0 },
239
+ });
240
+
241
+ await expect(
242
+ hook().commitPathOffset(
243
+ makeSelection("card", element),
244
+ { x: 1, y: 2 },
245
+ { plainTranslate: true },
246
+ ),
247
+ ).resolves.toEqual({ ok: true });
248
+ expect(calls.patches).toHaveLength(1);
249
+ unmount();
250
+ });
251
+
252
+ it("leaves a later move's translate alone when an earlier move's save fails", async () => {
253
+ stubPatchServer(500);
254
+ const { element, hook, unmount } = renderHost();
255
+ element.style.setProperty("translate", "40px 30px");
256
+
257
+ const saving = hook().commitPathOffset(makeSelection("card", element), { x: 1, y: 2 });
258
+ element.style.setProperty("translate", "7px 8px");
259
+ await expect(saving).rejects.toThrow();
260
+ expect(element.style.getPropertyValue("translate")).toBe("7px 8px");
261
+ unmount();
262
+ });
263
+
191
264
  it("keeps the same commits across renders, so the overlay's handlers stay put", () => {
192
265
  stubServer();
193
266
  const { hook, rerender, unmount } = renderHost();
@@ -216,6 +289,8 @@ describe("useDomGeometryCommit, from the package entry", () => {
216
289
  const showToast = vi.fn();
217
290
  const restore = vi.fn();
218
291
  const { element, recordEdit, hook, unmount } = renderHost({ showToast });
292
+ // GSAP renders the card's transform, so its resize reads animations first.
293
+ Object.assign(element, { _gsap: { renderTransform: () => undefined } });
219
294
 
220
295
  await expect(
221
296
  hook().commitBoxSize(
@@ -1,7 +1,11 @@
1
+ import type { RotationCommit } from "../components/editor/rotationDraft";
1
2
  import { useCallback, useMemo, useRef, useState } from "react";
2
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
4
  import type { DomEditSelection } from "../components/editor/domEditing";
4
- import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
5
+ import type {
6
+ DomEditGroupPathOffsetCommit,
7
+ MoveCommitOptions,
8
+ } from "../components/editor/DomEditOverlay";
5
9
  import { isPreviewBooted } from "../player/store/playerStore";
6
10
  import type { DomEditCommitOutcome } from "./domEditCommitRunner";
7
11
  import type { UseDomStyleCommitOptions } from "./useDomStyleCommit";
@@ -13,8 +17,8 @@ import { useGsapCacheVersion } from "./useGsapTweenCache";
13
17
  import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
14
18
  import { useDomEditPersist } from "./useDomEditPersist";
15
19
  import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
20
+ import { useDomGeometryCommits } from "./useDomGeometryCommits";
16
21
  import { useMountEffect } from "./useMountEffect";
17
- import { stageElementOffset } from "./elementOffsetStager";
18
22
 
19
23
  /**
20
24
  * Studio's `Player` must show the project, with `beginTimelineSession(projectId)` run before it
@@ -29,7 +33,7 @@ export interface DomGeometryCommits {
29
33
  commitPathOffset: (
30
34
  selection: DomEditSelection,
31
35
  next: { x: number; y: number },
32
- modifiers?: { altKey?: boolean },
36
+ modifiers?: MoveCommitOptions,
33
37
  ) => Promise<DomEditCommitOutcome>;
34
38
  commitGroupPathOffset: (updates: DomEditGroupPathOffsetCommit[]) => Promise<DomEditCommitOutcome>;
35
39
  commitBoxSize: (
@@ -40,15 +44,13 @@ export interface DomGeometryCommits {
40
44
  ) => Promise<DomEditCommitOutcome>;
41
45
  commitRotation: (
42
46
  selection: DomEditSelection,
43
- next: { angle: number },
47
+ next: RotationCommit,
44
48
  ) => Promise<DomEditCommitOutcome>;
45
49
  waitForPendingSaves: () => Promise<void>;
46
50
  }
47
51
 
48
52
  const noop = () => {};
49
53
  const NO_SELECTED_ANIMATIONS: GsapAnimation[] = [];
50
- // ponytail: unreachable, the GSAP writer always exists so a resize never takes the DOM route.
51
- const noDomBoxSizeRoute = () => Promise.reject(new Error("Resize has no DOM route here"));
52
54
 
53
55
  /**
54
56
  * Saves canvas moves, resizes and rotations through Studio's own GSAP-aware commits, for a host
@@ -107,16 +109,13 @@ export function useDomGeometryCommit({
107
109
  },
108
110
  [commitPositionPatchToHtml, queue],
109
111
  );
110
- const stageElementPositionOffset = useCallback(
111
- (selection: DomEditSelection, next: { x: number; y: number }, coalesceKey?: string) =>
112
- stageElementOffset(
113
- { commitPositionPatchToHtml: commitWithFreshQueue, showToast },
114
- selection,
115
- next,
116
- coalesceKey,
117
- ),
118
- [commitWithFreshQueue, showToast],
119
- );
112
+ const { stageElementPositionOffset, handleDomBoxSizeCommit, handleDomRotationCommit } =
113
+ useDomGeometryCommits({
114
+ previewIframeRef: iframeRef,
115
+ showToast,
116
+ commitPositionPatchToHtml: commitWithFreshQueue,
117
+ readOnlyPreview: false,
118
+ });
120
119
  const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
121
120
  const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
122
121
  const {
@@ -135,7 +134,8 @@ export function useDomGeometryCommit({
135
134
  makeFetchFallback,
136
135
  trackGsapInteractionFailure,
137
136
  stageElementPositionOffset,
138
- handleDomBoxSizeCommit: noDomBoxSizeRoute,
137
+ handleDomBoxSizeCommit,
138
+ handleDomRotationCommit,
139
139
  commitPositionPatchToHtml: commitWithFreshQueue,
140
140
  addGsapAnimation: gsap.addGsapAnimation,
141
141
  convertToKeyframes: gsap.convertToKeyframes,
@@ -162,7 +162,7 @@ describe("useDomGeometryCommits element position offset", () => {
162
162
  .mockResolvedValue(undefined);
163
163
  const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
164
164
 
165
- await commits().stageElementPositionOffset(selection, { x: 40, y: 20 }).save();
165
+ await commits().stageElementPositionOffset(selection, { x: 40, y: 20 }, false).save();
166
166
 
167
167
  const patches = commitPositionPatchToHtml.mock.calls[0]![1];
168
168
  expect(patches).toEqual([
@@ -175,6 +175,61 @@ describe("useDomGeometryCommits element position offset", () => {
175
175
  });
176
176
  });
177
177
 
178
+ describe("useDomGeometryCommits resize anchor", () => {
179
+ it("saves the anchor as the element's own plain px translate, sub-pixel, with the size", async () => {
180
+ const element = document.createElement("div");
181
+ element.id = "anchored";
182
+ element.style.setProperty("translate", "40px 30px");
183
+ document.body.append(element);
184
+ const selection = {
185
+ id: "anchored",
186
+ selector: "#anchored",
187
+ element,
188
+ } as unknown as DomEditSelection;
189
+ const commit = vi
190
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
191
+ .mockResolvedValue(undefined);
192
+ const { commits, unmount } = mountCommits(commit);
193
+
194
+ await commits().handleDomBoxSizeCommit(
195
+ selection,
196
+ { width: 340, height: 227 },
197
+ { x: -10.25, y: 3.5 },
198
+ );
199
+
200
+ expect(commit).toHaveBeenCalledTimes(1);
201
+ const patches = commit.mock.calls[0]![1];
202
+ expect(patches).toContainEqual({ type: "inline-style", property: "width", value: "340px" });
203
+ expect(patches).toContainEqual({
204
+ type: "inline-style",
205
+ property: "translate",
206
+ value: "-10.25px 3.5px",
207
+ });
208
+ expect(patches.some((p) => p.property.startsWith("--hf-studio-offset"))).toBe(false);
209
+ expect(element.style.getPropertyValue("translate")).toBe("-10.25px 3.5px");
210
+ unmount();
211
+ });
212
+ });
213
+
214
+ describe("useDomGeometryCommits resize rollback", () => {
215
+ it("rolls the whole gesture back once when the resize save fails", async () => {
216
+ const element = document.createElement("div");
217
+ document.body.append(element);
218
+ const selection = { id: "r", selector: "#r", element } as unknown as DomEditSelection;
219
+ const failure = new Error("save failed");
220
+ const commit = vi
221
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
222
+ .mockRejectedValue(failure);
223
+ const restore = vi.fn();
224
+ const { commits, unmount } = mountCommits(commit);
225
+ await expect(
226
+ commits().handleDomBoxSizeCommit(selection, { width: 50, height: 40 }, undefined, restore),
227
+ ).rejects.toBe(failure);
228
+ expect(restore).toHaveBeenCalledTimes(1);
229
+ unmount();
230
+ });
231
+ });
232
+
178
233
  describe("useDomGeometryCommits resize of a cropped element", () => {
179
234
  it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
180
235
  const element = withInlineLayoutBox(document.createElement("div"));
@@ -1,9 +1,9 @@
1
+ import type { RotationCommit } from "../components/editor/rotationDraft";
1
2
  import { useCallback } from "react";
2
3
  import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
3
4
  import {
4
5
  applyStudioPathOffset,
5
6
  applyStudioBoxSize,
6
- applyStudioRotation,
7
7
  captureStudioPathOffset,
8
8
  captureStudioBoxSize,
9
9
  captureStudioRotation,
@@ -15,11 +15,12 @@ import {
15
15
  clearStudioRotation,
16
16
  } from "../components/editor/manualEdits";
17
17
  import { stageElementOffset } from "./elementOffsetStager";
18
+ import { savePlainRotation } from "./plainRotation";
18
19
  import { prepareCropResize } from "../components/editor/cropResize";
20
+ import { translatePatch, writeTranslatePx } from "../components/editor/plainTranslate";
19
21
  import {
20
22
  buildPathOffsetPatches,
21
23
  buildBoxSizePatches,
22
- buildRotationPatches,
23
24
  buildClearPathOffsetPatches,
24
25
  buildClearBoxSizePatches,
25
26
  buildClearRotationPatches,
@@ -61,11 +62,17 @@ export function useDomGeometryCommits({
61
62
  readOnlyPreview,
62
63
  }: UseDomGeometryCommitsParams) {
63
64
  const stageElementPositionOffset = useCallback(
64
- (selection: DomEditSelection, next: { x: number; y: number }, coalesceKey?: string) =>
65
+ (
66
+ selection: DomEditSelection,
67
+ next: { x: number; y: number },
68
+ plainTranslate: boolean,
69
+ coalesceKey?: string,
70
+ ) =>
65
71
  stageElementOffset(
66
72
  { commitPositionPatchToHtml, showToast, readOnlyPreview },
67
73
  selection,
68
74
  next,
75
+ plainTranslate,
69
76
  coalesceKey,
70
77
  ),
71
78
  [commitPositionPatchToHtml, readOnlyPreview, showToast],
@@ -98,56 +105,41 @@ export function useDomGeometryCommits({
98
105
  selection: DomEditSelection,
99
106
  next: { width: number; height: number },
100
107
  offset?: { x: number; y: number },
108
+ restore?: () => void,
101
109
  ) => {
102
110
  if (readOnlyPreview) return Promise.resolve();
103
- const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
104
- if (gsapFallback) return gsapFallback;
105
- const beforeSize = captureStudioBoxSize(selection.element);
106
- const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
107
- const stageCrop = prepareCropResize(selection.element);
108
- applyStudioBoxSize(selection.element, next);
111
+ const element = selection.element;
112
+ const beforeSize = captureStudioBoxSize(element);
113
+ const beforeTranslate = element.style.getPropertyValue("translate");
114
+ const stageCrop = prepareCropResize(element);
115
+ applyStudioBoxSize(element, next);
109
116
  const crop = stageCrop();
110
- // Anchored-corner resize (NW/NE/SW) also moves the element to keep the
111
- // opposite corner fixed. Apply the offset and emit BOTH patch sets in a
112
- // SINGLE commit: one persist = one undo entry, and there is no
113
- // intermediate re-stamp where the new size is in source but the anchor
114
- // offset is not (that frame was the release "jump"). Both builders read
115
- // the already-mutated live element, so concatenation is safe.
116
- const patches = buildBoxSizePatches(selection.element);
117
+ // One commit, one undo entry: the size, the crop that follows it, and the translate
118
+ // (as a move writes it) that keeps the centre planted.
119
+ const patches = buildBoxSizePatches(element);
117
120
  if (crop) patches.push(crop.patch);
118
121
  if (offset) {
119
- applyStudioPathOffset(selection.element, offset);
120
- patches.push(...buildPathOffsetPatches(selection.element));
122
+ writeTranslatePx(element, offset);
123
+ patches.push(translatePatch(offset));
121
124
  }
122
125
  return commitPositionPatchToHtml(selection, patches, {
123
126
  label: "Resize layer box",
124
127
  coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
125
128
  }).catch((error) => {
126
- restoreStudioBoxSize(selection.element, beforeSize);
127
- if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
129
+ restoreStudioBoxSize(element, beforeSize);
130
+ if (offset) element.style.setProperty("translate", beforeTranslate);
128
131
  crop?.revert();
132
+ restore?.();
129
133
  throw error;
130
134
  });
131
135
  },
132
- [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
136
+ [commitPositionPatchToHtml, readOnlyPreview],
133
137
  );
134
138
 
135
139
  const handleDomRotationCommit = useCallback(
136
- (selection: DomEditSelection, next: { angle: number }) => {
137
- if (readOnlyPreview) return Promise.resolve();
138
- const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
139
- if (gsapFallback) return gsapFallback;
140
- const before = captureStudioRotation(selection.element);
141
- applyStudioRotation(selection.element, next);
142
- return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
143
- label: "Rotate layer",
144
- coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
145
- }).catch((error) => {
146
- restoreStudioRotation(selection.element, before);
147
- throw error;
148
- });
149
- },
150
- [commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
140
+ (selection: DomEditSelection, next: RotationCommit) =>
141
+ savePlainRotation({ commitPositionPatchToHtml, readOnlyPreview }, selection, next),
142
+ [commitPositionPatchToHtml, readOnlyPreview],
151
143
  );
152
144
 
153
145
  const handleDomManualEditsReset = useCallback(