@hyperframes/studio 0.8.97 → 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 (75) hide show
  1. package/dist/assets/{hyperframes-player-Biu5_W0l.js → hyperframes-player-Rml34FRW.js} +1 -1
  2. package/dist/assets/{index-BdV5T-QI.js → index-CVDArg3m.js} +188 -188
  3. package/dist/assets/{index-B5Z-1RW9.js → index-C_fi-jTe.js} +1 -1
  4. package/dist/assets/{index-CWuozokW.js → index-D9YzDuBx.js} +1 -1
  5. package/dist/index.d.ts +16 -4
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +758 -607
  8. package/dist/index.js.map +1 -1
  9. package/package.json +10 -9
  10. package/src/components/EditorShell.tsx +7 -0
  11. package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
  12. package/src/components/editor/DomEditCropHandles.tsx +30 -62
  13. package/src/components/editor/DomEditOverlay.test.ts +2 -2
  14. package/src/components/editor/DomEditOverlay.tsx +0 -27
  15. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
  16. package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
  17. package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
  18. package/src/components/editor/cropResize.test.ts +129 -0
  19. package/src/components/editor/cropResize.ts +143 -0
  20. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  21. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  22. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  23. package/src/components/editor/elementPositionOffset.ts +5 -4
  24. package/src/components/editor/manualEdits.test.ts +22 -8
  25. package/src/components/editor/manualEditsDom.ts +27 -11
  26. package/src/components/editor/manualEditsParsing.ts +0 -4
  27. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +14 -0
  29. package/src/components/editor/propertyPanelFont.test.tsx +48 -5
  30. package/src/components/editor/propertyPanelFont.tsx +2 -1
  31. package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
  32. package/src/components/nle/TimelinePane.tsx +4 -0
  33. package/src/hooks/domSelectionTestHarness.ts +8 -0
  34. package/src/hooks/draggedGsapPosition.ts +4 -2
  35. package/src/hooks/gsapDragCommit.ts +6 -6
  36. package/src/hooks/gsapEditOutcome.ts +6 -0
  37. package/src/hooks/gsapResizeIntercept.ts +5 -5
  38. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  39. package/src/hooks/gsapRuntimeBridge.ts +2 -1
  40. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  41. package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
  42. package/src/hooks/timelineTimingSync.ts +40 -4
  43. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  44. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  45. package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
  46. package/src/hooks/useCropOverlay.ts +8 -2
  47. package/src/hooks/useDomEditCommits.test.tsx +6 -1
  48. package/src/hooks/useDomEditCommits.ts +1 -0
  49. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  50. package/src/hooks/useDomEditSession.ts +2 -0
  51. package/src/hooks/useDomGeometryCommit.ts +1 -0
  52. package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
  53. package/src/hooks/useDomGeometryCommits.ts +5 -0
  54. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  55. package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
  56. package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
  57. package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
  58. package/src/hooks/useGsapAwareEditing.ts +37 -23
  59. package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
  60. package/src/hooks/useGsapTweenCache.ts +0 -24
  61. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  62. package/src/hooks/useTimelineEditing.ts +13 -13
  63. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  64. package/src/index.ts +4 -1
  65. package/src/player/components/ClipContextMenu.test.tsx +182 -0
  66. package/src/player/components/ClipContextMenu.tsx +52 -10
  67. package/src/player/components/TimelineOverlays.test.ts +30 -7
  68. package/src/player/components/TimelineOverlays.tsx +7 -1
  69. package/src/player/components/TimelineProvider.tsx +1 -0
  70. package/src/player/components/TimelineTypes.ts +10 -0
  71. package/src/player/components/menuKeyboardNav.ts +12 -5
  72. package/src/player/components/useTimelineProviderState.tsx +2 -0
  73. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  74. package/src/utils/rounding.ts +5 -0
  75. package/src/utils/sdkEditTransaction.ts +10 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.97",
3
+ "version": "0.8.99",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -54,18 +54,18 @@
54
54
  "@codemirror/view": "6.40.0",
55
55
  "@mcp-b/global": "^5.0.1",
56
56
  "@phosphor-icons/react": "^2.1.10",
57
- "@tanstack/react-virtual": "^3.14.6",
57
+ "@tanstack/react-virtual": "^3.14.13",
58
58
  "bpm-detective": "^2.0.5",
59
59
  "cn": "0.2.4",
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.97",
64
- "@hyperframes/parsers": "0.8.97",
65
- "@hyperframes/player": "0.8.97",
66
- "@hyperframes/shader-transitions": "0.8.97",
67
- "@hyperframes/sdk": "0.8.97",
68
- "@hyperframes/studio-server": "0.8.97"
63
+ "@hyperframes/core": "0.8.99",
64
+ "@hyperframes/parsers": "0.8.99",
65
+ "@hyperframes/player": "0.8.99",
66
+ "@hyperframes/sdk": "0.8.99",
67
+ "@hyperframes/studio-server": "0.8.99",
68
+ "@hyperframes/shader-transitions": "0.8.99"
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.97"
84
+ "@hyperframes/producer": "0.8.99"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -93,6 +93,7 @@
93
93
  "build": "vite build && tsup",
94
94
  "typecheck": "tsc --noEmit",
95
95
  "test": "vitest run",
96
+ "test:edit-accuracy": "bun tests/e2e/edit-accuracy/run.mjs",
96
97
  "test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
97
98
  "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
98
99
  "test:watch": "vitest",
@@ -18,6 +18,7 @@ import type { GestureRecordingState } from "./editor/GestureRecordControl";
18
18
  import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
19
19
  import { StudioAgentTools } from "../webmcp/StudioAgentTools";
20
20
  import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
21
+ import type { TimelineProps } from "../player/components/TimelineTypes";
21
22
  import { Dock } from "./dock/Dock";
22
23
 
23
24
  type RenderClipContent = (
@@ -57,6 +58,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
57
58
  onPasteClip: () => Promise<void>;
58
59
  onDuplicateClip: () => Promise<boolean>;
59
60
  canPasteClip: () => boolean;
61
+ clipMenuItems?: TimelineProps["clipMenuItems"];
60
62
  setCompIdToSrc: (map: Map<string, string>) => void;
61
63
  setCompositionLoading: (loading: boolean) => void;
62
64
  shouldShowMotionPath: boolean;
@@ -105,6 +107,7 @@ export function EditorShell({
105
107
  onPasteClip,
106
108
  onDuplicateClip,
107
109
  canPasteClip,
110
+ clipMenuItems,
108
111
  setCompIdToSrc,
109
112
  setCompositionLoading,
110
113
  shouldShowMotionPath,
@@ -201,6 +204,7 @@ export function EditorShell({
201
204
  onPasteClip={onPasteClip}
202
205
  onDuplicateClip={onDuplicateClip}
203
206
  canPasteClip={canPasteClip}
207
+ clipMenuItems={clipMenuItems}
204
208
  previewOverlay={
205
209
  <PreviewOverlays
206
210
  shouldShowMotionPath={shouldShowMotionPath}
@@ -244,6 +248,7 @@ interface EditorShellBodyProps {
244
248
  onPasteClip: () => Promise<void>;
245
249
  onDuplicateClip: () => Promise<boolean>;
246
250
  canPasteClip: () => boolean;
251
+ clipMenuItems?: TimelineProps["clipMenuItems"];
247
252
  }
248
253
 
249
254
  function EditorShellBody({
@@ -264,6 +269,7 @@ function EditorShellBody({
264
269
  onPasteClip,
265
270
  onDuplicateClip,
266
271
  canPasteClip,
272
+ clipMenuItems,
267
273
  }: EditorShellBodyProps) {
268
274
  const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
269
275
 
@@ -316,6 +322,7 @@ function EditorShellBody({
316
322
  onPasteClip={onPasteClip}
317
323
  onDuplicateClip={onDuplicateClip}
318
324
  canPasteClip={canPasteClip}
325
+ clipMenuItems={clipMenuItems}
319
326
  onSelectTimelineElement={onSelectTimelineElement}
320
327
  timelineFooter={
321
328
  captionEditMode ? (
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import type { DomEditSelection } from "./domEditing";
6
6
  import type { OverlayRect } from "./domEditOverlayGeometry";
7
7
  import { DomEditCropHandles } from "./DomEditCropHandles";
8
+ import { isElementCropLifted } from "./domEditOverlayCrop";
8
9
 
9
10
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
11
 
@@ -55,27 +56,27 @@ function render(
55
56
  return { root, rerender: draw };
56
57
  }
57
58
 
58
- // Regression: the deselect restore used a ref recomputed from RENDER state — on
59
- // a direct A→B selection switch, state re-syncs to B before A's effect cleanup
60
- // runs, so A used to get B's crop string (or lose its crop entirely). The
61
- // restore value must be owned by A's own lift effect / crop gesture.
62
- describe("DomEditCropHandles clip lift/restore", () => {
63
- it("lifts on select and restores the inline clip verbatim on unmount", () => {
59
+ // The lift never touches the element's own clip-path: select+deselect must leave what the author
60
+ // wrote verbatim, and a direct A→B switch must drop A's lift, not B's.
61
+ describe("DomEditCropHandles clip lift", () => {
62
+ it("lifts on select without rewriting the inline clip, and drops the lift on unmount", () => {
64
63
  const a = makeEl("a", "inset(16px round 12px)");
65
64
  const { root } = render(a);
66
- expect(a.style.getPropertyValue("clip-path")).toBe("none");
65
+ expect(isElementCropLifted(a)).toBe(true);
66
+ expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px round 12px)");
67
67
  act(() => root.unmount());
68
+ expect(isElementCropLifted(a)).toBe(false);
68
69
  expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px round 12px)");
69
70
  });
70
71
 
71
- it("restores A's own clip when switching directly to B", () => {
72
+ it("drops A's lift when switching directly to B", () => {
72
73
  const a = makeEl("a", "inset(16px)");
73
74
  const b = makeEl("b", "inset(40px 8px 4px 2px)");
74
75
  const { root, rerender } = render(a);
75
76
  rerender(b);
76
- // A got ITS clip back, not B's (and not removed); B is now lifted.
77
+ expect(isElementCropLifted(a)).toBe(false);
78
+ expect(isElementCropLifted(b)).toBe(true);
77
79
  expect(a.style.getPropertyValue("clip-path")).toBe("inset(16px)");
78
- expect(b.style.getPropertyValue("clip-path")).toBe("none");
79
80
  act(() => root.unmount());
80
81
  expect(b.style.getPropertyValue("clip-path")).toBe("inset(40px 8px 4px 2px)");
81
82
  });
@@ -83,49 +84,11 @@ describe("DomEditCropHandles clip lift/restore", () => {
83
84
  it("never lifts an uneditable clip and leaves it untouched across select/deselect", () => {
84
85
  const a = makeEl("a", "circle(50% at 50% 50%)");
85
86
  const { root } = render(a);
86
- expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
87
+ expect(isElementCropLifted(a)).toBe(false);
87
88
  act(() => root.unmount());
88
89
  expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
89
90
  });
90
91
 
91
- it("re-lifts synchronously after the commit path re-applies the cropped value", async () => {
92
- const a = makeEl("a", "inset(10px)");
93
- let resolveCommit: (() => void) | undefined;
94
- const pendingCommit = new Promise<void>((resolve) => {
95
- resolveCommit = resolve;
96
- });
97
- const onStyleCommit = vi.fn((property: string, value: string) => {
98
- a.style.setProperty(property, value);
99
- return pendingCommit;
100
- });
101
- const { root } = render(a, onStyleCommit);
102
- const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
103
- expect(handle).toBeTruthy();
104
-
105
- act(() =>
106
- handle!.dispatchEvent(
107
- new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 100 }),
108
- ),
109
- );
110
- act(() =>
111
- handle!.dispatchEvent(
112
- new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 80 }),
113
- ),
114
- );
115
- act(() =>
116
- handle!.dispatchEvent(
117
- new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 80 }),
118
- ),
119
- );
120
-
121
- expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
122
- expect(a.style.getPropertyValue("clip-path")).toBe("none");
123
- resolveCommit?.();
124
- await act(async () => pendingCommit);
125
- act(() => root.unmount());
126
- expect(a.style.getPropertyValue("clip-path")).toBe("inset(10px 30px 10px 10px)");
127
- });
128
-
129
92
  it.each([
130
93
  { name: "a crop edge", clip: "inset(10px)", handle: "Crop right", dx: -20, dy: 0 },
131
94
  {
@@ -189,33 +152,38 @@ describe("DomEditCropHandles clip lift/restore", () => {
189
152
  expect(a.style.getPropertyValue("clip-path")).toBe("inset(0px 20px 0px 0px)");
190
153
  });
191
154
 
192
- it("re-lifts when the crop commit rejects", async () => {
155
+ it("commits the dragged crop and stays lifted when the save fails", async () => {
193
156
  const a = makeEl("a", "inset(10px)");
194
157
  const onStyleCommit = vi.fn((property: string, value: string) => {
195
158
  a.style.setProperty(property, value);
196
159
  return Promise.reject(new Error("persist failed"));
197
160
  });
198
161
  const { root } = render(a, onStyleCommit);
199
- const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
200
-
201
- act(() =>
202
- handle!.dispatchEvent(
203
- new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 100 }),
204
- ),
205
- );
206
- act(() =>
207
- handle!.dispatchEvent(
208
- new PointerEvent("pointermove", { bubbles: true, pointerId: 2, clientX: 80 }),
209
- ),
210
- );
211
- await act(async () => {
212
- handle!.dispatchEvent(
213
- new PointerEvent("pointerup", { bubbles: true, pointerId: 2, clientX: 80 }),
162
+ const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
163
+ const press = (type: string, clientX: number) =>
164
+ act(() =>
165
+ handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientX })),
214
166
  );
167
+ press("pointerdown", 100);
168
+ press("pointermove", 80);
169
+ await act(async () => {
170
+ press("pointerup", 80);
215
171
  await Promise.resolve();
216
172
  });
217
173
 
218
- expect(a.style.getPropertyValue("clip-path")).toBe("none");
174
+ expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
175
+ expect(isElementCropLifted(a)).toBe(true);
176
+ act(() => root.unmount());
177
+ });
178
+
179
+ it("draws the crop the element has now, after an undo rewrites it", () => {
180
+ const a = makeEl("a", "inset(0px 20px 0px 0px)");
181
+ const { root, rerender } = render(a);
182
+ const outline = () => document.querySelector<HTMLElement>(".border-dashed")!.style.width;
183
+ expect(outline()).toBe("180px");
184
+ a.style.setProperty("clip-path", "inset(0px 50px 0px 0px)");
185
+ rerender(a);
186
+ expect(outline()).toBe("150px");
219
187
  act(() => root.unmount());
220
188
  });
221
189
  });
@@ -4,6 +4,9 @@ import { type OverlayRect, RESIZE_HANDLE_HIT_PX } from "./domEditOverlayGeometry
4
4
  import {
5
5
  type CropEdge,
6
6
  cropRectFromInsets,
7
+ dropElementCropLift,
8
+ hasCropInsets,
9
+ liftElementCrop,
7
10
  readElementCropFrame,
8
11
  readElementCropInsets,
9
12
  resolveCropInsetFromEdgeDrag,
@@ -11,6 +14,7 @@ import {
11
14
  rotateDeltaIntoFrame,
12
15
  } from "./domEditOverlayCrop";
13
16
  import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
17
+ import { readCropFollowingResize } from "./cropResize";
14
18
 
15
19
  interface CropGestureState {
16
20
  edge: CropEdge | "move";
@@ -19,6 +23,7 @@ interface CropGestureState {
19
23
  startY: number;
20
24
  startInsets: ClipPathInsetSides;
21
25
  insets: ClipPathInsetSides;
26
+ radius: number;
22
27
  /** Element frame captured at gesture start: pointer deltas rotate into it. */
23
28
  angleDeg: number;
24
29
  scaleX: number;
@@ -83,6 +88,7 @@ function repositionHandleSize(rect: Rect): number {
83
88
  }
84
89
 
85
90
  const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
91
+ const NO_CROP = { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
86
92
 
87
93
  /**
88
94
  * Always-on crop, integrated with the selection (no crop "mode"): while a
@@ -92,7 +98,7 @@ const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
92
98
  * grid guides framing); release commits `clip-path: inset(...)` through the
93
99
  * normal style-commit path (one undo step per drag). When cropped, a center
94
100
  * handle pans the crop window. Corners stay free for the selection's own resize
95
- * handle. Leaving the selection restores the committed crop. The clip-path model
101
+ * handle. Leaving the selection drops the lift. The element's clip-path
96
102
  * is the source of truth — nothing here mutates layout.
97
103
  */
98
104
  export function DomEditCropHandles({
@@ -109,54 +115,29 @@ export function DomEditCropHandles({
109
115
  // clip with an inset (or deletes it).
110
116
  const cropStateFor = (element: HTMLElement) => {
111
117
  const parsed = readElementCropInsets(element);
112
- const { radius, ...insets } = parsed ?? { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
113
- return { element, croppable: parsed !== null, insets, radius };
118
+ const { top, right, bottom, left } = parsed ?? NO_CROP;
119
+ return { element, croppable: parsed !== null, insets: { top, right, bottom, left } };
114
120
  };
115
121
  const [state, setState] = useState(() => cropStateFor(selection.element));
116
122
 
117
123
  // Re-sync when the selection targets a different element (reselect, or an
118
- // undo/redo that re-keys the node): read its committed crop before the lift
119
- // effect runs. Read inside the guard so a drag's per-frame setState doesn't
120
- // re-run getComputedStyle every frame.
124
+ // undo/redo that re-keys the node).
121
125
  if (state.element !== selection.element) {
122
126
  setState(cropStateFor(selection.element));
123
127
  }
124
128
 
125
- const hasCrop =
126
- state.insets.top > 0 ||
127
- state.insets.right > 0 ||
128
- state.insets.bottom > 0 ||
129
- state.insets.left > 0;
129
+ // The element's clip-path is the crop; state only holds a crop drag's draft.
130
+ const committed = readCropFollowingResize(selection.element) ?? NO_CROP;
131
+ const insets = dragging ? state.insets : committed;
132
+ const hasCrop = hasCropInsets(insets);
130
133
 
131
134
  // Lift the clip while the element is selected so the full content shows and the
132
- // cropped-away area can be dimmed; restore on deselect. Keyed on the element so
133
- // switching selections restores the previous one. Runs after render, so the
134
- // state re-sync above still reads the element's real committed clip. Restore
135
- // prefers the pre-lift inline value VERBATIM — the rebuilt inset only replaces
136
- // it after a crop gesture actually commits, so a mere select+deselect can
137
- // never reformat (or drop) what the author wrote. Both refs are written only
138
- // by THIS element's lift effect and crop gestures — never derived from render
139
- // state, which by cleanup time already describes the NEXT selection (a direct
140
- // A→B switch re-syncs state to B before A's cleanup runs).
141
- const liftedRef = useRef(false);
142
- const preLiftInlineClipRef = useRef("");
143
- // null = no crop gesture committed this selection; "" = committed a crop
144
- // removal; anything else = the exact committed clip-path value.
145
- const committedClipRef = useRef<string | null>(null);
135
+ // cropped-away area can be dimmed. Keyed on the element so a direct A→B switch drops A's lift.
146
136
  useEffect(() => {
147
137
  const el = selection.element;
148
138
  if (readElementCropInsets(el) === null) return;
149
- preLiftInlineClipRef.current = el.style.getPropertyValue("clip-path");
150
- committedClipRef.current = null;
151
- el.style.setProperty("clip-path", "none");
152
- liftedRef.current = true;
153
- return () => {
154
- liftedRef.current = false;
155
- const committed = committedClipRef.current;
156
- const restore = committed !== null ? committed || null : preLiftInlineClipRef.current || null;
157
- if (restore) el.style.setProperty("clip-path", restore);
158
- else el.style.removeProperty("clip-path");
159
- };
139
+ liftElementCrop(el);
140
+ return () => dropElementCropLift(el);
160
141
  }, [selection.element]);
161
142
 
162
143
  // The crop applies in the element's LOCAL frame (clip-path precedes the
@@ -169,7 +150,7 @@ export function DomEditCropHandles({
169
150
  // Crop rect in FRAME-LOCAL coordinates (origin = frame top-left).
170
151
  const cropRect = cropRectFromInsets(
171
152
  { left: 0, top: 0, width: frame.width, height: frame.height },
172
- state.insets,
153
+ insets,
173
154
  frame.scaleX,
174
155
  frame.scaleY,
175
156
  );
@@ -180,19 +161,23 @@ export function DomEditCropHandles({
180
161
  event.preventDefault();
181
162
  event.stopPropagation();
182
163
  event.currentTarget.setPointerCapture(event.pointerId);
164
+ // Read at press: a resize may have rescaled the crop since the last render.
165
+ const pressed = readCropFollowingResize(selection.element) ?? NO_CROP;
183
166
  gestureRef.current = {
184
167
  edge,
185
168
  pointerId: event.pointerId,
186
169
  startX: event.clientX,
187
170
  startY: event.clientY,
188
- startInsets: state.insets,
189
- insets: state.insets,
171
+ startInsets: pressed,
172
+ insets: pressed,
173
+ radius: pressed.radius,
190
174
  angleDeg: frame.angleDeg,
191
175
  scaleX: frame.scaleX,
192
176
  scaleY: frame.scaleY,
193
177
  };
194
178
  // Clip is already lifted by the selection effect; just flag the drag so the
195
179
  // rule-of-thirds grid shows.
180
+ setState((prev) => ({ ...prev, insets: pressed }));
196
181
  setDragging(true);
197
182
  };
198
183
 
@@ -234,29 +219,12 @@ export function DomEditCropHandles({
234
219
  const finishCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
235
220
  const gesture = endCropGesture(event);
236
221
  if (!gesture) return;
237
- // Commit to the file. The commit path re-applies the value to the live
238
- // element synchronously, so re-lift in the same turn to keep showing the full
239
- // content + dim while selected. Re-lift again on rejection so a failed commit
240
- // still restores crop-mode presentation without an unhandled rejection.
241
- const el = selection.element;
242
- const reLift = () => {
243
- if (liftedRef.current) el.style.setProperty("clip-path", "none");
244
- };
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;
249
- const commit = onStyleCommit?.("clip-path", committedValue);
250
- // handleDomStyleCommit applies the persisted value to the live element
251
- // synchronously before its first await. Restore the crop-mode lift in this
252
- // same turn so the browser never paints that intermediate cropped state.
253
- reLift();
254
- void Promise.resolve(commit).then(() => {
255
- // Only a landed commit makes the rebuilt inset the restore value; a
256
- // failed one keeps restoring the pre-lift clip. Store the value itself —
257
- // by deselect time, render state describes the next selection.
258
- committedClipRef.current = cropped ? committedValue : "";
259
- }, reLift);
222
+ // The commit writes the element's clip-path (and puts it back if the save fails);
223
+ // the lift keeps it hidden while selected. A drag that ends where it started saves nothing.
224
+ const value = buildInsetClipPathSides(gesture.insets, gesture.radius);
225
+ if (value === buildInsetClipPathSides(gesture.startInsets, gesture.radius)) return;
226
+ const commit = onStyleCommit?.("clip-path", value);
227
+ void Promise.resolve(commit).catch(() => undefined);
260
228
  };
261
229
 
262
230
  const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
@@ -697,7 +697,7 @@ describe("resolveDomEditRotationGesture", () => {
697
697
  ).toEqual({ angle: 15 });
698
698
  });
699
699
 
700
- it("allows small pointer movements when the rounded angle changes", () => {
700
+ it("allows small pointer movements and keeps the exact angle", () => {
701
701
  const nextRotation = resolveDomEditRotationGesture({
702
702
  centerX: 0,
703
703
  centerY: 0,
@@ -709,7 +709,7 @@ describe("resolveDomEditRotationGesture", () => {
709
709
  snap: false,
710
710
  });
711
711
 
712
- expect(nextRotation.angle).toBe(1.4);
712
+ expect(nextRotation.angle).toBe(1.432);
713
713
  expect(hasDomEditRotationChanged(0, nextRotation.angle)).toBe(true);
714
714
  expect(hasDomEditRotationChanged(0, 0)).toBe(false);
715
715
  });
@@ -161,8 +161,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
161
161
  const groupGestureRef = useRef<GroupGestureState | null>(null);
162
162
  const blockedMoveRef = useRef<BlockedMoveState | null>(null);
163
163
  const suppressNextBoxClickRef = useRef(false);
164
- const suppressNextBoxMouseDownRef = useRef(false);
165
- const suppressNextOverlayMouseDownRef = useRef(false);
166
164
  const snapGuidesRef = useRef<SnapGuidesState | null>(null);
167
165
  const rafPausedRef = useRef(false);
168
166
 
@@ -329,15 +327,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
329
327
 
330
328
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
331
329
  if (!allowCanvasMovement) return;
332
- if (suppressNextOverlayMouseDownRef.current) {
333
- logSelect("mousedown-suppressed", { shift: event.shiftKey });
334
- suppressNextOverlayMouseDownRef.current = false;
335
- suppressNextBoxMouseDownRef.current = false;
336
- suppressNextBoxClickRef.current = false;
337
- event.preventDefault();
338
- event.stopPropagation();
339
- return;
340
- }
341
330
  const target = event.target as HTMLElement | null;
342
331
  const onBox = Boolean(target?.closest('[data-dom-edit-selection-box="true"]'));
343
332
  logSelect("mousedown", { shift: event.shiftKey, onBox });
@@ -346,10 +335,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
346
335
  // extend beyond the composition rect into the gray zone, and users need
347
336
  // to select/deselect them by clicking there.
348
337
  onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
349
- if (event.shiftKey) {
350
- suppressNextBoxMouseDownRef.current = true;
351
- suppressNextBoxClickRef.current = true;
352
- }
353
338
  };
354
339
 
355
340
  // fallow-ignore-next-line complexity
@@ -373,8 +358,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
373
358
  if (!candidate) return;
374
359
  event.preventDefault();
375
360
  event.stopPropagation();
376
- suppressNextOverlayMouseDownRef.current = true;
377
- suppressNextBoxMouseDownRef.current = true;
378
361
  suppressNextBoxClickRef.current = true;
379
362
  onSelectionChangeRef.current(candidate, { additive: true });
380
363
  return;
@@ -419,7 +402,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
419
402
  // so those elements were always selectable once the band could begin.
420
403
  event.preventDefault();
421
404
  event.stopPropagation();
422
- suppressNextOverlayMouseDownRef.current = true;
423
405
  marquee.begin(event);
424
406
  return;
425
407
  }
@@ -437,13 +419,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
437
419
  onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
438
420
  };
439
421
 
440
- const suppressBoxMouseDown = (e: React.MouseEvent) => {
441
- if (!suppressNextBoxMouseDownRef.current) return;
442
- suppressNextBoxMouseDownRef.current = false;
443
- e.preventDefault();
444
- e.stopPropagation();
445
- };
446
-
447
422
  // Right-click state + handler: select the element under the pointer (if
448
423
  // needed), then open the menu; closes when the selection moves off-target.
449
424
  const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({
@@ -509,7 +484,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
509
484
  allowBodyDrag={bodyDrag}
510
485
  groupCanMove={groupCanMove}
511
486
  gestures={gestures}
512
- onBoxMouseDown={suppressBoxMouseDown}
513
487
  onBoxClick={handleBoxClick}
514
488
  />
515
489
  )}
@@ -528,7 +502,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
528
502
  groupSelectionCount={groupSelections.length}
529
503
  gestures={gestures}
530
504
  onStyleCommit={onStyleCommitRef.current}
531
- onBoxMouseDown={suppressBoxMouseDown}
532
505
  onBoxClick={handleBoxClick}
533
506
  />
534
507
  )}
@@ -56,7 +56,6 @@ describe("DomEditSelectionChrome crop composition", () => {
56
56
  groupSelectionCount={0}
57
57
  gestures={{ startGesture: vi.fn() } as never}
58
58
  onStyleCommit={vi.fn()}
59
- onBoxMouseDown={vi.fn()}
60
59
  onBoxClick={vi.fn()}
61
60
  />,
62
61
  );
@@ -112,7 +111,6 @@ describe("DomEditSelectionChrome crop composition", () => {
112
111
  groupSelectionCount={0}
113
112
  gestures={{ startGesture: vi.fn() } as never}
114
113
  onStyleCommit={vi.fn()}
115
- onBoxMouseDown={vi.fn()}
116
114
  onBoxClick={vi.fn()}
117
115
  />,
118
116
  );
@@ -173,7 +171,6 @@ describe("DomEditSelectionChrome while editing text", () => {
173
171
  groupSelectionCount={0}
174
172
  gestures={{ startGesture: vi.fn() } as never}
175
173
  onStyleCommit={vi.fn()}
176
- onBoxMouseDown={vi.fn()}
177
174
  onBoxClick={vi.fn()}
178
175
  inlineText={{ editing, startFromPress: vi.fn() }}
179
176
  />,
@@ -258,7 +255,6 @@ describe("DomEditSelectionChrome with body drag off", () => {
258
255
  selectionKey="box"
259
256
  groupSelectionCount={0}
260
257
  gestures={gestures as never}
261
- onBoxMouseDown={vi.fn()}
262
258
  onBoxClick={vi.fn()}
263
259
  />,
264
260
  );
@@ -313,7 +309,6 @@ describe("DomEditSelectionChrome with body drag off", () => {
313
309
  allowBodyDrag={false}
314
310
  groupCanMove
315
311
  gestures={gestures as never}
316
- onBoxMouseDown={vi.fn()}
317
312
  onBoxClick={vi.fn()}
318
313
  />,
319
314
  );
@@ -62,7 +62,6 @@ interface DomEditGroupChromeProps {
62
62
  allowBodyDrag: boolean;
63
63
  groupCanMove: boolean;
64
64
  gestures: GestureHandlers;
65
- onBoxMouseDown: (e: React.MouseEvent) => void;
66
65
  onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
67
66
  }
68
67
 
@@ -75,7 +74,6 @@ export function DomEditGroupChrome({
75
74
  allowBodyDrag,
76
75
  groupCanMove,
77
76
  gestures,
78
- onBoxMouseDown,
79
77
  onBoxClick,
80
78
  }: DomEditGroupChromeProps) {
81
79
  const canManipulate = allowCanvasMovement && !usePreviewReadOnly();
@@ -108,7 +106,6 @@ export function DomEditGroupChrome({
108
106
  if (!canManipulate || !allowBodyDrag || (e.shiftKey && !groupCanMove)) return;
109
107
  gestures.startGroupDrag(e);
110
108
  }}
111
- onMouseDown={onBoxMouseDown}
112
109
  onClick={onBoxClick}
113
110
  />
114
111
  </>
@@ -128,7 +125,6 @@ interface DomEditSelectionChromeProps {
128
125
  groupSelectionCount: number;
129
126
  gestures: GestureHandlers;
130
127
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
131
- onBoxMouseDown: (e: React.MouseEvent) => void;
132
128
  onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
133
129
  /** The canvas' text-editing session: what opens one, and whether one is open. */
134
130
  inlineText?: {
@@ -157,7 +153,6 @@ export function DomEditSelectionChrome({
157
153
  groupSelectionCount,
158
154
  gestures,
159
155
  onStyleCommit,
160
- onBoxMouseDown,
161
156
  onBoxClick,
162
157
  inlineText,
163
158
  }: DomEditSelectionChromeProps) {
@@ -225,7 +220,6 @@ export function DomEditSelectionChrome({
225
220
  }
226
221
  if (!e.shiftKey) gestures.startBlockedMove(e, selection);
227
222
  }}
228
- onMouseDown={onBoxMouseDown}
229
223
  onClick={onBoxClick}
230
224
  >
231
225
  {cropOutlineInsetPx && (