@hyperframes/studio 0.8.93 → 0.8.95

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-CkV9KK6A.js → hyperframes-player-DJJKab4_.js} +1 -1
  2. package/dist/assets/{index-V3fzEfOD.js → index-C1dXTZvs.js} +1 -1
  3. package/dist/assets/{index-CYk0NwSv.js → index-Cc4by8r7.js} +183 -183
  4. package/dist/assets/{index-BlLFipOk.js → index-DE1dn3QB.js} +1 -1
  5. package/dist/index.d.ts +63 -46
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +196 -111
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/TimelineToolbar.test.tsx +17 -0
  11. package/src/components/TimelineToolbar.tsx +3 -1
  12. package/src/components/editor/ConnectedDomEditOverlay.test.tsx +2 -0
  13. package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -1
  14. package/src/components/editor/DomEditCropHandles.test.tsx +87 -1
  15. package/src/components/editor/DomEditCropHandles.tsx +64 -62
  16. package/src/components/editor/DomEditOverlay.test.ts +39 -0
  17. package/src/components/editor/DomEditOverlay.tsx +6 -1
  18. package/src/components/editor/DomEditSelectionChrome.test.tsx +114 -1
  19. package/src/components/editor/DomEditSelectionChrome.tsx +17 -10
  20. package/src/components/editor/PreviewOverlayProvider.test.tsx +19 -0
  21. package/src/components/editor/PreviewOverlayProvider.tsx +1 -1
  22. package/src/components/editor/SnapToolbar.tsx +1 -1
  23. package/src/components/editor/domEditOverlayGeometry.ts +2 -0
  24. package/src/components/editor/manualEditsSeekReapply.ts +5 -1
  25. package/src/components/nle/PreviewOverlays.tsx +3 -3
  26. package/src/hooks/gsapResizeIntercept.test.ts +40 -0
  27. package/src/hooks/gsapResizeIntercept.ts +8 -4
  28. package/src/index.ts +4 -0
  29. package/src/player/components/PlayerControls.tsx +2 -15
  30. package/src/player/components/ShortcutsPanel.test.tsx +33 -1
  31. package/src/player/components/ShortcutsPanel.tsx +26 -0
  32. package/src/player/components/Timeline.ghostLane.test.tsx +126 -0
  33. package/src/player/components/Timeline.rulerSeek.test.tsx +7 -0
  34. package/src/player/components/Timeline.test.ts +3 -3
  35. package/src/player/components/Timeline.trackPadding.test.tsx +157 -0
  36. package/src/player/components/TimelineCanvas.tsx +44 -19
  37. package/src/player/components/TimelineProvider.tsx +1 -0
  38. package/src/player/components/TimelineTypes.ts +2 -0
  39. package/src/player/components/timelineDragDrop.test.tsx +14 -12
  40. package/src/player/components/timelineLayout.test.ts +87 -11
  41. package/src/player/components/timelineLayout.ts +28 -21
  42. package/src/player/components/useTimelineProviderState.tsx +5 -1
  43. package/src/player/components/useTimelineTrackLayout.ts +11 -2
  44. package/src/player/components/useTimelineVirtualRows.test.tsx +32 -0
  45. package/src/player/components/useTimelineVirtualRows.ts +2 -2
  46. package/src/utils/gsapSoftReload.test.ts +51 -2
  47. package/src/utils/gsapSoftReload.ts +24 -10
  48. package/src/utils/gsapUndoRestore.test.ts +49 -0
  49. package/src/utils/gsapUndoRestore.ts +15 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.93",
3
+ "version": "0.8.95",
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.93",
64
- "@hyperframes/shader-transitions": "0.8.93",
65
- "@hyperframes/studio-server": "0.8.93",
66
- "@hyperframes/parsers": "0.8.93",
67
- "@hyperframes/player": "0.8.93",
68
- "@hyperframes/sdk": "0.8.93"
63
+ "@hyperframes/core": "0.8.95",
64
+ "@hyperframes/parsers": "0.8.95",
65
+ "@hyperframes/sdk": "0.8.95",
66
+ "@hyperframes/player": "0.8.95",
67
+ "@hyperframes/studio-server": "0.8.95",
68
+ "@hyperframes/shader-transitions": "0.8.95"
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.93"
84
+ "@hyperframes/producer": "0.8.95"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -278,6 +278,23 @@ describe("TimelineToolbar audio meters", () => {
278
278
  });
279
279
  });
280
280
 
281
+ describe("TimelineToolbar history", () => {
282
+ const historyButtons = (host: HTMLElement) =>
283
+ host.querySelectorAll('button[aria-label="Undo"], button[aria-label="Redo"]').length;
284
+
285
+ it("shows Undo and Redo by default", () => {
286
+ const { host, root } = renderToolbar();
287
+ expect(historyButtons(host)).toBe(2);
288
+ act(() => root.unmount());
289
+ });
290
+
291
+ it("renders neither when the host hides history", () => {
292
+ const { host, root } = renderToolbar(undefined, { showHistory: false });
293
+ expect(historyButtons(host)).toBe(0);
294
+ act(() => root.unmount());
295
+ });
296
+ });
297
+
281
298
  describe("TimelineToolbar add beat", () => {
282
299
  it("shows Add beat by default, as Studio does", () => {
283
300
  const { host, root } = renderToolbar();
@@ -43,6 +43,7 @@ export interface TimelineToolbarProps {
43
43
  domEditSession?: DomEditSessionSlice;
44
44
  onSplitElement?: (element: TimelineElement, splitTime: number) => void;
45
45
  history?: TimelineHistoryButtonsProps;
46
+ showHistory?: boolean;
46
47
  showAddBeat?: boolean;
47
48
  /** Hides Add keyframe and auto-record, and turns off auto-record and the K shortcut with them. */
48
49
  showKeyframes?: boolean;
@@ -141,6 +142,7 @@ export function TimelineToolbar({
141
142
  domEditSession,
142
143
  onSplitElement,
143
144
  history,
145
+ showHistory = true,
144
146
  showAddBeat = true,
145
147
  showKeyframes = true,
146
148
  }: TimelineToolbarProps) {
@@ -213,7 +215,7 @@ export function TimelineToolbar({
213
215
  <div className="border-b border-neutral-800/60">
214
216
  <div className="flex items-center justify-between px-2 py-0.5">
215
217
  <div className="flex items-center gap-0.5">
216
- <TimelineHistoryButtons {...history} />
218
+ {showHistory && <TimelineHistoryButtons {...history} />}
217
219
  <TimelineToolPicker />
218
220
  {/* Divider: tool-mode | editing-actions */}
219
221
  <div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
@@ -149,11 +149,13 @@ describe("ConnectedDomEditOverlay", () => {
149
149
  showHoverSelection={false}
150
150
  shouldShowSelectedDomBounds={true}
151
151
  canvasInput="host"
152
+ allowBodyDrag={false}
152
153
  onTextEditingChange={onTextEditingChange}
153
154
  />,
154
155
  );
155
156
  expect(props).toMatchObject({
156
157
  canvasInput: "host",
158
+ allowBodyDrag: false,
157
159
  onTextEditingChange,
158
160
  hoverSelection: null,
159
161
  selection: ctx.selection.domEditSelection,
@@ -13,7 +13,7 @@ import { runZLaneGesture } from "../nle/zLaneGesture";
13
13
 
14
14
  type HostInputProps = Pick<
15
15
  ComponentProps<typeof DomEditOverlay>,
16
- "canvasInput" | "onSelectionBoxClick"
16
+ "canvasInput" | "onSelectionBoxClick" | "allowBodyDrag"
17
17
  >;
18
18
 
19
19
  export interface ConnectedDomEditOverlayProps extends HostInputProps {
@@ -121,6 +121,7 @@ export function ConnectedDomEditOverlay({
121
121
  shouldShowSelectedDomBounds,
122
122
  isGestureRecording,
123
123
  canvasInput,
124
+ allowBodyDrag,
124
125
  onTextEditingChange,
125
126
  onSelectionBoxClick,
126
127
  }: ConnectedDomEditOverlayProps) {
@@ -154,6 +155,7 @@ export function ConnectedDomEditOverlay({
154
155
  groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
155
156
  allowCanvasMovement={!isGestureRecording}
156
157
  canvasInput={canvasInput}
158
+ allowBodyDrag={allowBodyDrag}
157
159
  onTextEditingChange={onTextEditingChange}
158
160
  onSelectionBoxClick={onSelectionBoxClick}
159
161
  onCanvasMouseDown={handlePreviewCanvasMouseDown}
@@ -36,6 +36,7 @@ function makeEl(id: string, clip: string): HTMLElement {
36
36
  function render(
37
37
  el: HTMLElement,
38
38
  onStyleCommit: (property: string, value: string) => Promise<unknown> | void = () => undefined,
39
+ rect: OverlayRect = overlayRect,
39
40
  ): { root: Root; rerender: (next: HTMLElement) => void } {
40
41
  const host = document.createElement("div");
41
42
  document.body.append(host);
@@ -45,7 +46,7 @@ function render(
45
46
  root.render(
46
47
  <DomEditCropHandles
47
48
  selection={selectionFor(target)}
48
- overlayRect={overlayRect}
49
+ overlayRect={rect}
49
50
  onStyleCommit={onStyleCommit}
50
51
  />,
51
52
  );
@@ -155,3 +156,88 @@ describe("DomEditCropHandles clip lift/restore", () => {
155
156
  act(() => root.unmount());
156
157
  });
157
158
  });
159
+
160
+ describe("DomEditCropHandles leaves the corner resize dots free", () => {
161
+ type Box = { left: number; top: number; width: number; height: number };
162
+ const HIT = 16;
163
+ const cornerSquares = (r: Box): Box[] =>
164
+ [
165
+ [r.left, r.top],
166
+ [r.left + r.width, r.top],
167
+ [r.left, r.top + r.height],
168
+ [r.left + r.width, r.top + r.height],
169
+ ].map(([x, y]) => ({ left: x - HIT / 2, top: y - HIT / 2, width: HIT, height: HIT }));
170
+ const overlaps = (a: Box, b: Box) =>
171
+ a.left < b.left + b.width &&
172
+ b.left < a.left + a.width &&
173
+ a.top < b.top + b.height &&
174
+ b.top < a.top + a.height;
175
+ const handles = () =>
176
+ [...document.querySelectorAll<HTMLElement>("[data-dom-edit-crop-handle]")].map((el) => ({
177
+ label: el.getAttribute("aria-label"),
178
+ box: {
179
+ left: parseFloat(el.style.left),
180
+ top: parseFloat(el.style.top),
181
+ width: parseFloat(el.style.width),
182
+ height: parseFloat(el.style.height),
183
+ },
184
+ }));
185
+ const rectOf = (width: number, height: number): OverlayRect => ({
186
+ left: 0,
187
+ top: 0,
188
+ width,
189
+ height,
190
+ editScaleX: 1,
191
+ editScaleY: 1,
192
+ });
193
+
194
+ it.each([
195
+ {
196
+ name: "29x14",
197
+ size: [29, 14],
198
+ clip: "",
199
+ cropped: { left: 0, top: 0, width: 29, height: 14 },
200
+ },
201
+ {
202
+ name: "40x40",
203
+ size: [40, 40],
204
+ clip: "",
205
+ cropped: { left: 0, top: 0, width: 40, height: 40 },
206
+ },
207
+ {
208
+ name: "29x14",
209
+ size: [29, 14],
210
+ clip: "inset(2px)",
211
+ cropped: { left: 2, top: 2, width: 25, height: 10 },
212
+ },
213
+ {
214
+ name: "200x100",
215
+ size: [200, 100],
216
+ clip: "inset(10px)",
217
+ cropped: { left: 10, top: 10, width: 180, height: 80 },
218
+ },
219
+ ])("no crop handle covers a corner of a $name box clipped '$clip'", (c) => {
220
+ render(makeEl("a", c.clip), undefined, rectOf(c.size[0], c.size[1]));
221
+ const found = handles();
222
+ expect(found.length).toBeGreaterThan(0);
223
+ for (const handle of found) {
224
+ for (const corner of cornerSquares(c.cropped)) {
225
+ expect(overlaps(handle.box, corner), `${handle.label} covers a corner`).toBe(false);
226
+ }
227
+ }
228
+ });
229
+
230
+ it("keeps every handle where the edges have room, and drops only those with none", () => {
231
+ render(makeEl("a", "inset(10px)"), undefined, rectOf(200, 100));
232
+ expect(handles().map((h) => h.label)).toEqual([
233
+ "Reposition crop",
234
+ "Crop top",
235
+ "Crop right",
236
+ "Crop bottom",
237
+ "Crop left",
238
+ ]);
239
+ document.body.innerHTML = "";
240
+ render(makeEl("b", ""), undefined, rectOf(29, 14));
241
+ expect(handles().map((h) => h.label)).toEqual(["Crop top", "Crop bottom"]);
242
+ });
243
+ });
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
2
2
  import type { DomEditSelection } from "./domEditing";
3
- import type { OverlayRect } from "./domEditOverlayGeometry";
3
+ import { type OverlayRect, RESIZE_HANDLE_HIT_PX } from "./domEditOverlayGeometry";
4
4
  import {
5
5
  type CropEdge,
6
6
  cropRectFromInsets,
@@ -37,47 +37,53 @@ interface DomEditCropHandlesProps {
37
37
  // and centered inside the strip.
38
38
  const EDGE_HIT_THICKNESS = 12;
39
39
  const EDGE_HIT_LENGTH = 32;
40
+ const EDGE_PILL_LENGTH = 24;
41
+ const REPOSITION_HANDLE_PX = 22;
40
42
 
41
- /** Place an edge handle's hit strip just OUTSIDE the given crop edge
42
- * (translate pushes it fully past the boundary). Keeps the element body free
43
- * for moving. Corners stay free for the selection's own resize handles. */
44
- function edgeHandlePlacement(
45
- edge: CropEdge,
46
- rect: { left: number; top: number; width: number; height: number },
47
- ) {
48
- const cx = rect.left + rect.width / 2;
49
- const cy = rect.top + rect.height / 2;
50
- if (edge === "top") {
51
- return { left: cx, top: rect.top, transform: "translate(-50%, -100%)" };
52
- }
53
- if (edge === "bottom") {
54
- return { left: cx, top: rect.top + rect.height, transform: "translate(-50%, 0)" };
55
- }
56
- if (edge === "left") {
57
- return { left: rect.left, top: cy, transform: "translate(-100%, -50%)" };
58
- }
59
- return { left: rect.left + rect.width, top: cy, transform: "translate(0, -50%)" };
60
- }
43
+ type Rect = { left: number; top: number; width: number; height: number };
61
44
 
62
- const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
63
-
64
- /** Hit-strip + pill dimensions for an edge handle, keyed on its orientation. */
65
- function edgeHandleMetrics(vertical: boolean): {
66
- hitWidth: number;
67
- hitHeight: number;
68
- cursor: string;
69
- pillWidth: number;
70
- pillHeight: number;
71
- } {
45
+ /** An edge handle's hit strip, just OUTSIDE the crop edge so the body stays free
46
+ * for moving, and short of the corner squares the resize dots own, with its
47
+ * cursor and pill. Null when the edge is too short to leave any room. */
48
+ function edgeHandleLayout(edge: CropEdge, rect: Rect) {
49
+ const vertical = edge === "left" || edge === "right";
50
+ const length = Math.min(
51
+ EDGE_HIT_LENGTH,
52
+ (vertical ? rect.height : rect.width) - RESIZE_HANDLE_HIT_PX,
53
+ );
54
+ if (length <= 0) return null;
55
+ const pill = Math.min(EDGE_PILL_LENGTH, length);
56
+ if (vertical) {
57
+ return {
58
+ hit: {
59
+ left: edge === "left" ? rect.left - EDGE_HIT_THICKNESS : rect.left + rect.width,
60
+ top: rect.top + (rect.height - length) / 2,
61
+ width: EDGE_HIT_THICKNESS,
62
+ height: length,
63
+ },
64
+ cursor: "ew-resize",
65
+ pill: { width: 4, height: pill },
66
+ };
67
+ }
72
68
  return {
73
- hitWidth: vertical ? EDGE_HIT_THICKNESS : EDGE_HIT_LENGTH,
74
- hitHeight: vertical ? EDGE_HIT_LENGTH : EDGE_HIT_THICKNESS,
75
- cursor: vertical ? "ew-resize" : "ns-resize",
76
- pillWidth: vertical ? 4 : 24,
77
- pillHeight: vertical ? 24 : 4,
69
+ hit: {
70
+ left: rect.left + (rect.width - length) / 2,
71
+ top: edge === "top" ? rect.top - EDGE_HIT_THICKNESS : rect.top + rect.height,
72
+ width: length,
73
+ height: EDGE_HIT_THICKNESS,
74
+ },
75
+ cursor: "ns-resize",
76
+ pill: { width: pill, height: 4 },
78
77
  };
79
78
  }
80
79
 
80
+ /** The reposition handle's size: shrunk until its box clears the corner squares. */
81
+ function repositionHandleSize(rect: Rect): number {
82
+ return Math.min(REPOSITION_HANDLE_PX, Math.max(rect.width, rect.height) - RESIZE_HANDLE_HIT_PX);
83
+ }
84
+
85
+ const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
86
+
81
87
  /**
82
88
  * Always-on crop, integrated with the selection (no crop "mode"): while a
83
89
  * croppable element is selected its clip is lifted so the FULL content shows and
@@ -167,6 +173,7 @@ export function DomEditCropHandles({
167
173
  frame.scaleX,
168
174
  frame.scaleY,
169
175
  );
176
+ const repositionSize = repositionHandleSize(cropRect);
170
177
 
171
178
  const startCropGesture = (edge: CropEdge | "move", event: ReactPointerEvent<HTMLElement>) => {
172
179
  if (!onStyleCommit) return;
@@ -214,13 +221,19 @@ export function DomEditCropHandles({
214
221
  setState((prev) => ({ ...prev, insets: nextInsets }));
215
222
  };
216
223
 
217
- const finishCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
224
+ const endCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
218
225
  const gesture = gestureRef.current;
219
- if (!gesture || gesture.pointerId !== event.pointerId) return;
226
+ if (!gesture || gesture.pointerId !== event.pointerId) return null;
220
227
  event.preventDefault();
221
228
  event.stopPropagation();
222
229
  gestureRef.current = null;
223
230
  setDragging(false);
231
+ return gesture;
232
+ };
233
+
234
+ const finishCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
235
+ const gesture = endCropGesture(event);
236
+ if (!gesture) return;
224
237
  if (!gesture.didMove) return;
225
238
  // Commit to the file. The commit path re-applies the value to the live
226
239
  // element synchronously, so re-lift in the same turn to keep showing the full
@@ -250,12 +263,8 @@ export function DomEditCropHandles({
250
263
  };
251
264
 
252
265
  const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
253
- const gesture = gestureRef.current;
254
- if (!gesture || gesture.pointerId !== event.pointerId) return;
255
- event.preventDefault();
256
- event.stopPropagation();
257
- gestureRef.current = null;
258
- setDragging(false);
266
+ const gesture = endCropGesture(event);
267
+ if (!gesture) return;
259
268
  // Clip stays lifted; the dim follows the reset insets.
260
269
  setState((prev) => ({ ...prev, insets: gesture.startInsets }));
261
270
  };
@@ -317,7 +326,7 @@ export function DomEditCropHandles({
317
326
  {/* Reposition handle — a center circle shown only once cropped. Drag it to
318
327
  pan the crop window (which part of the element shows) without resizing
319
328
  the crop. It's a small, discrete target, so a body drag still MOVES. */}
320
- {hasCrop && (
329
+ {hasCrop && repositionSize > 0 && (
321
330
  <button
322
331
  type="button"
323
332
  aria-label="Reposition crop"
@@ -325,11 +334,10 @@ export function DomEditCropHandles({
325
334
  data-dom-edit-crop-handle="true"
326
335
  className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
327
336
  style={{
328
- left: cropRect.left + cropRect.width / 2,
329
- top: cropRect.top + cropRect.height / 2,
330
- width: 22,
331
- height: 22,
332
- transform: "translate(-50%, -50%)",
337
+ left: cropRect.left + (cropRect.width - repositionSize) / 2,
338
+ top: cropRect.top + (cropRect.height - repositionSize) / 2,
339
+ width: repositionSize,
340
+ height: repositionSize,
333
341
  cursor: "move",
334
342
  touchAction: "none",
335
343
  }}
@@ -340,16 +348,15 @@ export function DomEditCropHandles({
340
348
  />
341
349
  )}
342
350
  {/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
343
- edge (via edgeHandlePlacement) so they never overlap the element body:
351
+ edge (via edgeHandleLayout) so they never overlap the element body:
344
352
  dragging the body always MOVES, only a handle crops. The pill is
345
353
  hover-revealed (or shown while dragging / once a crop exists) so the
346
354
  resting selection chrome stays uncluttered; the hit strip is always
347
355
  live, and the title names the affordance. */}
348
356
  {EDGES.map((edge) => {
349
- const vertical = edge === "left" || edge === "right";
350
- const place = edgeHandlePlacement(edge, cropRect);
357
+ const layout = edgeHandleLayout(edge, cropRect);
358
+ if (!layout) return null;
351
359
  const revealed = dragging || hasCrop || hotEdge === edge;
352
- const m = edgeHandleMetrics(vertical);
353
360
  return (
354
361
  <button
355
362
  key={edge}
@@ -359,12 +366,8 @@ export function DomEditCropHandles({
359
366
  data-dom-edit-crop-handle="true"
360
367
  className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
361
368
  style={{
362
- left: place.left,
363
- top: place.top,
364
- width: m.hitWidth,
365
- height: m.hitHeight,
366
- transform: place.transform,
367
- cursor: m.cursor,
369
+ ...layout.hit,
370
+ cursor: layout.cursor,
368
371
  touchAction: "none",
369
372
  }}
370
373
  onPointerEnter={() => setHotEdge(edge)}
@@ -377,8 +380,7 @@ export function DomEditCropHandles({
377
380
  <span
378
381
  className="pointer-events-none rounded-full bg-studio-accent/90 shadow-[0_0_0_1px_rgba(0,0,0,0.4)] transition-opacity duration-100"
379
382
  style={{
380
- width: m.pillWidth,
381
- height: m.pillHeight,
383
+ ...layout.pill,
382
384
  opacity: revealed ? 1 : 0,
383
385
  }}
384
386
  />
@@ -485,6 +485,45 @@ describe("DomEditOverlay", () => {
485
485
  host.remove();
486
486
  });
487
487
 
488
+ it.each([
489
+ { canvasInput: "overlay" as const, drags: true },
490
+ { canvasInput: "host" as const, drags: false },
491
+ ])("with body drag off, a $canvasInput canvas drags the body: $drags", async (c) => {
492
+ const restoreRect = stubViewportRect();
493
+ const host = document.createElement("div");
494
+ document.body.append(host);
495
+ const root = createRoot(host);
496
+ const selection = makeDomEditSelection();
497
+ const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
498
+ const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
499
+ HTMLDivElement.prototype.setPointerCapture = () => {};
500
+ gestureSpies.startGesture.mockClear();
501
+
502
+ act(() => {
503
+ root.render(
504
+ React.createElement(DomEditOverlay, {
505
+ ...createOverlayProps({ iframeRef, selection, hoverSelection: null }),
506
+ allowBodyDrag: false,
507
+ canvasInput: c.canvasInput,
508
+ }),
509
+ );
510
+ });
511
+ await flushOverlayRaf();
512
+ const selectionBox = host.querySelector(
513
+ '[data-dom-edit-selection-box="true"]',
514
+ ) as HTMLDivElement;
515
+ dispatchOverlayPointerDown(selectionBox);
516
+
517
+ expect(gestureSpies.startGesture.mock.calls.some((call) => call[0] === "drag")).toBe(c.drags);
518
+
519
+ act(() => {
520
+ root.unmount();
521
+ });
522
+ HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
523
+ restoreRect();
524
+ host.remove();
525
+ });
526
+
488
527
  it("passes the tracked hover selection when clicking the existing selection box", async () => {
489
528
  const restoreRect = stubViewportRect();
490
529
 
@@ -54,7 +54,8 @@ export interface DomEditOverlayProps {
54
54
  groupSelections?: DomEditSelection[];
55
55
  hoverSelection: DomEditSelection | null;
56
56
  allowCanvasMovement?: boolean;
57
- /** "host": no hover, marquee or box re-select; Enter with nothing focused still opens text. */
57
+ allowBodyDrag?: boolean;
58
+ /** "host": no hover, marquee, box re-select, or body drag if allowBodyDrag is false; Enter still opens text. */
58
59
  canvasInput?: "overlay" | "host";
59
60
  onTextEditingChange?: (editing: boolean) => void;
60
61
  /** A click on a single selection's box, in either mode; the event may be the pointerup. */
@@ -128,6 +129,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
128
129
  groupSelections = [],
129
130
  hoverSelection,
130
131
  allowCanvasMovement = true,
132
+ allowBodyDrag = true,
131
133
  canvasInput = "overlay",
132
134
  onTextEditingChange,
133
135
  onSelectionBoxClick,
@@ -148,6 +150,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
148
150
  }: DomEditOverlayProps) {
149
151
  const readOnly = usePreviewReadOnly();
150
152
  const hostInput = canvasInput === "host";
153
+ const bodyDrag = allowBodyDrag || !hostInput;
151
154
  const overlayRef = useRef<HTMLDivElement | null>(null);
152
155
  const boxRef = useRef<HTMLDivElement | null>(null);
153
156
  const onMarqueeSelectRef = useRef(onMarqueeSelect);
@@ -503,6 +506,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
503
506
  groupOverlayItems={groupOverlayItems}
504
507
  groupBounds={groupBounds}
505
508
  allowCanvasMovement={allowCanvasMovement}
509
+ allowBodyDrag={bodyDrag}
506
510
  groupCanMove={groupCanMove}
507
511
  gestures={gestures}
508
512
  onBoxMouseDown={suppressBoxMouseDown}
@@ -515,6 +519,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
515
519
  selection={selection}
516
520
  overlayRect={overlayRect}
517
521
  allowCanvasMovement={allowCanvasMovement}
522
+ allowBodyDrag={bodyDrag}
518
523
  cropOutlineInsetPx={cropOutlineInsetPx ?? undefined}
519
524
  boxRef={boxRef}
520
525
  boxChromeClass={boxChromeClass}
@@ -4,7 +4,7 @@ import React, { act, createRef } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
  import type { DomEditSelection } from "./domEditing";
7
- import { DomEditSelectionChrome } from "./DomEditSelectionChrome";
7
+ import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
8
8
 
9
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
10
 
@@ -48,6 +48,7 @@ describe("DomEditSelectionChrome crop composition", () => {
48
48
  selection={selection}
49
49
  overlayRect={{ left: 44, top: 52, width: 220, height: 48, editScaleX: 1, editScaleY: 1 }}
50
50
  allowCanvasMovement={false}
51
+ allowBodyDrag
51
52
  boxRef={createRef()}
52
53
  boxChromeClass="border border-studio-accent/80"
53
54
  boxClipPath={undefined}
@@ -103,6 +104,7 @@ describe("DomEditSelectionChrome crop composition", () => {
103
104
  angle: 30,
104
105
  }}
105
106
  allowCanvasMovement={true}
107
+ allowBodyDrag
106
108
  boxRef={createRef()}
107
109
  boxChromeClass=""
108
110
  boxClipPath={undefined}
@@ -163,6 +165,7 @@ describe("DomEditSelectionChrome while editing text", () => {
163
165
  selection={selection}
164
166
  overlayRect={{ left: 10, top: 20, width: 200, height: 60, editScaleX: 1, editScaleY: 1 }}
165
167
  allowCanvasMovement={true}
168
+ allowBodyDrag
166
169
  boxRef={createRef()}
167
170
  boxChromeClass="border border-studio-accent/80"
168
171
  boxClipPath={undefined}
@@ -214,3 +217,113 @@ describe("DomEditSelectionChrome while editing text", () => {
214
217
  unmount();
215
218
  });
216
219
  });
220
+
221
+ describe("DomEditSelectionChrome with body drag off", () => {
222
+ const rect = { left: 10, top: 10, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
223
+ const gestureSpies = () => ({
224
+ startGesture: vi.fn(),
225
+ startGroupDrag: vi.fn(),
226
+ startBlockedMove: vi.fn(),
227
+ });
228
+ const press = (el: Element) => {
229
+ const event = new PointerEvent("pointerdown", {
230
+ bubbles: true,
231
+ cancelable: true,
232
+ button: 0,
233
+ pointerId: 1,
234
+ });
235
+ act(() => {
236
+ el.dispatchEvent(event);
237
+ });
238
+ return event;
239
+ };
240
+
241
+ function renderChrome(allowBodyDrag: boolean) {
242
+ const element = document.createElement("div");
243
+ document.body.append(element);
244
+ const { selection, host, root } = selectionFixture(element, "#box", true);
245
+ const hostPress = vi.fn();
246
+ host.addEventListener("pointerdown", hostPress);
247
+ const gestures = gestureSpies();
248
+ act(() => {
249
+ root.render(
250
+ <DomEditSelectionChrome
251
+ selection={selection}
252
+ overlayRect={rect}
253
+ allowCanvasMovement
254
+ allowBodyDrag={allowBodyDrag}
255
+ boxRef={createRef()}
256
+ boxChromeClass=""
257
+ boxClipPath={undefined}
258
+ selectionKey="box"
259
+ groupSelectionCount={0}
260
+ gestures={gestures as never}
261
+ onBoxMouseDown={vi.fn()}
262
+ onBoxClick={vi.fn()}
263
+ />,
264
+ );
265
+ });
266
+ const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
267
+ return { host, box, gestures, hostPress, cleanup: () => act(() => root.unmount()) };
268
+ }
269
+
270
+ it("leaves a body press untouched for the host: no drag, no capture, no cursor", () => {
271
+ const { box, gestures, hostPress, cleanup } = renderChrome(false);
272
+ const event = press(box);
273
+ expect(hostPress).toHaveBeenCalledTimes(1);
274
+ expect(event.defaultPrevented).toBe(false);
275
+ expect(box.hasPointerCapture(1)).toBe(false);
276
+ expect(gestures.startGesture).not.toHaveBeenCalled();
277
+ expect(gestures.startBlockedMove).not.toHaveBeenCalled();
278
+ expect(box.style.cursor).toBe("");
279
+ cleanup();
280
+ });
281
+
282
+ it("keeps the resize and rotate handles working", () => {
283
+ const { host, gestures, cleanup } = renderChrome(false);
284
+ const corner = host.querySelector<HTMLElement>('[style*="nwse-resize"]')!;
285
+ press(corner);
286
+ expect(gestures.startGesture).toHaveBeenCalledWith("resize", expect.anything(), {
287
+ resizeHandle: "nw",
288
+ });
289
+ press(host.querySelector('[aria-label="Rotate selection"]')!);
290
+ expect(gestures.startGesture).toHaveBeenCalledWith("rotate", expect.anything());
291
+ cleanup();
292
+ });
293
+
294
+ it("still drags the body by default", () => {
295
+ const { box, gestures, cleanup } = renderChrome(true);
296
+ press(box);
297
+ expect(gestures.startGesture).toHaveBeenCalledWith("drag", expect.anything());
298
+ expect(box.style.cursor).toBe("move");
299
+ cleanup();
300
+ });
301
+
302
+ it("leaves a group body press untouched too", () => {
303
+ const { host, root } = selectionFixture(document.createElement("div"), "#g", true);
304
+ const hostPress = vi.fn();
305
+ host.addEventListener("pointerdown", hostPress);
306
+ const gestures = gestureSpies();
307
+ act(() => {
308
+ root.render(
309
+ <DomEditGroupChrome
310
+ groupOverlayItems={[]}
311
+ groupBounds={rect}
312
+ allowCanvasMovement
313
+ allowBodyDrag={false}
314
+ groupCanMove
315
+ gestures={gestures as never}
316
+ onBoxMouseDown={vi.fn()}
317
+ onBoxClick={vi.fn()}
318
+ />,
319
+ );
320
+ });
321
+ const groupBox = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
322
+ const event = press(groupBox);
323
+ expect(groupBox.style.cursor).toBe("");
324
+ expect(hostPress).toHaveBeenCalledTimes(1);
325
+ expect(event.defaultPrevented).toBe(false);
326
+ expect(gestures.startGroupDrag).not.toHaveBeenCalled();
327
+ act(() => root.unmount());
328
+ });
329
+ });