@hyperframes/studio 0.8.143 → 0.8.145

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 (72) hide show
  1. package/dist/assets/{hyperframes-player-BXSuksFh.js → hyperframes-player-DZ5JrMDg.js} +3 -3
  2. package/dist/assets/{index-FgyO_5Kv.js → index-BOs6C0aZ.js} +1 -1
  3. package/dist/assets/{index-BgOVCqjo.js → index-BqFRhNZa.js} +1 -1
  4. package/dist/assets/{index-J7jpL4pj.js → index-D8W0a0Z9.js} +149 -149
  5. package/dist/{chunk-TFLU2MGI.js → chunk-6JY3MTIX.js} +108 -2
  6. package/dist/chunk-6JY3MTIX.js.map +1 -0
  7. package/dist/{chunk-H2DUOXTF.js → chunk-NZPJUZKT.js} +100 -4
  8. package/dist/chunk-NZPJUZKT.js.map +1 -0
  9. package/dist/{chunk-UYWON5AX.js → chunk-TP6D3U37.js} +7 -100
  10. package/dist/chunk-TP6D3U37.js.map +1 -0
  11. package/dist/{chunk-LOVBLSMR.js → chunk-Y2OHHNM2.js} +35 -19
  12. package/dist/chunk-Y2OHHNM2.js.map +1 -0
  13. package/dist/{domEditingLayers-KBTPQKGA.js → domEditingLayers-PTP2ICGM.js} +3 -3
  14. package/dist/index.d.ts +8 -5
  15. package/dist/index.html +1 -1
  16. package/dist/index.js +545 -495
  17. package/dist/index.js.map +1 -1
  18. package/dist/player.js +3 -3
  19. package/dist/ui.d.ts +4 -5
  20. package/dist/ui.js +1 -1
  21. package/package.json +8 -8
  22. package/src/components/StudioLeftPanels.tsx +2 -2
  23. package/src/components/editor/DomEditSelectionChrome.test.tsx +103 -0
  24. package/src/components/editor/DomEditSelectionChrome.tsx +42 -16
  25. package/src/components/editor/LayersPanel.test.ts +4 -4
  26. package/src/components/editor/LayersPanel.tsx +2 -2
  27. package/src/components/editor/TopologyLens.test.tsx +5 -4
  28. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +36 -0
  29. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  30. package/src/components/editor/domEditOverlayStartGesture.ts +5 -0
  31. package/src/components/editor/domEditResizeLocal.test.ts +81 -1
  32. package/src/components/editor/domEditResizeLocal.ts +27 -3
  33. package/src/components/editor/domEditingLayers.ts +4 -1
  34. package/src/components/editor/domEditingTextFields.test.ts +18 -0
  35. package/src/components/editor/manualEditsDom.ts +12 -12
  36. package/src/components/editor/previewGestureStarts.ts +14 -5
  37. package/src/components/editor/resizeDraft.ts +13 -4
  38. package/src/components/editor/useDomEditOverlayGestures.ts +5 -3
  39. package/src/components/renders/GpuEncodingToggle.tsx +28 -0
  40. package/src/components/renders/RenderQueue.test.tsx +20 -2
  41. package/src/components/renders/RenderQueue.tsx +13 -1
  42. package/src/components/renders/RenderQueuePanel.tsx +2 -1
  43. package/src/components/renders/renderSettings.ts +5 -2
  44. package/src/components/renders/useRenderQueue.ts +3 -0
  45. package/src/components/renders/useRenderQueueComposition.test.tsx +12 -0
  46. package/src/components/ui/Menu.test.tsx +67 -5
  47. package/src/components/ui/Menu.tsx +21 -4
  48. package/src/components/ui/Popover.tsx +11 -5
  49. package/src/hooks/gsapResizeIntercept.test.ts +46 -11
  50. package/src/hooks/gsapResizeIntercept.ts +25 -20
  51. package/src/hooks/useConsoleErrorCapture.test.tsx +21 -2
  52. package/src/hooks/useConsoleErrorCapture.ts +3 -2
  53. package/src/hooks/useThumbnailStripSize.test.tsx +123 -0
  54. package/src/hooks/useThumbnailStripSize.ts +19 -8
  55. package/src/index.ts +1 -0
  56. package/src/player/components/CompositionThumbnail.test.ts +3 -0
  57. package/src/player/components/ImageThumbnail.test.tsx +4 -1
  58. package/src/player/components/Player.test.ts +44 -0
  59. package/src/player/components/Player.tsx +11 -0
  60. package/src/player/components/VideoThumbnail.test.tsx +13 -2
  61. package/src/player/hooks/timelineSyncHydration.ts +2 -1
  62. package/src/player/lib/playbackTypes.ts +1 -0
  63. package/src/player/sceneSwap.test.ts +27 -1
  64. package/src/player/sceneSwap.ts +16 -2
  65. package/src/utils/authoredSource.test.ts +25 -0
  66. package/src/utils/authoredSource.ts +3 -5
  67. package/src/utils/rounding.ts +7 -0
  68. package/dist/chunk-H2DUOXTF.js.map +0 -1
  69. package/dist/chunk-LOVBLSMR.js.map +0 -1
  70. package/dist/chunk-TFLU2MGI.js.map +0 -1
  71. package/dist/chunk-UYWON5AX.js.map +0 -1
  72. /package/dist/{domEditingLayers-KBTPQKGA.js.map → domEditingLayers-PTP2ICGM.js.map} +0 -0
package/dist/player.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-H2DUOXTF.js";
4
- import "./chunk-LOVBLSMR.js";
5
- import "./chunk-TFLU2MGI.js";
3
+ } from "./chunk-NZPJUZKT.js";
4
+ import "./chunk-Y2OHHNM2.js";
5
+ import "./chunk-6JY3MTIX.js";
6
6
  import "./chunk-PZ5AY32C.js";
7
7
  export {
8
8
  Player
package/dist/ui.d.ts CHANGED
@@ -115,7 +115,7 @@ type StyledProps<T extends ElementType> = Omit<ComponentPropsWithoutRef<T>, "cla
115
115
  /** Forces the settled open look for a gallery shot; CSS-only, open state stays controlled. */
116
116
  type PopupPreviewState = "open";
117
117
  /** Where the portal puts the popup. `null` keeps it inline, next to its trigger. */
118
- type PortalContainer$1 = ComponentPropsWithoutRef<typeof Menu$1.Portal>["container"];
118
+ type PortalContainer = ComponentPropsWithoutRef<typeof Menu$1.Portal>["container"];
119
119
  /**
120
120
  * Chrome shared by every floating panel (`Popover` too); callers add their own shadow token.
121
121
  */
@@ -125,8 +125,8 @@ interface PositionedProps {
125
125
  side?: "top" | "bottom" | "left" | "right";
126
126
  align?: "start" | "center" | "end";
127
127
  sideOffset?: number;
128
- /** Portal target. Pass the shadow root when the trigger lives in one. */
129
- container?: PortalContainer$1;
128
+ /** Portal target. Pass the shadow root when the trigger lives in one; `null` keeps it inline. */
129
+ container?: PortalContainer;
130
130
  /** Names the popup for assistive tech. A menu with no name is unlabelled. */
131
131
  "aria-label"?: string;
132
132
  className?: string;
@@ -174,7 +174,6 @@ declare function MenuRadioItem({ className, children, ...props }: MenuRadioItemP
174
174
  /** A hairline between two groups of items. */
175
175
  declare function MenuSeparator({ className, ...props }: StyledProps<typeof Menu$1.Separator>): react_jsx_runtime.JSX.Element;
176
176
 
177
- type PortalContainer = ComponentPropsWithoutRef<typeof Popover$1.Portal>["container"];
178
177
  interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof Popover$1.Root>, "children"> {
179
178
  /** A single element. It becomes the trigger; no wrapper is added around it. */
180
179
  trigger: ReactElement;
@@ -185,7 +184,7 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof Popover$1.Ro
185
184
  sideOffset?: number;
186
185
  /** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
187
186
  arrow?: boolean;
188
- /** Portal target. Pass the shadow root when the trigger lives in one. */
187
+ /** Portal target. Pass the shadow root when the trigger lives in one; `null` keeps it inline. */
189
188
  container?: PortalContainer;
190
189
  /** Names the popup for assistive tech. */
191
190
  "aria-label"?: string;
package/dist/ui.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  fieldBase,
31
31
  fieldText,
32
32
  popupSurface
33
- } from "./chunk-LOVBLSMR.js";
33
+ } from "./chunk-Y2OHHNM2.js";
34
34
  import "./chunk-PZ5AY32C.js";
35
35
  export {
36
36
  Button,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.143",
3
+ "version": "0.8.145",
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.143",
64
- "@hyperframes/player": "0.8.143",
65
- "@hyperframes/sdk": "0.8.143",
66
- "@hyperframes/studio-server": "0.8.143",
67
- "@hyperframes/parsers": "0.8.143",
68
- "@hyperframes/shader-transitions": "0.8.143"
63
+ "@hyperframes/core": "0.8.145",
64
+ "@hyperframes/sdk": "0.8.145",
65
+ "@hyperframes/player": "0.8.145",
66
+ "@hyperframes/shader-transitions": "0.8.145",
67
+ "@hyperframes/parsers": "0.8.145",
68
+ "@hyperframes/studio-server": "0.8.145"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -82,7 +82,7 @@
82
82
  "vite": "^6.4.2",
83
83
  "vitest": "^4.1.11",
84
84
  "zustand": "^5.0.0",
85
- "@hyperframes/producer": "0.8.143"
85
+ "@hyperframes/producer": "0.8.145"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "react": "19",
@@ -75,8 +75,8 @@ export const StudioLeftPanels = memo(function StudioLeftPanels({
75
75
  return;
76
76
  }
77
77
  await waitForPendingDomEditSaves();
78
- const { format, quality, fps } = getPersistedRenderSettings();
79
- await renderQueue.startRender({ composition: comp, format, quality, fps });
78
+ const { format, quality, fps, gpu } = getPersistedRenderSettings();
79
+ await renderQueue.startRender({ composition: comp, format, quality, fps, gpu });
80
80
  },
81
81
  [renderQueue, waitForPendingDomEditSaves],
82
82
  );
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
  import type { DomEditSelection } from "./domEditing";
7
7
  import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
8
+ import { RESIZE_HANDLE_HIT_PX } from "./domEditOverlayGeometry";
8
9
  import { SELECTION_CHROME } from "./motionPathLayerNode";
9
10
 
10
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -283,9 +284,12 @@ describe("DomEditSelectionChrome with body drag off", () => {
283
284
  it("keeps the resize and rotate handles working", () => {
284
285
  const { host, gestures, cleanup } = renderChrome(false);
285
286
  const corner = host.querySelector<HTMLElement>('[style*="nwse-resize"]')!;
287
+ corner.querySelector("[data-resize-corner]")!.getBoundingClientRect = () =>
288
+ ({ left: 7, top: 9 }) as DOMRect;
286
289
  press(corner);
287
290
  expect(gestures.startGesture).toHaveBeenCalledWith("resize", expect.anything(), {
288
291
  resizeHandle: "nw",
292
+ resizeCorner: { x: 7, y: 9 },
289
293
  });
290
294
  press(host.querySelector('[aria-label="Rotate selection"]')!);
291
295
  expect(gestures.startGesture).toHaveBeenCalledWith("rotate", expect.anything());
@@ -327,3 +331,102 @@ describe("DomEditSelectionChrome with body drag off", () => {
327
331
  act(() => root.unmount());
328
332
  });
329
333
  });
334
+
335
+ describe("DomEditSelectionChrome corner handles", () => {
336
+ type Inset = { top: number; right: number; bottom: number; left: number };
337
+ function cornerHits(
338
+ rect: { left: number; top: number; width: number; height: number },
339
+ cropOutlineInsetPx?: Inset,
340
+ ) {
341
+ const { selection, host, root } = selectionFixture(document.createElement("div"), "#t", true);
342
+ (selection.capabilities as { canCrop: boolean }).canCrop = false;
343
+ act(() => {
344
+ root.render(
345
+ <DomEditSelectionChrome
346
+ selection={selection}
347
+ overlayRect={{ ...rect, editScaleX: 1, editScaleY: 1 }}
348
+ allowCanvasMovement
349
+ allowBodyDrag
350
+ cropOutlineInsetPx={cropOutlineInsetPx}
351
+ boxRef={createRef()}
352
+ boxChromeClass=""
353
+ boxClipPath={undefined}
354
+ selectionKey="t"
355
+ groupSelectionCount={0}
356
+ gestures={{ startGesture: vi.fn() } as never}
357
+ onStyleCommit={vi.fn()}
358
+ onBoxClick={vi.fn()}
359
+ />,
360
+ );
361
+ });
362
+ const hits = [
363
+ ...host.querySelectorAll<HTMLElement>('[style*="nwse-resize"], [style*="nesw-resize"]'),
364
+ ].map((handle) => {
365
+ const corner = handle.querySelector<HTMLElement>("[data-resize-corner]")!;
366
+ const left = parseFloat(handle.style.left);
367
+ const top = parseFloat(handle.style.top);
368
+ return {
369
+ left,
370
+ top,
371
+ corner: [left + parseFloat(corner.style.left), top + parseFloat(corner.style.top)],
372
+ };
373
+ });
374
+ act(() => root.unmount());
375
+ host.remove();
376
+ return hits;
377
+ }
378
+
379
+ const NO_INSET: Inset = { top: 0, right: 0, bottom: 0, left: 0 };
380
+ it.each([
381
+ ["14x8", 14, 8, NO_INSET],
382
+ ["4x4", 4, 4, NO_INSET],
383
+ ["24x24", 24, 24, NO_INSET],
384
+ ["cropped 40x40 showing 16x16", 40, 40, { top: 12, right: 12, bottom: 12, left: 12 }],
385
+ ])(
386
+ "reach at most a quarter of each side into a small %s pick, so its middle moves",
387
+ (_name, width, height, inset) => {
388
+ const rect = { left: 100, top: 100, width, height };
389
+ const pick = {
390
+ left: rect.left + inset.left,
391
+ top: rect.top + inset.top,
392
+ right: rect.left + width - inset.right,
393
+ bottom: rect.top + height - inset.bottom,
394
+ };
395
+ const quarterX = (pick.right - pick.left) / 4;
396
+ const quarterY = (pick.bottom - pick.top) / 4;
397
+ const hits = cornerHits(rect, inset === NO_INSET ? undefined : inset);
398
+ expect(hits.map((hit) => hit.corner)).toEqual(
399
+ expect.arrayContaining([
400
+ [pick.left, pick.top],
401
+ [pick.right, pick.top],
402
+ [pick.left, pick.bottom],
403
+ [pick.right, pick.bottom],
404
+ ]),
405
+ );
406
+ expect(hits).toHaveLength(4);
407
+ for (const hit of hits) {
408
+ const reachX =
409
+ hit.left < (pick.left + pick.right) / 2
410
+ ? hit.left + RESIZE_HANDLE_HIT_PX - pick.left
411
+ : pick.right - hit.left;
412
+ const reachY =
413
+ hit.top < (pick.top + pick.bottom) / 2
414
+ ? hit.top + RESIZE_HANDLE_HIT_PX - pick.top
415
+ : pick.bottom - hit.top;
416
+ expect(reachX, JSON.stringify(hit)).toBeLessThanOrEqual(quarterX);
417
+ expect(reachY, JSON.stringify(hit)).toBeLessThanOrEqual(quarterY);
418
+ }
419
+ },
420
+ );
421
+
422
+ it("stay centred on the corners of a pick large enough to hold them", () => {
423
+ expect(cornerHits({ left: 100, top: 100, width: 32, height: 40 })).toEqual(
424
+ expect.arrayContaining([
425
+ { left: 92, top: 92, corner: [100, 100] },
426
+ { left: 124, top: 92, corner: [132, 100] },
427
+ { left: 92, top: 132, corner: [100, 140] },
428
+ { left: 124, top: 132, corner: [132, 140] },
429
+ ]),
430
+ );
431
+ });
432
+ });
@@ -30,27 +30,40 @@ const RESIZE_HANDLE_DEFS: Array<{
30
30
  type CropInset = { top: number; right: number; bottom: number; left: number };
31
31
  const NO_CROP_INSET: CropInset = { top: 0, right: 0, bottom: 0, left: 0 };
32
32
 
33
- function resizeHandleStyle(
33
+ function resizeHandleLayout(
34
34
  def: (typeof RESIZE_HANDLE_DEFS)[number],
35
35
  overlayRect: { left: number; top: number; width: number; height: number },
36
36
  cropInset?: CropInset,
37
- ): React.CSSProperties {
37
+ ): { style: React.CSSProperties; anchor: React.CSSProperties } {
38
38
  const half = RESIZE_HANDLE_HIT_PX / 2;
39
39
  const inset = cropInset ?? NO_CROP_INSET;
40
- const style: React.CSSProperties = { cursor: def.cursor, touchAction: "none" };
41
40
  // Position relative to the overlay container (not the selection box).
42
41
  // This ensures the dots render as siblings of the box border div — strictly
43
42
  // above it — rather than as children where the parent border can visually
44
43
  // overlap the dot circle at the corner.
45
- style.left =
44
+ const cornerX =
46
45
  def.x === "left"
47
- ? overlayRect.left + inset.left - half
48
- : overlayRect.left + overlayRect.width - inset.right - half;
49
- style.top =
46
+ ? overlayRect.left + inset.left
47
+ : overlayRect.left + overlayRect.width - inset.right;
48
+ const cornerY =
50
49
  def.y === "top"
51
- ? overlayRect.top + inset.top - half
52
- : overlayRect.top + overlayRect.height - inset.bottom - half;
53
- return style;
50
+ ? overlayRect.top + inset.top
51
+ : overlayRect.top + overlayRect.height - inset.bottom;
52
+ const slideOutToFreeMiddleX =
53
+ (def.x === "left" ? -1 : 1) *
54
+ Math.max(0, half - (overlayRect.width - inset.left - inset.right) / 4);
55
+ const slideOutToFreeMiddleY =
56
+ (def.y === "top" ? -1 : 1) *
57
+ Math.max(0, half - (overlayRect.height - inset.top - inset.bottom) / 4);
58
+ return {
59
+ style: {
60
+ cursor: def.cursor,
61
+ touchAction: "none",
62
+ left: cornerX - half + slideOutToFreeMiddleX,
63
+ top: cornerY - half + slideOutToFreeMiddleY,
64
+ },
65
+ anchor: { left: half - slideOutToFreeMiddleX, top: half - slideOutToFreeMiddleY },
66
+ };
54
67
  }
55
68
 
56
69
  type GestureHandlers = ReturnType<typeof createDomEditOverlayGestureHandlers>;
@@ -242,26 +255,39 @@ export function DomEditSelectionChrome({
242
255
  {canManipulate &&
243
256
  !editing &&
244
257
  selection.capabilities.canApplyManualSize &&
245
- RESIZE_HANDLE_DEFS.map((def) =>
246
- def.handle !== "se" && !selection.capabilities.canApplyManualOffset ? null : (
258
+ RESIZE_HANDLE_DEFS.map((def) => {
259
+ if (def.handle !== "se" && !selection.capabilities.canApplyManualOffset) return null;
260
+ const layout = resizeHandleLayout(def, overlayRect, cropOutlineInsetPx ?? undefined);
261
+ return (
247
262
  <div
248
263
  key={def.handle}
249
264
  className="pointer-events-auto absolute flex h-4 w-4 items-center justify-center"
250
265
  style={{
251
- ...resizeHandleStyle(def, overlayRect, cropOutlineInsetPx ?? undefined),
266
+ ...layout.style,
252
267
  // Cursor rotates with the object: bucket the corner's base
253
268
  // diagonal + element rotation into the 8 CSS resize cursors.
254
269
  cursor: resolveRotatedResizeCursor(def.handle, overlayRect.angle ?? 0),
255
270
  }}
256
271
  onPointerDown={(e) => {
257
272
  e.stopPropagation();
258
- gestures.startGesture("resize", e, { resizeHandle: def.handle });
273
+ const corner = e.currentTarget
274
+ .querySelector("[data-resize-corner]")
275
+ ?.getBoundingClientRect();
276
+ gestures.startGesture("resize", e, {
277
+ resizeHandle: def.handle,
278
+ resizeCorner: corner && { x: corner.left, y: corner.top },
279
+ });
259
280
  }}
260
281
  >
282
+ <span
283
+ data-resize-corner
284
+ className="pointer-events-none absolute"
285
+ style={layout.anchor}
286
+ />
261
287
  <div className="pointer-events-none h-[12px] w-[12px] rounded-full border-[1.5px] border-studio-accent bg-white shadow-[0_0_3px_rgba(0,0,0,0.45)]" />
262
288
  </div>
263
- ),
264
- )}
289
+ );
290
+ })}
265
291
  </div>
266
292
  {/* Crop owns its element-local oriented frame. Keep it outside the chrome's
267
293
  rotated plane or a rotated selection applies the angle twice. */}
@@ -9,7 +9,7 @@ import type { DomEditLayerItem } from "./domEditingTypes";
9
9
  import { createRafThrottle, LayersPanel, sortLayersByZIndex } from "./LayersPanel";
10
10
  import { isLayerDraggable } from "./useLayerDrag";
11
11
  import { liveTime } from "../../player";
12
- import { sceneSwapFor } from "../../player/sceneSwap";
12
+ import { announcePreviewDocumentLoaded, sceneSwapFor } from "../../player/sceneSwap";
13
13
 
14
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
15
 
@@ -284,12 +284,12 @@ describe("LayersPanel preview promotion", () => {
284
284
  mocks.collect.mockClear();
285
285
 
286
286
  act(() => {
287
- a.dispatchEvent(new Event("load"));
287
+ announcePreviewDocumentLoaded(a);
288
288
  });
289
289
  expect(mocks.collect).not.toHaveBeenCalled();
290
290
 
291
291
  act(() => {
292
- b.dispatchEvent(new Event("load"));
292
+ announcePreviewDocumentLoaded(b);
293
293
  });
294
294
  expect(collectedRootIds()).toEqual(["b"]);
295
295
  });
@@ -325,7 +325,7 @@ describe("LayersPanel preview promotion", () => {
325
325
  mocks.previewIframeRef.current = iframe;
326
326
  usePreviewIframeStore.setState({ iframe });
327
327
  act(() => root.render(createElement(LayersPanel)));
328
- act(() => void iframe.dispatchEvent(new Event("load")));
328
+ act(() => void announcePreviewDocumentLoaded(iframe));
329
329
 
330
330
  const rows = [...host.querySelectorAll("[data-layer-index]")];
331
331
  expect(rows).toHaveLength(2);
@@ -89,8 +89,8 @@ interface CollapsedState {
89
89
  [key: string]: boolean;
90
90
  }
91
91
 
92
- /** Studio state the panel reads; passed by hosts outside Studio's providers. Keep previewIframeRef stable,
93
- * and bump refreshKey when the preview document is rebuilt without an iframe load. */
92
+ /** Studio state the panel reads; passed by hosts outside Studio's providers. Keep previewIframeRef stable;
93
+ * a host with its own player calls announcePreviewDocumentLoaded on each new page, or bumps refreshKey. */
94
94
  export type LayersPanelHost = Pick<
95
95
  StudioShellValue,
96
96
  "previewIframeRef" | "activeCompPath" | "showToast"
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { studioEditLifecycle, type StudioWriteResult } from "../../webmcp/writeCoordinator";
7
7
  import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
8
+ import { announcePreviewDocumentLoaded } from "../../player/sceneSwap";
8
9
  import { TopologyLens } from "./TopologyLens";
9
10
 
10
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -259,7 +260,7 @@ describe("TopologyLens", () => {
259
260
  mount();
260
261
  begin();
261
262
 
262
- act(() => iframe?.dispatchEvent(new Event("load")));
263
+ act(() => announcePreviewDocumentLoaded(iframe!));
263
264
  expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
264
265
 
265
266
  begin();
@@ -286,7 +287,7 @@ describe("TopologyLens", () => {
286
287
  root = null;
287
288
 
288
289
  expect(vi.getTimerCount()).toBe(1);
289
- expect(removeListener).toHaveBeenCalledWith("load", expect.any(Function));
290
+ expect(removeListener).toHaveBeenCalledWith("hf-preview-document-loaded", expect.any(Function));
290
291
  expect(studioEditLifecycle.getSnapshot()).toMatchObject({ phase: "dispatching" });
291
292
  act(() => vi.advanceTimersByTime(0));
292
293
  expect(vi.getTimerCount()).toBe(0);
@@ -312,10 +313,10 @@ describe("TopologyLens", () => {
312
313
  ref.current = b;
313
314
  usePreviewIframeStore.getState().setIframe(b);
314
315
  });
315
- act(() => a.dispatchEvent(new Event("load")));
316
+ act(() => announcePreviewDocumentLoaded(a));
316
317
  expect(host.querySelector('[data-topology-lens="hidden"]')).toBeNull();
317
318
 
318
- act(() => b.dispatchEvent(new Event("load")));
319
+ act(() => announcePreviewDocumentLoaded(b));
319
320
  expect(host.querySelector('[data-topology-lens="hidden"]')).not.toBeNull();
320
321
  usePreviewIframeStore.setState({ iframe: null });
321
322
  });
@@ -259,6 +259,42 @@ describe("anchored corner resize — the release commit feeds the center-pin off
259
259
  expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
260
260
  });
261
261
 
262
+ it("puts the grabbed corner on the pointer when the size rounds to whole px", () => {
263
+ const { handlers, commits } = buildHarness();
264
+ const grabbed = { x: ORIGIN_CENTER.x + 100, y: ORIGIN_CENTER.y + 50 };
265
+ handlers.startGesture("resize", evt(grabbed.x, grabbed.y), {
266
+ resizeHandle: "se",
267
+ resizeCorner: grabbed,
268
+ });
269
+ // Radial scale 1.503: the pointer asks for 300.6 x 150.3, Studio writes 301 x 150.
270
+ for (let i = 0; i < 5; i++)
271
+ handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150.3, ORIGIN_CENTER.y + 75.15));
272
+ handlers.onPointerUp(evt(ORIGIN_CENTER.x + 150.3, ORIGIN_CENTER.y + 75.15));
273
+
274
+ const { size, offset } = commits[0]!;
275
+ expect(size).toEqual({ width: 301, height: 150 });
276
+ const corner = {
277
+ x: ORIGIN_CENTER.x + (size.width - ORIGIN.width) / 2 + offset!.x + size.width / 2,
278
+ y: ORIGIN_CENTER.y + (size.height - ORIGIN.height) / 2 + offset!.y + size.height / 2,
279
+ };
280
+ expect(corner.x).toBeCloseTo(ORIGIN_CENTER.x + 300.6 / 2, 6);
281
+ expect(corner.y).toBeCloseTo(ORIGIN_CENTER.y + 150.3 / 2, 6);
282
+ });
283
+
284
+ it("moves the grabbed corner with the cursor when the press lands beside it", () => {
285
+ const { handlers, commits } = buildHarness();
286
+ handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 106, ORIGIN_CENTER.y), {
287
+ resizeHandle: "se",
288
+ resizeCorner: { x: ORIGIN_CENTER.x + 100, y: ORIGIN_CENTER.y },
289
+ });
290
+ handlers.onPointerMove(evt(ORIGIN_CENTER.x + 156, ORIGIN_CENTER.y));
291
+ handlers.onPointerUp(evt(ORIGIN_CENTER.x + 156, ORIGIN_CENTER.y));
292
+
293
+ expect(commits).toHaveLength(1);
294
+ expect(commits[0]!.size.width).toBeCloseTo(300, 1);
295
+ expect(commits[0]!.size.height).toBeCloseTo(150, 1);
296
+ });
297
+
262
298
  it("keeps the centre on the first frame when the authored translate is a percent", async () => {
263
299
  const { orientedOverlayRect } = await import("./domEditOverlayGeometry");
264
300
  authored.percent = 0.25;
@@ -93,6 +93,7 @@ export interface GestureState {
93
93
  * when the corner geometry can't be measured (member creation still succeeded).
94
94
  */
95
95
  resizeFixedCenterStart?: { x: number; y: number };
96
+ resizePressFromCorner?: { x: number; y: number };
96
97
  }
97
98
 
98
99
  export interface GroupGestureState {
@@ -147,6 +147,7 @@ export function startGesture(
147
147
  selection?: DomEditSelection;
148
148
  rect?: OverlayRect | null;
149
149
  resizeHandle?: ResizeHandle;
150
+ resizeCorner?: { x: number; y: number };
150
151
  at?: EditMoment;
151
152
  },
152
153
  ): boolean {
@@ -304,6 +305,10 @@ export function startGesture(
304
305
  manualEditDragToken,
305
306
  snapContext,
306
307
  resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
308
+ resizePressFromCorner:
309
+ kind === "resize" && options?.resizeCorner
310
+ ? { x: e.clientX - options.resizeCorner.x, y: e.clientY - options.resizeCorner.y }
311
+ : undefined,
307
312
  resizeFixedCenterStart,
308
313
  };
309
314
  if (kind === "resize") {
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ resizeRemainderShift,
3
4
  resolveCenterResizeScale,
4
5
  resolveCenterResizeSize,
5
6
  resolveRotatedResizeCursor,
@@ -33,11 +34,21 @@ describe("resolveCenterResizeScale — radial distance from the center", () => {
33
34
  expect(
34
35
  resolveCenterResizeScale({
35
36
  centerStart: { x: 100, y: 100 },
36
- pointerStart: { x: 101, y: 100 },
37
+ pointerStart: { x: 100.2, y: 100 },
37
38
  pointer: { x: 400, y: 400 },
38
39
  }),
39
40
  ).toBe(1);
40
41
  });
42
+
43
+ it("scales a 4x4 pick whose grabbed corner sits under 3px from its center", () => {
44
+ expect(
45
+ resolveCenterResizeScale({
46
+ centerStart: { x: 100, y: 100 },
47
+ pointerStart: { x: 102, y: 102 },
48
+ pointer: { x: 104, y: 104 },
49
+ }),
50
+ ).toBeCloseTo(2, 9);
51
+ });
41
52
  });
42
53
 
43
54
  describe("resolveCenterResizeSize — rotation is a non-event (radial distance)", () => {
@@ -129,3 +140,72 @@ describe("resolveRotatedResizeCursor", () => {
129
140
  expect(resolveRotatedResizeCursor("se", -90)).toBe("nesw-resize");
130
141
  });
131
142
  });
143
+
144
+ describe("resizeRemainderShift", () => {
145
+ const written = { width: 301, height: 150 };
146
+ const wanted = { width: 300.6, height: 150.3 };
147
+ const centre = { x: 400, y: 300 };
148
+ // Local (u, v) px map to overlay px through a 1.5x scale, a rotation and an optional x mirror.
149
+ const box = (angle: number, mirror: 1 | -1) => {
150
+ const at = (u: number, v: number) => ({
151
+ x: centre.x + 1.5 * (mirror * u * Math.cos(angle) - v * Math.sin(angle)),
152
+ y: centre.y + 1.5 * (mirror * u * Math.sin(angle) + v * Math.cos(angle)),
153
+ });
154
+ const [w, h] = [written.width / 2, written.height / 2];
155
+ return { at, corners: { nw: at(-w, -h), ne: at(w, -h), sw: at(-w, h), se: at(w, h) } };
156
+ };
157
+
158
+ // A rotated parent composes into the same map, and a mirror moves each handle onto another local corner.
159
+ it.each([
160
+ [30, 1, -1, -1],
161
+ [30, 1, 1, -1],
162
+ [30, 1, -1, 1],
163
+ [30, 1, 1, 1],
164
+ [-120, 1, 1, 1],
165
+ [90, 1, 1, -1],
166
+ [0, -1, -1, 1],
167
+ [150, -1, 1, 1],
168
+ [180, -1, -1, -1],
169
+ ] as const)("at %i deg, mirror %i, lands local corner (%i, %i)", (deg, mirror, su, sv) => {
170
+ const { at, corners } = box((deg * Math.PI) / 180, mirror);
171
+ const grabbed = at((su * written.width) / 2, (sv * written.height) / 2);
172
+ const grab = { x: grabbed.x - centre.x, y: grabbed.y - centre.y };
173
+ const shift = resizeRemainderShift(corners, grab, { wanted, written });
174
+ const target = at((su * wanted.width) / 2, (sv * wanted.height) / 2);
175
+ expect(grabbed.x + shift.x).toBeCloseTo(target.x, 9);
176
+ expect(grabbed.y + shift.y).toBeCloseTo(target.y, 9);
177
+ });
178
+ });
179
+
180
+ describe("resizeRemainderShift on a skewed box", () => {
181
+ // A 30 deg child of a parent scaled 2 x 1 renders as a parallelogram; a 10 x 200 bar flips a projected sign.
182
+ const angle = Math.PI / 6;
183
+ const at = (u: number, v: number) => ({
184
+ x: 400 + 2 * (u * Math.cos(angle) - v * Math.sin(angle)),
185
+ y: 300 + (u * Math.sin(angle) + v * Math.cos(angle)),
186
+ });
187
+ const written = { width: 10, height: 200 };
188
+ const wanted = { width: 10.4, height: 199.7 };
189
+ const corners = { nw: at(-5, -100), ne: at(5, -100), sw: at(-5, 100), se: at(5, 100) };
190
+
191
+ it.each([
192
+ [1, 1],
193
+ [-1, -1],
194
+ [1, -1],
195
+ [-1, 1],
196
+ ] as const)("lands local corner (%i, %i) on the wanted size", (su, sv) => {
197
+ const grabbed = at((su * written.width) / 2, (sv * written.height) / 2);
198
+ const grab = { x: grabbed.x - 400, y: grabbed.y - 300 };
199
+ const shift = resizeRemainderShift(corners, grab, { wanted, written });
200
+ const target = at((su * wanted.width) / 2, (sv * wanted.height) / 2);
201
+ expect(grabbed.x + shift.x).toBeCloseTo(target.x, 9);
202
+ expect(grabbed.y + shift.y).toBeCloseTo(target.y, 9);
203
+ });
204
+ });
205
+
206
+ it("shifts nothing for a box that renders with no area", () => {
207
+ const p = { x: 400, y: 300 };
208
+ const corners = { nw: p, ne: p, sw: p, se: p };
209
+ const sizes = { wanted: { width: 300.6, height: 150.3 }, written: { width: 301, height: 150 } };
210
+ expect(resizeRemainderShift(corners, { x: 1, y: 1 }, sizes)).toEqual({ x: 0, y: 0 });
211
+ });
@@ -14,6 +14,7 @@
14
14
  * rendered center, feeding the center-pin translate through the manual-offset
15
15
  * channel) lives in useDomEditOverlayGestures.ts.
16
16
  */
17
+ import type { FixedCorner } from "./domEditOverlayGeometry";
17
18
  import type { ResizeHandle } from "./domEditOverlayGestures";
18
19
 
19
20
  /** Minimum element edge in LOCAL px — mirrors the old MIN_RESIZE_EDGE_PX clamp
@@ -21,11 +22,11 @@ import type { ResizeHandle } from "./domEditOverlayGestures";
21
22
  const MIN_RESIZE_LOCAL_PX = 1;
22
23
 
23
24
  /**
24
- * Below this pointer-to-center distance (overlay px) the gesture started at (or
25
+ * Below this start-to-center distance (overlay px) the gesture started at (or
25
26
  * effectively at) the center, so the ratio is degenerate (division by ~0). Bail to
26
- * scale 1 rather than blow up.
27
+ * scale 1 rather than blow up. A handle starts from its corner, so a pick a few px across still resizes.
27
28
  */
28
- const DEGENERATE_START_DIST_PX = 3;
29
+ const DEGENERATE_START_DIST_PX = 0.5;
29
30
 
30
31
  /**
31
32
  * The proportional scale factor for a center-anchored resize: the ratio of the
@@ -112,6 +113,29 @@ export function resolveRotatedResizeCursor(handle: ResizeHandle, rotationDeg: nu
112
113
  return CURSORS_8[bucket]!;
113
114
  }
114
115
 
116
+ type Size = { width: number; height: number };
117
+ /** The size the pointer asks for and the whole-px size written for it. */
118
+ export type ResizeDraftSizes = { wanted: Size; written: Size };
119
+
120
+ /** Overlay shift of the pinned center that puts the grabbed corner where the wanted size would: the
121
+ * written size is whole px, so half the remainder moves along the element's own rendered edges. */
122
+ export function resizeRemainderShift(
123
+ corners: Record<FixedCorner, { x: number; y: number }>,
124
+ grab: { x: number; y: number },
125
+ { wanted, written }: ResizeDraftSizes,
126
+ ): { x: number; y: number } {
127
+ const u = { x: corners.ne.x - corners.nw.x, y: corners.ne.y - corners.nw.y };
128
+ const v = { x: corners.sw.x - corners.nw.x, y: corners.sw.y - corners.nw.y };
129
+ // `grab` in edge coordinates: which side of each edge the grabbed corner is, even on a skewed box.
130
+ const det = u.x * v.y - u.y * v.x;
131
+ if (!det) return { x: 0, y: 0 };
132
+ const su = Math.sign((grab.x * v.y - grab.y * v.x) / det);
133
+ const sv = Math.sign((u.x * grab.y - u.y * grab.x) / det);
134
+ const kw = (su * (wanted.width - written.width)) / (2 * written.width);
135
+ const kh = (sv * (wanted.height - written.height)) / (2 * written.height);
136
+ return { x: u.x * kw + v.x * kh, y: u.y * kw + v.y * kh };
137
+ }
138
+
115
139
  /** Per-frame anchored-resize center accumulator: ADD the residual center correction
116
140
  * (fixedStart − fixedNow) onto the previous anchor so the pin CONVERGES instead of
117
141
  * oscillating (fa4f39168). Pure; exported for the release-shift characterization tests. */
@@ -1,3 +1,4 @@
1
+ import { PREVIEW_ONLY_ATTRS } from "@hyperframes/core/studio-preview-mark";
1
2
  import { knownSourceAnswer, probeSourceElement, type ProbeTarget } from "./probeSourceElement";
2
3
  import { isAudibleVideoNode } from "../../player/lib/timelineElementHelpers";
3
4
  import type { PatchOperation } from "../../utils/sourcePatcher";
@@ -55,6 +56,8 @@ function getTextFieldLabel(
55
56
  return `Text ${index + 1}`;
56
57
  }
57
58
 
59
+ const NOT_COPIED_ATTRS = new Set<string>(["style", ...PREVIEW_ONLY_ATTRS]);
60
+
58
61
  function buildTextField(
59
62
  el: HTMLElement,
60
63
  index: number,
@@ -70,7 +73,7 @@ function buildTextField(
70
73
  value: el.textContent ?? "",
71
74
  tagName,
72
75
  attributes: Array.from(el.attributes)
73
- .filter((attribute) => attribute.name !== "style")
76
+ .filter((attribute) => !NOT_COPIED_ATTRS.has(attribute.name))
74
77
  .map((attribute) => ({
75
78
  name: attribute.name,
76
79
  value: attribute.value,