@hyperframes/studio 0.7.102 → 0.7.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/assets/{hyperframes-player-BTnkvhf3.js → hyperframes-player-Bj2HAymq.js} +1 -1
  2. package/dist/assets/{index-CnrSn9Ua.js → index-8UzO6PkK.js} +1 -1
  3. package/dist/assets/{index-DN614u1l.js → index-DzfDa1ci.js} +212 -212
  4. package/dist/assets/{index-B4MocVKM.js → index-TDfRaO1Y.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +779 -294
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +6 -2
  10. package/src/captions/hooks/useCaptionSync.ts +2 -1
  11. package/src/components/editor/DomEditOverlay.test.ts +89 -2
  12. package/src/components/editor/DomEditOverlay.tsx +40 -23
  13. package/src/components/editor/domEditOverlayGeometry.test.ts +94 -0
  14. package/src/components/editor/domEditOverlayGeometry.ts +59 -2
  15. package/src/components/editor/domEditOverlayGestures.ts +42 -0
  16. package/src/components/editor/domEditOverlayStartGesture.ts +17 -0
  17. package/src/components/editor/groupDragMove.ts +110 -0
  18. package/src/components/editor/groupDropKeepsSelection.test.ts +70 -0
  19. package/src/components/editor/manualEditsDom.ts +15 -13
  20. package/src/components/editor/manualOffsetDrag.test.ts +92 -5
  21. package/src/components/editor/manualOffsetDrag.ts +22 -17
  22. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +64 -0
  23. package/src/components/editor/snapEngageTravel.test.ts +65 -0
  24. package/src/components/editor/snapEngine.ts +21 -1
  25. package/src/components/editor/useDomEditOverlayGestures.ts +40 -53
  26. package/src/components/editor/useDomEditOverlayRects.ts +4 -2
  27. package/src/hooks/domSelectionTimelineMirror.ts +73 -0
  28. package/src/hooks/gestureTransaction.test.ts +7 -12
  29. package/src/hooks/gestureTransaction.ts +7 -20
  30. package/src/hooks/gsapRuntimePatch.test.ts +43 -0
  31. package/src/hooks/gsapRuntimePatch.ts +11 -1
  32. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  33. package/src/hooks/gsapScriptCommitTypes.ts +20 -0
  34. package/src/hooks/keyframeCacheAstLoad.test.ts +108 -0
  35. package/src/hooks/keyframeCacheAstLoad.ts +29 -1
  36. package/src/hooks/timelineTimingSync.ts +4 -1
  37. package/src/hooks/useDomEditCommits.ts +2 -1
  38. package/src/hooks/useDomEditCommitsHelpers.ts +2 -1
  39. package/src/hooks/useDomEditPreviewSync.ts +23 -0
  40. package/src/hooks/useDomEditSession.test.tsx +43 -62
  41. package/src/hooks/useDomEditSession.ts +10 -2
  42. package/src/hooks/useDomEditWiring.ts +6 -0
  43. package/src/hooks/useDomSelection.test.ts +133 -3
  44. package/src/hooks/useDomSelection.ts +67 -40
  45. package/src/hooks/useDomSelectionSelectionGuards.test.ts +6 -2
  46. package/src/hooks/useElementLifecycleOps.ts +2 -1
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +16 -3
  48. package/src/hooks/useFileManager.ts +2 -8
  49. package/src/hooks/useGroupCommits.ts +2 -1
  50. package/src/hooks/useGsapAnimationFetchFallback.ts +6 -1
  51. package/src/hooks/useGsapAwareEditing.test.tsx +36 -0
  52. package/src/hooks/useGsapAwareEditing.ts +88 -15
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -5
  54. package/src/hooks/useGsapScriptCommits.test.tsx +193 -7
  55. package/src/hooks/useGsapScriptCommits.ts +52 -15
  56. package/src/hooks/useStudioUrlState.ts +130 -7
  57. package/src/hooks/useTimelineEditing.ts +2 -1
  58. package/src/hooks/useTimelineSelectionPreviewSync.ts +13 -0
  59. package/src/player/hooks/useTimelinePlayer.ts +2 -0
  60. package/src/utils/dragDebug.ts +98 -0
  61. package/src/utils/razorSplitTransaction.test.ts +15 -3
  62. package/src/utils/razorSplitTransaction.ts +2 -0
  63. package/src/utils/reloadDebug.ts +10 -0
  64. package/src/utils/resizeDebug.ts +19 -29
  65. package/src/utils/selectDebug.ts +9 -0
  66. package/src/utils/studioDebug.test.ts +32 -0
  67. package/src/utils/studioDebug.ts +30 -0
  68. package/src/utils/studioFileVersion.ts +16 -1
  69. package/src/utils/studioUrlState.test.ts +163 -1
  70. package/src/utils/studioUrlState.ts +60 -3
  71. package/src/utils/timelineCompositionInsert.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.102",
3
+ "version": "0.7.104",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.7.102",
51
- "@hyperframes/parsers": "0.7.102",
52
- "@hyperframes/sdk": "0.7.102",
53
- "@hyperframes/player": "0.7.102",
54
- "@hyperframes/studio-server": "0.7.102"
50
+ "@hyperframes/core": "0.7.104",
51
+ "@hyperframes/parsers": "0.7.104",
52
+ "@hyperframes/player": "0.7.104",
53
+ "@hyperframes/studio-server": "0.7.104",
54
+ "@hyperframes/sdk": "0.7.104"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -67,7 +67,7 @@
67
67
  "vite": "^6.4.2",
68
68
  "vitest": "^3.2.4",
69
69
  "zustand": "^5.0.0",
70
- "@hyperframes/producer": "0.7.102"
70
+ "@hyperframes/producer": "0.7.104"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "react": "19",
package/src/App.tsx CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  readStudioUrlStateFromWindow,
66
66
  resolveMasterCompositionPath,
67
67
  } from "./utils/studioUrlState";
68
+ const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
68
69
  // fallow-ignore-next-line complexity
69
70
  export function StudioApp() {
70
71
  const { projectId, resolving, waitingForServer } = useServerConnection();
@@ -91,9 +92,9 @@ export function StudioApp() {
91
92
  const captionSync = useCaptionSync(projectId);
92
93
  const timelineElements = usePlayerStore((s) => s.elements);
93
94
  const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
95
+ const setTimelineSelectionSet = usePlayerStore((s) => s.setSelectedElementIds);
94
96
  const timelineDuration = usePlayerStore((s) => s.duration);
95
97
  const isPlaying = usePlayerStore((s) => s.isPlaying);
96
- const isMasterView = !activeCompPath || activeCompPath === "index.html";
97
98
  const effectiveTimelineDuration = useMemo(() => {
98
99
  const maxEnd =
99
100
  timelineElements.length > 0
@@ -270,13 +271,14 @@ export function StudioApp() {
270
271
  const domEditSession = useDomEditSession({
271
272
  projectId,
272
273
  activeCompPath,
273
- isMasterView,
274
274
  compIdToSrc,
275
275
  captionEditMode,
276
276
  compositionLoading,
277
277
  previewIframeRef,
278
278
  timelineElements,
279
+ getTimelineSelectionSet,
279
280
  setSelectedTimelineElementId,
281
+ setTimelineSelectionSet,
280
282
  setRightCollapsed: panelLayout.setRightCollapsed,
281
283
  setRightPanelTab: panelLayout.setRightPanelTab,
282
284
  showToast,
@@ -416,6 +418,8 @@ export function StudioApp() {
416
418
  rightCollapsed: panelLayout.rightCollapsed,
417
419
  activeCompPathHydrated,
418
420
  domEditSelection: domEditSession.domEditSelection,
421
+ domEditGroupSelections: domEditSession.domEditGroupSelections,
422
+ applyMarqueeSelection: domEditSession.applyMarqueeSelection,
419
423
  buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
420
424
  applyDomSelection: domEditSession.applyDomSelection,
421
425
  setRightPanelTab: panelLayout.setRightPanelTab,
@@ -3,6 +3,7 @@ import { useCaptionStore } from "../store";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { trackEvent } from "../../telemetry/client";
5
5
  import type { CaptionStyle } from "../types";
6
+ import { studioWriteHeaders } from "../../utils/studioFileVersion";
6
7
 
7
8
  interface CaptionOverrideEntry {
8
9
  wordId?: string;
@@ -77,7 +78,7 @@ export function useCaptionSync(projectId: string | null) {
77
78
 
78
79
  fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, {
79
80
  method: "PUT",
80
- headers: { "Content-Type": "text/plain" },
81
+ headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
81
82
  body: JSON.stringify(overrides, null, 2),
82
83
  }).catch((error: unknown) => {
83
84
  // Caption auto-save is a data-loss path; surface failures via telemetry
@@ -14,7 +14,10 @@ import {
14
14
  resolveDomEditRotationGesture,
15
15
  } from "./DomEditOverlay";
16
16
  import type { DomEditSelection } from "./domEditing";
17
- import { resolveResizeCenterAnchorOffset } from "./domEditOverlayGestures";
17
+ import {
18
+ hoverCacheDescribesPoint,
19
+ resolveResizeCenterAnchorOffset,
20
+ } from "./domEditOverlayGestures";
18
21
 
19
22
  // React 19 warns unless the test environment opts into act().
20
23
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -278,7 +281,9 @@ describe("DomEditOverlay", () => {
278
281
  const host = document.createElement("div");
279
282
  document.body.append(host);
280
283
  const root = createRoot(host);
281
- const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
284
+ const iframeRef: { current: HTMLIFrameElement | null } = {
285
+ current: document.createElement("iframe"),
286
+ };
282
287
  const onCanvasMouseDown = vi.fn();
283
288
  const onMarqueeSelect = vi.fn();
284
289
 
@@ -323,6 +328,44 @@ describe("DomEditOverlay", () => {
323
328
  host.remove();
324
329
  });
325
330
 
331
+ it("starts a marquee from outside the composition frame", async () => {
332
+ const restoreRect = stubViewportRect();
333
+ const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
334
+ const setPointerCapture = vi.fn();
335
+ HTMLDivElement.prototype.setPointerCapture = setPointerCapture;
336
+ const host = document.createElement("div");
337
+ document.body.append(host);
338
+ const root = createRoot(host);
339
+ const iframeRef: { current: HTMLIFrameElement | null } = {
340
+ current: document.createElement("iframe"),
341
+ };
342
+
343
+ act(() => {
344
+ root.render(
345
+ React.createElement(DomEditOverlay, {
346
+ ...createOverlayProps({
347
+ iframeRef,
348
+ selection: null,
349
+ hoverSelection: null,
350
+ onSelectionChange: () => {},
351
+ }),
352
+ onMarqueeSelect: vi.fn(),
353
+ }),
354
+ );
355
+ });
356
+ await flushOverlayRaf();
357
+
358
+ // Negative x is outside the 0..800 composition frame but still reaches the
359
+ // overlay in a real pointer event when the user starts in the grey margin.
360
+ dispatchOverlayPointerDown(getOverlay(host), -40, 100);
361
+ expect(setPointerCapture).toHaveBeenCalledTimes(1);
362
+
363
+ act(() => root.unmount());
364
+ HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
365
+ restoreRect();
366
+ host.remove();
367
+ });
368
+
326
369
  it("does not start a drag from a stale hover target on canvas pointer-down", () => {
327
370
  const host = document.createElement("div");
328
371
  document.body.append(host);
@@ -628,6 +671,50 @@ describe("resolveDomEditRotationGesture", () => {
628
671
  });
629
672
  });
630
673
 
674
+ /**
675
+ * Shift-click reads the hover cache instead of hit-testing, and the cache is
676
+ * filled asynchronously as the pointer moves. Pass over one element on the way to
677
+ * another and the cache still names the one you left, so the shift-click added
678
+ * THAT element and the click looked like it selected something at random. The
679
+ * guard is what makes the cache usable only when it is about the point clicked.
680
+ */
681
+ describe("hoverCacheDescribesPoint", () => {
682
+ const doc = new Window().document;
683
+
684
+ it("rejects a cache left behind by an element the pointer passed over", () => {
685
+ const passedOver = doc.createElement("div");
686
+ const clicked = doc.createElement("div");
687
+ doc.body.append(passedOver, clicked);
688
+
689
+ expect(hoverCacheDescribesPoint(passedOver, clicked)).toBe(false);
690
+ });
691
+
692
+ it("accepts the cache when it names the element at the point", () => {
693
+ const clicked = doc.createElement("div");
694
+ doc.body.append(clicked);
695
+
696
+ expect(hoverCacheDescribesPoint(clicked, clicked)).toBe(true);
697
+ });
698
+
699
+ // The resolver is allowed to hand back a clip ancestor of the raw target, which
700
+ // still describes the same click — rejecting it would drop the fast path on
701
+ // every element that has children.
702
+ it("accepts an ancestor of the element at the point", () => {
703
+ const clip = doc.createElement("div");
704
+ const child = doc.createElement("span");
705
+ clip.append(child);
706
+ doc.body.append(clip);
707
+
708
+ expect(hoverCacheDescribesPoint(clip, child)).toBe(true);
709
+ });
710
+
711
+ it("rejects a missing cache or an empty point", () => {
712
+ const el = doc.createElement("div");
713
+ expect(hoverCacheDescribesPoint(null, el)).toBe(false);
714
+ expect(hoverCacheDescribesPoint(el, null)).toBe(false);
715
+ });
716
+ });
717
+
631
718
  // resolveResizeCenterAnchorOffset is the UNROTATED (AABB) fallback used only when
632
719
  // the element's real transformed corners can't be measured. Center-anchored: a
633
720
  // width/height change grows the box from its top-left, drifting the center by half
@@ -13,6 +13,7 @@ import {
13
13
  type GestureState,
14
14
  type GroupGestureState,
15
15
  focusDomEditOverlayElement,
16
+ resolveShiftClickCandidate,
16
17
  } from "./domEditOverlayGestures";
17
18
  import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
18
19
  import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
@@ -31,6 +32,7 @@ import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
31
32
  import { CanvasContextMenu } from "./CanvasContextMenu";
32
33
  import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
33
34
  import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
35
+ import { logSelect } from "../../utils/selectDebug";
34
36
 
35
37
  // Re-exports for external consumers — preserving existing import paths.
36
38
  export {
@@ -318,6 +320,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
318
320
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
319
321
  if (!allowCanvasMovement) return;
320
322
  if (suppressNextOverlayMouseDownRef.current) {
323
+ logSelect("mousedown-suppressed", { shift: event.shiftKey });
321
324
  suppressNextOverlayMouseDownRef.current = false;
322
325
  suppressNextBoxMouseDownRef.current = false;
323
326
  suppressNextBoxClickRef.current = false;
@@ -326,7 +329,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
326
329
  return;
327
330
  }
328
331
  const target = event.target as HTMLElement | null;
329
- if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
332
+ const onBox = Boolean(target?.closest('[data-dom-edit-selection-box="true"]'));
333
+ logSelect("mousedown", { shift: event.shiftKey, onBox });
334
+ if (onBox) return;
330
335
  // Allow clicks anywhere on the overlay — GSAP-translated elements can
331
336
  // extend beyond the composition rect into the gray zone, and users need
332
337
  // to select/deselect them by clicking there.
@@ -341,8 +346,20 @@ export const DomEditOverlay = memo(function DomEditOverlay({
341
346
  const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
342
347
  if (!allowCanvasMovement || event.button !== 0) return;
343
348
  if (event.shiftKey) {
344
- // Use the already-updated hover selection rather than re-resolving async
345
- const candidate = hoverSelectionRef.current;
349
+ const shiftIframe = iframeRef.current;
350
+ const candidate = resolveShiftClickCandidate({
351
+ cached: hoverSelectionRef.current,
352
+ elementAtPoint: shiftIframe
353
+ ? getPreviewTargetFromPointer(
354
+ shiftIframe,
355
+ event.clientX,
356
+ event.clientY,
357
+ activeCompositionPathRef.current,
358
+ )
359
+ : null,
360
+ });
361
+ // Not confident: fall through untouched — no preventDefault, no suppression —
362
+ // so the mousedown path resolves this point instead of guessing here.
346
363
  if (!candidate) return;
347
364
  event.preventDefault();
348
365
  event.stopPropagation();
@@ -376,28 +393,27 @@ export const DomEditOverlay = memo(function DomEditOverlay({
376
393
  const overlayEl = overlayRef.current;
377
394
  if (overlayEl) {
378
395
  const oRect = overlayEl.getBoundingClientRect();
396
+ // Anywhere empty on the overlay starts one, not just inside the frame.
397
+ // An element dragged past the edge sits OUT there in the grey, and a
398
+ // rubber band that refuses to start there cannot reach it — which left
399
+ // the timeline as the only way to select something you can plainly see.
400
+ // The hit test collects in overlay space and never clipped to the frame,
401
+ // so those elements were always selectable once the band could begin.
402
+ event.preventDefault();
403
+ event.stopPropagation();
404
+ suppressNextOverlayMouseDownRef.current = true;
405
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
379
406
  const cx = event.clientX - oRect.left;
380
407
  const cy = event.clientY - oRect.top;
381
- const inComp =
382
- cx >= compRect.left &&
383
- cx <= compRect.left + compRect.width &&
384
- cy >= compRect.top &&
385
- cy <= compRect.top + compRect.height;
386
- if (inComp) {
387
- event.preventDefault();
388
- event.stopPropagation();
389
- suppressNextOverlayMouseDownRef.current = true;
390
- (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
391
- marquee.marqueeRef.current = {
392
- startX: cx,
393
- startY: cy,
394
- currentX: cx,
395
- currentY: cy,
396
- pointerId: event.pointerId,
397
- pastThreshold: false,
398
- };
399
- return;
400
- }
408
+ marquee.marqueeRef.current = {
409
+ startX: cx,
410
+ startY: cy,
411
+ currentX: cx,
412
+ currentY: cy,
413
+ pointerId: event.pointerId,
414
+ pastThreshold: false,
415
+ };
416
+ return;
401
417
  }
402
418
  }
403
419
  };
@@ -503,6 +519,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
503
519
  top: cr.top,
504
520
  width: cr.width,
505
521
  height: cr.height,
522
+ transform: cr.angle ? `rotate(${cr.angle}deg)` : undefined,
506
523
  }}
507
524
  />
508
525
  ))}
@@ -5,6 +5,7 @@ import {
5
5
  orientedGroupAwareOverlayRect,
6
6
  overlayCornersCentroid,
7
7
  selectionCacheKey,
8
+ orientedVisibleOverlayRect,
8
9
  } from "./domEditOverlayGeometry";
9
10
 
10
11
  describe("overlayCornersCentroid", () => {
@@ -67,6 +68,17 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
67
68
  number,
68
69
  ];
69
70
  }
71
+ /** `this` applied outside `other`, the way an ancestor composes over a child. */
72
+ multiply(other: { a: number; b: number; c: number; d: number; e: number; f: number }) {
73
+ const out = new (this.constructor as new (init?: string) => this)();
74
+ out.a = this.a * other.a + this.c * other.b;
75
+ out.b = this.b * other.a + this.d * other.b;
76
+ out.c = this.a * other.c + this.c * other.d;
77
+ out.d = this.b * other.c + this.d * other.d;
78
+ out.e = this.a * other.e + this.c * other.f + this.e;
79
+ out.f = this.b * other.e + this.d * other.f + this.f;
80
+ return out;
81
+ }
70
82
  transformPoint(pt: { x: number; y: number }) {
71
83
  return {
72
84
  x: this.a * pt.x + this.c * pt.y + this.e,
@@ -148,6 +160,30 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
148
160
  expect(rect!.angle ?? 0).toBe(0);
149
161
  });
150
162
 
163
+ /**
164
+ * A child outline is drawn ON the child, not around it. Measured axis-aligned,
165
+ * a text layer inside a rotated card got an upright dashed box sitting across
166
+ * the rotated glyphs — the parent's chrome rotated and its children's did not.
167
+ */
168
+ it("child outlines carry the element's angle, so they can co-rotate with it", () => {
169
+ const { overlayEl, iframe, el } = buildHarness();
170
+ el.style.transform = ROTATE_30DEG_MATRIX;
171
+ const rect = orientedVisibleOverlayRect(overlayEl, iframe, el);
172
+ expect(rect).not.toBeNull();
173
+ expect(rect!.angle).toBeCloseTo(30, 3);
174
+ });
175
+
176
+ it("an unrotated child outline is unchanged — no angle, same box as before", () => {
177
+ const { overlayEl, iframe, el } = buildHarness();
178
+ const rect = orientedVisibleOverlayRect(overlayEl, iframe, el);
179
+ expect(rect).not.toBeNull();
180
+ expect(rect!.angle ?? 0).toBe(0);
181
+ expect(rect!.left).toBeCloseTo(400, 5);
182
+ expect(rect!.top).toBeCloseTo(450, 5);
183
+ expect(rect!.width).toBeCloseTo(200, 5);
184
+ expect(rect!.height).toBeCloseTo(100, 5);
185
+ });
186
+
151
187
  it("rotated element takes the corner-geometry path — reports the live angle", () => {
152
188
  const { overlayEl, iframe, el } = buildHarness();
153
189
  el.style.transform = ROTATE_30DEG_MATRIX;
@@ -156,6 +192,64 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
156
192
  expect(rect!.angle).toBeCloseTo(30, 3);
157
193
  });
158
194
 
195
+ /**
196
+ * The selection box is drawn at the size the element PAINTS, which is the
197
+ * product of every transform between it and the composition root.
198
+ *
199
+ * A text layer inside a card carrying `scale(1.2)` was drawn at 1/1.2 of the
200
+ * text: the top-left was right, because the caller anchors that to the real
201
+ * bounding rect, and the right and bottom edges fell short. The same read
202
+ * decides whether to draw the box rotated, so an element inside a rotated
203
+ * parent got an upright box.
204
+ */
205
+ const SCALE_1_2_MATRIX = "matrix(1.2, 0, 0, 1.2, 0, 0)";
206
+ const MIRROR_X_MATRIX = "matrix(-1, 0, 0, 1, 0, 0)";
207
+
208
+ it("sizes the box by the accumulated transform, not the element's own", () => {
209
+ const { overlayEl, iframe, el } = buildHarness();
210
+ // The element carries no transform; its parent scales it by 1.2, so it
211
+ // paints at 240x120 and its bounding rect says so.
212
+ el.parentElement!.style.transform = SCALE_1_2_MATRIX;
213
+ el.style.transform = ROTATE_30DEG_MATRIX;
214
+ stubRect(el, { left: 400, top: 450, width: 240, height: 120 });
215
+
216
+ const rect = orientedOverlayRect(overlayEl, iframe, el);
217
+
218
+ expect(rect).not.toBeNull();
219
+ // 200x100 local, scaled by the ancestor, then rotated: the oriented box is
220
+ // the scaled local box, and the AABB it is anchored to is wider again.
221
+ expect(rect!.width).toBeCloseTo(240, 3);
222
+ expect(rect!.height).toBeCloseTo(120, 3);
223
+ expect(rect!.angle).toBeCloseTo(30, 3);
224
+ });
225
+
226
+ it("takes the rotated path when only an ANCESTOR is rotated", () => {
227
+ const { overlayEl, iframe, el } = buildHarness();
228
+ el.parentElement!.style.transform = ROTATE_30DEG_MATRIX;
229
+
230
+ const rect = orientedOverlayRect(overlayEl, iframe, el);
231
+
232
+ expect(rect!.angle).toBeCloseTo(30, 3);
233
+ });
234
+
235
+ it("does not misread a mirrored ancestor as a 180-degree rotation", () => {
236
+ const { overlayEl, iframe, el } = buildHarness();
237
+ el.parentElement!.style.transform = MIRROR_X_MATRIX;
238
+
239
+ const rect = orientedOverlayRect(overlayEl, iframe, el);
240
+
241
+ expect(rect?.angle ?? 0).toBe(0);
242
+ });
243
+
244
+ it("stops transform composition at the composition root", () => {
245
+ const { overlayEl, iframe, el } = buildHarness();
246
+ iframe.contentDocument!.body.style.transform = ROTATE_30DEG_MATRIX;
247
+
248
+ const rect = orientedOverlayRect(overlayEl, iframe, el);
249
+
250
+ expect(rect?.angle ?? 0).toBe(0);
251
+ });
252
+
159
253
  it("preserves an ordinary element's rotation through the group-aware entry point", () => {
160
254
  const { overlayEl, iframe, el } = buildHarness();
161
255
  el.style.transform = ROTATE_30DEG_MATRIX;
@@ -117,6 +117,28 @@ interface ElementTransformSnapshot {
117
117
  cs: CSSStyleDeclaration;
118
118
  }
119
119
 
120
+ /**
121
+ * The transform from the element's own box to the composition's, ACCUMULATED
122
+ * over its ancestors rather than read from the element alone.
123
+ *
124
+ * What the user sees is the product of every transform between the element and
125
+ * the composition root, and an element is routinely a child of something
126
+ * scaled or rotated. Reading only its own transform drew the selection box at
127
+ * the element's untransformed size: a text layer inside a card carrying
128
+ * `scale(1.2)` got a box at 1/1.2 of the text, with the top-left correct (the
129
+ * caller anchors that to the real bounding rect) and the right and bottom
130
+ * edges falling short. The same read decides whether to draw the box rotated,
131
+ * so an element inside a rotated parent got an upright box too.
132
+ *
133
+ * Only the linear part matters here. Each transform's origin contributes
134
+ * translation, and the caller discards translation by matching the corners'
135
+ * bounding box to the element's real one, so composing the matrices alone is
136
+ * enough and there is no per-ancestor origin to unpick.
137
+ *
138
+ * The walk stops at the composition document's root. The canvas zoom lives on
139
+ * the iframe element in Studio's own document and is applied separately by
140
+ * `computeOverlayRootScale`; including it here would count it twice.
141
+ */
120
142
  function readElementTransformSnapshot(
121
143
  win: Window,
122
144
  element: HTMLElement,
@@ -125,7 +147,15 @@ function readElementTransformSnapshot(
125
147
  if (!DOMMatrixCtor) return null;
126
148
  const cs = win.getComputedStyle(element);
127
149
  try {
128
- const matrix = new DOMMatrixCtor(cs.transform === "none" ? "" : cs.transform);
150
+ let matrix = new DOMMatrixCtor();
151
+ for (let node: HTMLElement | null = element; node; node = node.parentElement) {
152
+ const transform = node === element ? cs.transform : win.getComputedStyle(node).transform;
153
+ if (transform && transform !== "none") {
154
+ // An ancestor applies outside, so it multiplies on the left.
155
+ matrix = new DOMMatrixCtor(transform).multiply(matrix);
156
+ }
157
+ if (node.hasAttribute("data-composition-id")) break;
158
+ }
129
159
  return { matrix, cs };
130
160
  } catch {
131
161
  return null;
@@ -141,7 +171,16 @@ function readElementTransformSnapshot(
141
171
  function rotationDegreesFromMatrix(matrix: DOMMatrix): number {
142
172
  const a = Number.isFinite(matrix.a) ? matrix.a : 1;
143
173
  const b = Number.isFinite(matrix.b) ? matrix.b : 0;
144
- const deg = (Math.atan2(b, a) * 180) / Math.PI;
174
+ const c = Number.isFinite(matrix.c) ? matrix.c : 0;
175
+ const d = Number.isFinite(matrix.d) ? matrix.d : 1;
176
+ const fromX = (Math.atan2(b, a) * 180) / Math.PI;
177
+ const determinant = a * d - b * c;
178
+ // A reflection makes one basis direction read 180° away from the authored
179
+ // rotation. For cursor/handle orientation those directions are equivalent;
180
+ // choose the representative nearest zero instead of drawing a pure mirror's
181
+ // rotate handle on the opposite side of the element.
182
+ const fromY = (Math.atan2(-c, d) * 180) / Math.PI;
183
+ const deg = determinant < 0 && Math.abs(fromY) < Math.abs(fromX) ? fromY : fromX;
145
184
  return Number.isFinite(deg) ? deg : 0;
146
185
  }
147
186
 
@@ -391,6 +430,24 @@ export function orientedOverlayRect(
391
430
  };
392
431
  }
393
432
 
433
+ /**
434
+ * `toVisibleOverlayRect`'s oriented twin: the element's crop-hugged box plus its
435
+ * live rotation, for chrome that has to sit on a rotated element rather than
436
+ * around it. Rendering the result with `transform: rotate(angle)` about its
437
+ * centre lands it on the element's real corners.
438
+ *
439
+ * At angle 0 `orientedOverlayRect` returns the plain AABB, so an unrotated
440
+ * element measures exactly as it did before.
441
+ */
442
+ export function orientedVisibleOverlayRect(
443
+ overlayEl: HTMLDivElement,
444
+ iframe: HTMLIFrameElement,
445
+ element: HTMLElement,
446
+ ): OverlayRect | null {
447
+ const rect = orientedOverlayRect(overlayEl, iframe, element);
448
+ return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
449
+ }
450
+
394
451
  const OVERLAY_RECT_EPSILON_PX = 0.5;
395
452
  const OVERLAY_RECT_ANGLE_EPSILON_DEG = 0.1;
396
453
 
@@ -10,6 +10,7 @@ import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
10
10
  import type { SnapContext } from "./snapTargetCollection";
11
11
  import type { SnapGuidesState } from "./SnapGuideOverlay";
12
12
  import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
13
+ import { logSelect } from "../../utils/selectDebug";
13
14
 
14
15
  export type GestureKind = "drag" | "resize" | "rotate";
15
16
 
@@ -112,6 +113,47 @@ export function focusDomEditOverlayElement(element: FocusableDomEditOverlay | nu
112
113
  element?.focus({ preventScroll: true });
113
114
  }
114
115
 
116
+ /**
117
+ * Whether the hover cache may stand in for a hit-test at this point.
118
+ *
119
+ * The cache is filled asynchronously as the pointer moves, so it can describe an
120
+ * element the pointer has already left. That is harmless for drawing a hover
121
+ * outline and wrong for a shift-click, which would add the stale element to the
122
+ * selection instead of the one under the pointer. True only when the cached
123
+ * element IS the element at the point, or contains it — the resolver is allowed
124
+ * to hand back a clip ancestor of the raw target, and that still describes the
125
+ * same click.
126
+ */
127
+ export function hoverCacheDescribesPoint(
128
+ cachedElement: Element | null | undefined,
129
+ elementAtPoint: Element | null | undefined,
130
+ ): boolean {
131
+ if (!cachedElement || !elementAtPoint) return false;
132
+ return cachedElement === elementAtPoint || cachedElement.contains(elementAtPoint);
133
+ }
134
+
135
+ /**
136
+ * The element a shift-click should add, or null to let the slower path resolve it.
137
+ *
138
+ * Reading the hover cache without checking is safe for a hover outline and wrong
139
+ * for a shift-click: the click silently adds whatever the pointer last passed
140
+ * over instead of the element under it, which reads as multi-select picking
141
+ * things at random. Returning null means "not confident", and the caller must
142
+ * then fall through untouched so the mousedown path resolves the point properly.
143
+ */
144
+ export function resolveShiftClickCandidate<T extends { element: Element }>(input: {
145
+ cached: T | null;
146
+ elementAtPoint: Element | null;
147
+ }): T | null {
148
+ const describes = hoverCacheDescribesPoint(input.cached?.element, input.elementAtPoint);
149
+ logSelect("shift-pointerdown", {
150
+ candidate: input.cached ? ((input.cached as { selector?: string }).selector ?? null) : null,
151
+ pointTarget: input.elementAtPoint?.id ?? input.elementAtPoint?.tagName ?? null,
152
+ cacheIsAboutThisPoint: describes,
153
+ });
154
+ return describes ? input.cached : null;
155
+ }
156
+
115
157
  /**
116
158
  * Overlay-px translation that keeps the element's CENTER fixed while a corner
117
159
  * resizes: a CSS width/height change grows the layout box from its top-left, so
@@ -33,6 +33,7 @@ import {
33
33
  } from "./domEditOverlayGestures";
34
34
  import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
35
35
  import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
36
+ import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
36
37
 
37
38
  export function startGroupDrag(
38
39
  e: React.PointerEvent<HTMLElement>,
@@ -70,6 +71,22 @@ export function startGroupDrag(
70
71
  }
71
72
  members.push(result.member);
72
73
  }
74
+ resetDragMoveLog();
75
+ logDrag("group-start", {
76
+ // A member whose mapping differs from its neighbours travels a different
77
+ // distance for the same pointer delta, which is the group coming apart.
78
+ members: Object.fromEntries(
79
+ members.map((member) => [
80
+ member.key,
81
+ {
82
+ map: `${member.screenToOffset.a.toFixed(3)},${member.screenToOffset.d.toFixed(3)}`,
83
+ base: `${Math.round(member.baseGsap.x)},${Math.round(member.baseGsap.y)}`,
84
+ offset: `${Math.round(member.initialOffset.x)},${Math.round(member.initialOffset.y)}`,
85
+ },
86
+ ]),
87
+ ),
88
+ at: readDragPositions(members),
89
+ });
73
90
 
74
91
  const overlayEl = opts.overlayRef.current;
75
92
  const iframe = opts.iframeRef.current;