@hyperframes/studio 0.8.98 → 0.8.99

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 (51) hide show
  1. package/dist/assets/{hyperframes-player-C6muFtfk.js → hyperframes-player-Rml34FRW.js} +1 -1
  2. package/dist/assets/{index-itc1XziP.js → index-CVDArg3m.js} +178 -178
  3. package/dist/assets/{index-B6vU39NM.js → index-C_fi-jTe.js} +1 -1
  4. package/dist/assets/{index-BxnoLgxG.js → index-D9YzDuBx.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +542 -373
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -9
  9. package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
  10. package/src/components/editor/DomEditCropHandles.tsx +30 -62
  11. package/src/components/editor/DomEditOverlay.test.ts +2 -2
  12. package/src/components/editor/cropResize.test.ts +129 -0
  13. package/src/components/editor/cropResize.ts +143 -0
  14. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  15. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  17. package/src/components/editor/elementPositionOffset.ts +5 -4
  18. package/src/components/editor/manualEdits.test.ts +22 -8
  19. package/src/components/editor/manualEditsDom.ts +27 -11
  20. package/src/components/editor/manualEditsParsing.ts +0 -4
  21. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  22. package/src/components/editor/manualOffsetDrag.test.ts +14 -0
  23. package/src/hooks/domSelectionTestHarness.ts +8 -0
  24. package/src/hooks/draggedGsapPosition.ts +4 -2
  25. package/src/hooks/gsapDragCommit.ts +6 -6
  26. package/src/hooks/gsapEditOutcome.ts +6 -0
  27. package/src/hooks/gsapResizeIntercept.ts +5 -5
  28. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  29. package/src/hooks/gsapRuntimeBridge.ts +2 -1
  30. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  31. package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
  32. package/src/hooks/timelineTimingSync.ts +40 -4
  33. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  34. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  35. package/src/hooks/useCropOverlay.ts +8 -2
  36. package/src/hooks/useDomEditCommits.test.tsx +6 -1
  37. package/src/hooks/useDomEditCommits.ts +1 -0
  38. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  39. package/src/hooks/useDomEditSession.ts +2 -0
  40. package/src/hooks/useDomGeometryCommit.ts +1 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
  42. package/src/hooks/useDomGeometryCommits.ts +5 -0
  43. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  44. package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
  45. package/src/hooks/useGsapAwareEditing.ts +37 -23
  46. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  47. package/src/hooks/useTimelineEditing.ts +13 -13
  48. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  49. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  50. package/src/utils/rounding.ts +5 -0
  51. package/src/utils/sdkEditTransaction.ts +10 -3
@@ -200,7 +200,7 @@ function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: s
200
200
  /**
201
201
  * Like `varsCarryChannel` but for a keyframe tween: the channels live inside the
202
202
  * keyframe steps (`vars.keyframes`), not as own props of `vars`. Handles the object
203
- * form (`{ "0%": {...} }`) and the array form (`[{...}, ...]`).
203
+ * form (`{ "0%": {...} }`), the array form (`[{...}, ...]`) and the object-of-arrays form.
204
204
  */
205
205
  function keyframeVarsCarryChannel(
206
206
  vars: Record<string, unknown> | undefined,
@@ -208,6 +208,8 @@ function keyframeVarsCarryChannel(
208
208
  ): boolean {
209
209
  const kf = vars?.keyframes;
210
210
  if (!kf || typeof kf !== "object") return false;
211
+ if (!Array.isArray(kf) && channels.some((ch) => Object.prototype.hasOwnProperty.call(kf, ch)))
212
+ return true;
211
213
  const steps = Array.isArray(kf) ? kf : Object.values(kf);
212
214
  return steps.some(
213
215
  (step) =>
@@ -375,8 +377,9 @@ export function readRuntimeKeyframes(
375
377
  }
376
378
 
377
379
  /**
378
- * Whether the live timeline has at least one NON-HOLD tween (non-zero duration,
379
- * not the studio position-hold `set`) targeting `selector`. Stricter than a
380
+ * Whether any live timeline has at least one NON-HOLD tween (non-zero duration,
381
+ * not the studio position-hold `set`) targeting `selector`. Every timeline is read:
382
+ * a soft reload re-adds the rebuilt composition's key last. Stricter than a
380
383
  * truthy `readRuntimeKeyframes`: that returns a flat read for any property-bearing
381
384
  * tween, so it can't distinguish a real animation from a leftover hold/marker.
382
385
  * The drag's stale-parse guard needs this exact distinction — after a delete-all
@@ -387,37 +390,47 @@ export function readRuntimeKeyframes(
387
390
  * rotation/scale tween doesn't make a static position hold enter the keyframe
388
391
  * branch.
389
392
  */
390
- // fallow-ignore-next-line complexity
391
393
  export function hasNonHoldTweenForElement(
392
394
  iframe: HTMLIFrameElement | null,
393
395
  selector: string,
394
396
  compositionId?: string,
395
397
  channels?: string[],
396
398
  ): boolean {
397
- const timelines = timelinesOf(iframe);
398
- if (!timelines) return false;
399
- const tlId =
400
- compositionId ||
401
- Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function");
402
- if (!tlId) return false;
403
- const timeline = timelines[tlId];
404
- if (!timeline?.getChildren) return false;
405
-
406
399
  let targetEl: Element | null = null;
407
400
  try {
408
401
  targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
409
402
  } catch {
410
403
  return false;
411
404
  }
412
- if (!targetEl) return false;
405
+ return !!targetEl && hasNonHoldTween(timelinesOf(iframe), targetEl, channels, compositionId);
406
+ }
413
407
 
414
- // fallow-ignore-next-line code-duplication
415
- for (const tween of timeline.getChildren(true)) {
416
- if (!tween.vars || !matchesElement(tween, targetEl)) continue;
417
- const dur = typeof tween.duration === "function" ? tween.duration() : 0;
418
- if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
419
- const read = readTween(tween.vars);
420
- if (read && (!channels || readCarriesChannel(read, channels))) return true;
408
+ /** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
409
+ export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
410
+ const win = el.ownerDocument.defaultView as {
411
+ __timelines?: Record<string, RuntimeTimeline>;
412
+ } | null;
413
+ return hasNonHoldTween(win?.__timelines ?? null, el, channels);
414
+ }
415
+
416
+ // fallow-ignore-next-line complexity
417
+ function hasNonHoldTween(
418
+ timelines: Record<string, RuntimeTimeline> | null,
419
+ targetEl: Element,
420
+ channels?: string[],
421
+ compositionId?: string,
422
+ ): boolean {
423
+ if (!timelines) return false;
424
+ for (const tlId of compositionId ? [compositionId] : Object.keys(timelines)) {
425
+ // fallow-ignore-next-line code-duplication
426
+ for (const tween of timelines[tlId]?.getChildren?.(true) ?? []) {
427
+ if (!tween.vars || !matchesElement(tween, targetEl)) continue;
428
+ const dur = typeof tween.duration === "function" ? tween.duration() : 0;
429
+ if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
430
+ if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true;
431
+ const read = readTween(tween.vars);
432
+ if (read && (!channels || readCarriesChannel(read, channels))) return true;
433
+ }
421
434
  }
422
435
  return false;
423
436
  }
@@ -3,7 +3,11 @@
3
3
  // edit's undo history, and swapping the rewritten script into the live preview
4
4
  // without a full iframe reload when possible.
5
5
  import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
6
- import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftReload";
6
+ import {
7
+ applySoftReload,
8
+ applySoftReloadFinalization,
9
+ extractGsapScriptText,
10
+ } from "../utils/gsapSoftReload";
7
11
  import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
8
12
  import type { RecordEditInput } from "../utils/studioFileHistory";
9
13
  import { patchDocumentRootDuration } from "./timelineEditingGsap";
@@ -15,6 +19,9 @@ import {
15
19
  rollbackOwnedMutation,
16
20
  type GsapMutationStatus,
17
21
  } from "./gsapMutationClient";
22
+ import type { CutoverResult } from "../utils/sdkEditTransaction";
23
+ import { findTimelineScript } from "@hyperframes/core/gsap-parser-acorn";
24
+ import { walkCompositionDescendants } from "@hyperframes/parsers/hf-ids";
18
25
  import {
19
26
  serializeStudioFileMutations,
20
27
  type StudioProjectFileWriter,
@@ -365,6 +372,21 @@ export async function scaleGsapPositions(
365
372
  );
366
373
  }
367
374
 
375
+ /** The GSAP sync a committed SDK timing write made, or null when its timeline script is unchanged. */
376
+ export function sdkTimingGsapSync(
377
+ result: Extract<CutoverResult, { status: "committed" }>,
378
+ ): GsapMutationStatus | null {
379
+ const timelineText = (html: string) => {
380
+ const scripts: Element[] = [];
381
+ walkCompositionDescendants(new DOMParser().parseFromString(html, "text/html"), (el) => {
382
+ if (el.tagName.toLowerCase() === "script") scripts.push(el);
383
+ });
384
+ return findTimelineScript(scripts)?.textContent ?? null;
385
+ };
386
+ if (timelineText(result.before) === timelineText(result.after)) return null;
387
+ return { mutated: true, scriptText: extractGsapScriptText(result.after) };
388
+ }
389
+
368
390
  /** Timing delta a single-clip edit applies to its GSAP tweens. */
369
391
  export type SingleClipGsapEdit =
370
392
  | { kind: "shift"; delta: number }
@@ -395,8 +417,9 @@ export function finishClipTimingFallback(input: {
395
417
  recordEdit: (edit: RecordEditInput) => Promise<void>;
396
418
  writeProjectFile: StudioProjectFileWriter;
397
419
  edit: SingleClipGsapEdit;
420
+ sdkGsap?: GsapMutationStatus | null;
398
421
  }): Promise<void> {
399
- const { projectId, targetPath, domId, edit } = input;
422
+ const { projectId, targetPath, domId, edit, sdkGsap } = input;
400
423
  const timingChanged =
401
424
  edit.kind === "shift"
402
425
  ? edit.delta !== 0
@@ -418,8 +441,9 @@ export function finishClipTimingFallback(input: {
418
441
  return finishTimelineTimingFallback({
419
442
  iframe: input.iframe,
420
443
  reloadPreview: input.reloadPreview,
421
- gsapMutation:
422
- timingChanged && domId && projectId
444
+ gsapMutation: sdkGsap
445
+ ? () => Promise.resolve(sdkGsap)
446
+ : timingChanged && domId && projectId
423
447
  ? () =>
424
448
  foldGsapMutationIntoHistory({
425
449
  writeFile: input.writeProjectFile,
@@ -463,12 +487,24 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
463
487
  resolveChangePath: (element: TimelineElement) => string;
464
488
  /** Per-change GSAP mutation; return null to skip a change with no timing delta. */
465
489
  mutateChange: (change: C, changePath: string) => Promise<GsapMutationStatus> | null;
490
+ sdkGsap?: GsapMutationStatus | null;
466
491
  }): Promise<void> {
467
492
  const activePath = input.activeCompPath || "index.html";
468
493
  const otherFileChanged = input.changes.some(
469
494
  (change) => input.resolveChangePath(change.element) !== activePath,
470
495
  );
471
496
  const onGsapError = (err: unknown) => console.error(`[Timeline] ${input.errorLabel}`, err);
497
+ const { sdkGsap } = input;
498
+ if (sdkGsap) {
499
+ await finishTimelineTimingFallback({
500
+ iframe: input.iframe,
501
+ reloadPreview: input.reloadPreview,
502
+ gsapMutation: () => Promise.resolve(sdkGsap),
503
+ onGsapError,
504
+ rebindWhenUnmutated: !otherFileChanged,
505
+ });
506
+ return;
507
+ }
472
508
  await finishTimelineTimingFallback({
473
509
  iframe: input.iframe,
474
510
  reloadPreview: input.reloadPreview,
@@ -6,7 +6,9 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
8
  import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
9
- import { mountReactHarness } from "./domSelectionTestHarness";
9
+ import { mountReactHarness, withInlineLayoutBox } from "./domSelectionTestHarness";
10
+ import { writeSizeWithCrop } from "../components/editor/cropResize";
11
+ import type { CommitMutation } from "./gsapScriptCommitTypes";
10
12
 
11
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
14
 
@@ -362,3 +364,40 @@ describe("commitStaticSet group routing", () => {
362
364
  act(() => root.unmount());
363
365
  });
364
366
  });
367
+
368
+ describe("useAnimatedPropertyCommit — a size write and its crop are one undo step", () => {
369
+ it.each([[{ width: 450 }], [{ x: 10, width: 450 }]])("%o on a static element", async (props) => {
370
+ const element = withInlineLayoutBox(document.createElement("div"));
371
+ element.id = "box";
372
+ element.style.cssText = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)";
373
+ document.body.append(element);
374
+ const sel = { ...selection, element } as DomEditSelection;
375
+ const keys: unknown[] = [];
376
+ const land = async (options: { coalesceKey?: string }) => {
377
+ keys.push(options.coalesceKey);
378
+ element.style.width = "450px";
379
+ };
380
+ const mutation: CommitMutation = (_s, _m, options) => land(options);
381
+ mutation.batch = (_calls, options) => land(options);
382
+ let raw!: ReturnType<typeof useAnimatedPropertyCommit>["commitAnimatedProperties"];
383
+ function Harness() {
384
+ raw = useAnimatedPropertyCommit({
385
+ selectedGsapAnimations: [],
386
+ gsapCommitMutation: mutation,
387
+ addGsapAnimation: vi.fn(),
388
+ convertToKeyframes: vi.fn(),
389
+ previewIframeRef: { current: null },
390
+ bumpGsapCache: vi.fn(),
391
+ }).commitAnimatedProperties;
392
+ return null;
393
+ }
394
+ const root = mountReactHarness(<Harness />);
395
+ const patch = vi.fn().mockResolvedValue(undefined);
396
+ await writeSizeWithCrop(sel, props, mutation, patch, (keyed) => raw(sel, props, keyed));
397
+
398
+ const cropKey = patch.mock.calls[0]![2].coalesceKey;
399
+ expect(patch.mock.calls[0]![1][0]).toMatchObject({ value: "inset(0px 90px 0px 0px)" });
400
+ expect(keys).toEqual([cropKey]);
401
+ act(() => root.unmount());
402
+ });
403
+ });
@@ -431,8 +431,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
431
431
  const commitAnimatedProperties = useCallback(
432
432
  // This is the single routing boundary for set, keyframe, whole-tween, and first-group writes.
433
433
  // fallow-ignore-next-line complexity
434
- async (selection: DomEditSelection, props: Record<string, number | string>): Promise<void> => {
435
- if (!gsapCommitMutation) return;
434
+ async (
435
+ selection: DomEditSelection,
436
+ props: Record<string, number | string>,
437
+ commit: CommitMutation | null = gsapCommitMutation,
438
+ ): Promise<void> => {
439
+ if (!commit) return;
436
440
  const propEntries = Object.entries(props);
437
441
  if (propEntries.length === 0) return;
438
442
  const primaryProp = propEntries[0]![0];
@@ -490,7 +494,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
490
494
  ),
491
495
  pct,
492
496
  iframe,
493
- { commitMutation: gsapCommitMutation },
497
+ { commitMutation: commit },
494
498
  `Edit ${primaryProp} (whole animation)`,
495
499
  );
496
500
  return;
@@ -503,7 +507,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
503
507
  primaryProp,
504
508
  selector,
505
509
  iframe,
506
- gsapCommitMutation,
510
+ commit,
507
511
  );
508
512
  return;
509
513
  }
@@ -515,13 +519,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
515
519
  // must update the position set AND create a size set atomically rather
516
520
  // than contaminating the first set with a foreign property group.
517
521
  if (!elementHasKeyframes) {
518
- await commitStaticSet(
519
- selection,
520
- propEntries,
521
- selector,
522
- selectedGsapAnimations,
523
- gsapCommitMutation,
524
- );
522
+ await commitStaticSet(selection, propEntries, selector, selectedGsapAnimations, commit);
525
523
  return;
526
524
  }
527
525
 
@@ -534,7 +532,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
534
532
  propEntries,
535
533
  selector,
536
534
  selectedGsapAnimations,
537
- gsapCommitMutation,
535
+ commit,
538
536
  );
539
537
  return;
540
538
  }
@@ -566,7 +564,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
566
564
  { percentage: 0, properties: { ...newProps, _auto: 1 } },
567
565
  { percentage: pct, properties: newProps },
568
566
  ];
569
- await gsapCommitMutation(
567
+ await commit(
570
568
  selection,
571
569
  {
572
570
  type: "add-with-keyframes",
@@ -1,6 +1,9 @@
1
1
  import type { OverlayRect } from "../components/editor/domEditOverlayGeometry";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
- import { readElementCropInsets } from "../components/editor/domEditOverlayCrop";
3
+ import {
4
+ isElementCropLifted,
5
+ readElementCropInsets,
6
+ } from "../components/editor/domEditOverlayCrop";
4
7
 
5
8
  /** Selection-box crop hug: the outline that makes the selection box hug the
6
9
  * element's committed inset crop. Crop is always-on (no mode) — the draggable
@@ -13,7 +16,10 @@ export function useCropOverlay(params: {
13
16
  }) {
14
17
  const { selection, overlayRect } = params;
15
18
 
16
- const cropInsets = selection ? readElementCropInsets(selection.element) : null;
19
+ const cropInsets =
20
+ selection && !isElementCropLifted(selection.element)
21
+ ? readElementCropInsets(selection.element)
22
+ : null;
17
23
  const hasCropInsets = Boolean(
18
24
  cropInsets &&
19
25
  (cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0),
@@ -1204,7 +1204,12 @@ describe("useDomEditCommits style persist handling", () => {
1204
1204
  const fetchMock = stubPatchFetch({ ok: true, changed: true, matched: true });
1205
1205
  const { iframe, element } = createPreviewElement();
1206
1206
  const rendered = renderDomEditCommits(createSelection(element), iframe, {
1207
- onTrySdkPersist: async () => ({ status: "committed", version: "sdk-version-2" }),
1207
+ onTrySdkPersist: async () => ({
1208
+ status: "committed",
1209
+ version: "sdk-version-2",
1210
+ before: "",
1211
+ after: "",
1212
+ }),
1208
1213
  });
1209
1214
 
1210
1215
  try {
@@ -314,6 +314,7 @@ export function useDomEditCommits({
314
314
  handleDomAddTextField,
315
315
  handleDomRemoveTextField,
316
316
  stageElementPositionOffset,
317
+ commitPositionPatchToHtml,
317
318
  handleDomPathOffsetCommit,
318
319
  handleDomBoxSizeCommit,
319
320
  handleDomRotationCommit,
@@ -12,12 +12,12 @@ interface UseDomEditPositionPatchCommitParams {
12
12
  showToast: (message: string, tone?: "error" | "info") => void;
13
13
  }
14
14
 
15
- interface PositionPatchOptions {
15
+ type PositionPatchOptions = {
16
16
  label: string;
17
17
  coalesceKey: string;
18
18
  coalesceMs?: number;
19
19
  skipRefresh?: boolean;
20
- }
20
+ };
21
21
 
22
22
  export function useDomEditPositionPatchCommit({
23
23
  activeCompPath,
@@ -230,6 +230,7 @@ export function useDomEditSession({
230
230
  handleDomAddTextField,
231
231
  handleDomRemoveTextField,
232
232
  stageElementPositionOffset,
233
+ commitPositionPatchToHtml,
233
234
  handleDomBoxSizeCommit,
234
235
  handleDomManualEditsReset,
235
236
  handleDomEditElementsDelete,
@@ -490,6 +491,7 @@ export function useDomEditSession({
490
491
  trackGsapInteractionFailure,
491
492
  stageElementPositionOffset,
492
493
  handleDomBoxSizeCommit,
494
+ commitPositionPatchToHtml,
493
495
  addGsapAnimation,
494
496
  convertToKeyframes,
495
497
  setArcPath,
@@ -136,6 +136,7 @@ export function useDomGeometryCommit({
136
136
  trackGsapInteractionFailure,
137
137
  stageElementPositionOffset,
138
138
  handleDomBoxSizeCommit: noDomBoxSizeRoute,
139
+ commitPositionPatchToHtml: commitWithFreshQueue,
139
140
  addGsapAnimation: gsap.addGsapAnimation,
140
141
  convertToKeyframes: gsap.convertToKeyframes,
141
142
  setArcPath: gsap.setArcPath,
@@ -13,6 +13,8 @@ import {
13
13
  readStudioRotation,
14
14
  } from "../components/editor/manualEdits";
15
15
  import { useDomGeometryCommits, type UseDomGeometryCommitsParams } from "./useDomGeometryCommits";
16
+ import { DomEditCropHandles } from "../components/editor/DomEditCropHandles";
17
+ import { withInlineLayoutBox } from "./domSelectionTestHarness";
16
18
 
17
19
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
18
20
 
@@ -170,3 +172,113 @@ describe("useDomGeometryCommits element position offset", () => {
170
172
  unmount();
171
173
  });
172
174
  });
175
+
176
+ describe("useDomGeometryCommits resize of a cropped element", () => {
177
+ it("saves the crop scaled per axis with the box in the resize's own commit", async () => {
178
+ const element = withInlineLayoutBox(document.createElement("div"));
179
+ element.id = "cropped";
180
+ element.style.cssText = "width: 300px; height: 200px; clip-path: inset(8px 60px 16px 30px)";
181
+ document.body.append(element);
182
+ const selection = {
183
+ id: "cropped",
184
+ selector: "#cropped",
185
+ element,
186
+ } as unknown as DomEditSelection;
187
+ const commitPositionPatchToHtml = vi
188
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
189
+ .mockResolvedValue(undefined);
190
+ const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
191
+ const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
192
+ const host = document.createElement("div");
193
+ const crop = createRoot(host);
194
+ act(() =>
195
+ crop.render(
196
+ <DomEditCropHandles selection={selection} overlayRect={rect} onStyleCommit={vi.fn()} />,
197
+ ),
198
+ );
199
+
200
+ await commits().handleDomBoxSizeCommit(selection, { width: 450, height: 250 });
201
+
202
+ const scaled = "inset(10px 90px 20px 45px)";
203
+ expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(1);
204
+ expect(commitPositionPatchToHtml.mock.calls[0]![1]).toContainEqual({
205
+ type: "inline-style",
206
+ property: "clip-path",
207
+ value: scaled,
208
+ });
209
+ act(() => crop.unmount());
210
+ expect(element.style.getPropertyValue("clip-path")).toBe(scaled);
211
+ unmount();
212
+ });
213
+
214
+ function mountCroppedSelection(id: string, authored: string) {
215
+ const element = withInlineLayoutBox(document.createElement("div"));
216
+ element.id = id;
217
+ element.style.cssText = authored;
218
+ document.body.append(element);
219
+ const selection = { id, selector: `#${id}`, element } as unknown as DomEditSelection;
220
+ const commitPositionPatchToHtml = vi
221
+ .fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
222
+ .mockResolvedValue(undefined);
223
+ const mounted = mountCommits(commitPositionPatchToHtml);
224
+ const onStyleCommit = vi.fn((property: string, value: string) => {
225
+ element.style.setProperty(property, value);
226
+ });
227
+ const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
228
+ const crop = createRoot(document.body.appendChild(document.createElement("div")));
229
+ const draw = () =>
230
+ act(() =>
231
+ crop.render(
232
+ <DomEditCropHandles
233
+ selection={selection}
234
+ overlayRect={rect}
235
+ onStyleCommit={onStyleCommit}
236
+ />,
237
+ ),
238
+ );
239
+ draw();
240
+ const dragRightEdge = (by: number) => {
241
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
242
+ const press = (type: string, clientX: number) =>
243
+ act(() =>
244
+ handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 3, clientX })),
245
+ );
246
+ press("pointerdown", 100);
247
+ press("pointermove", 100 - by);
248
+ press("pointerup", 100 - by);
249
+ };
250
+ const resize = () =>
251
+ mounted.commits().handleDomBoxSizeCommit(selection, { width: 450, height: 300 });
252
+ const done = () => {
253
+ act(() => crop.unmount());
254
+ mounted.unmount();
255
+ };
256
+ return { element, onStyleCommit, draw, dragRightEdge, resize, done };
257
+ }
258
+
259
+ it("after an undo while still selected, the next crop drag starts from the undone crop", async () => {
260
+ const authored = "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)";
261
+ const h = mountCroppedSelection("undone", authored);
262
+ await h.resize();
263
+ // Undo writes the file's style attribute back onto the same live element.
264
+ h.element.setAttribute("style", authored);
265
+ h.draw();
266
+ h.dragRightEdge(20);
267
+
268
+ expect(h.onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(0px 80px 0px 0px)");
269
+ h.done();
270
+ expect(h.element.style.getPropertyValue("clip-path")).toBe("inset(0px 80px 0px 0px)");
271
+ });
272
+
273
+ it("a crop drag right after a resize, with no render between, starts from the scaled crop", async () => {
274
+ const h = mountCroppedSelection(
275
+ "resized",
276
+ "width: 300px; height: 200px; clip-path: inset(0px 60px 0px 0px)",
277
+ );
278
+ await h.resize();
279
+ h.dragRightEdge(20);
280
+
281
+ expect(h.onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(0px 110px 0px 0px)");
282
+ h.done();
283
+ });
284
+ });
@@ -15,6 +15,7 @@ import {
15
15
  clearStudioRotation,
16
16
  } from "../components/editor/manualEdits";
17
17
  import { stageElementOffset } from "./elementOffsetStager";
18
+ import { prepareCropResize } from "../components/editor/cropResize";
18
19
  import {
19
20
  buildPathOffsetPatches,
20
21
  buildBoxSizePatches,
@@ -103,7 +104,9 @@ export function useDomGeometryCommits({
103
104
  if (gsapFallback) return gsapFallback;
104
105
  const beforeSize = captureStudioBoxSize(selection.element);
105
106
  const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
107
+ const stageCrop = prepareCropResize(selection.element);
106
108
  applyStudioBoxSize(selection.element, next);
109
+ const crop = stageCrop();
107
110
  // Anchored-corner resize (NW/NE/SW) also moves the element to keep the
108
111
  // opposite corner fixed. Apply the offset and emit BOTH patch sets in a
109
112
  // SINGLE commit: one persist = one undo entry, and there is no
@@ -111,6 +114,7 @@ export function useDomGeometryCommits({
111
114
  // offset is not (that frame was the release "jump"). Both builders read
112
115
  // the already-mutated live element, so concatenation is safe.
113
116
  const patches = buildBoxSizePatches(selection.element);
117
+ if (crop) patches.push(crop.patch);
114
118
  if (offset) {
115
119
  applyStudioPathOffset(selection.element, offset);
116
120
  patches.push(...buildPathOffsetPatches(selection.element));
@@ -121,6 +125,7 @@ export function useDomGeometryCommits({
121
125
  }).catch((error) => {
122
126
  restoreStudioBoxSize(selection.element, beforeSize);
123
127
  if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
128
+ crop?.revert();
124
129
  throw error;
125
130
  });
126
131
  },
@@ -92,7 +92,9 @@ describe("useElementLifecycleOps — deleting a canvas multi-selection", () => {
92
92
  it("reports a successful SDK delete as landed", async () => {
93
93
  const ops = mountDeleteOps({
94
94
  projectIdRef: { current: "p1" },
95
- onTrySdkDelete: vi.fn(async () => ({ status: "committed", version: "v1" }) as const),
95
+ onTrySdkDelete: vi.fn(
96
+ async () => ({ status: "committed", version: "v1", before: "", after: "" }) as const,
97
+ ),
96
98
  });
97
99
 
98
100
  const target = { ...selectionFor("a"), hfId: "hf-a" };