@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.
- package/dist/assets/{hyperframes-player-BXSuksFh.js → hyperframes-player-DZ5JrMDg.js} +3 -3
- package/dist/assets/{index-FgyO_5Kv.js → index-BOs6C0aZ.js} +1 -1
- package/dist/assets/{index-BgOVCqjo.js → index-BqFRhNZa.js} +1 -1
- package/dist/assets/{index-J7jpL4pj.js → index-D8W0a0Z9.js} +149 -149
- package/dist/{chunk-TFLU2MGI.js → chunk-6JY3MTIX.js} +108 -2
- package/dist/chunk-6JY3MTIX.js.map +1 -0
- package/dist/{chunk-H2DUOXTF.js → chunk-NZPJUZKT.js} +100 -4
- package/dist/chunk-NZPJUZKT.js.map +1 -0
- package/dist/{chunk-UYWON5AX.js → chunk-TP6D3U37.js} +7 -100
- package/dist/chunk-TP6D3U37.js.map +1 -0
- package/dist/{chunk-LOVBLSMR.js → chunk-Y2OHHNM2.js} +35 -19
- package/dist/chunk-Y2OHHNM2.js.map +1 -0
- package/dist/{domEditingLayers-KBTPQKGA.js → domEditingLayers-PTP2ICGM.js} +3 -3
- package/dist/index.d.ts +8 -5
- package/dist/index.html +1 -1
- package/dist/index.js +545 -495
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/ui.d.ts +4 -5
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/StudioLeftPanels.tsx +2 -2
- package/src/components/editor/DomEditSelectionChrome.test.tsx +103 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +42 -16
- package/src/components/editor/LayersPanel.test.ts +4 -4
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.test.tsx +5 -4
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +36 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -0
- package/src/components/editor/domEditResizeLocal.test.ts +81 -1
- package/src/components/editor/domEditResizeLocal.ts +27 -3
- package/src/components/editor/domEditingLayers.ts +4 -1
- package/src/components/editor/domEditingTextFields.test.ts +18 -0
- package/src/components/editor/manualEditsDom.ts +12 -12
- package/src/components/editor/previewGestureStarts.ts +14 -5
- package/src/components/editor/resizeDraft.ts +13 -4
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -3
- package/src/components/renders/GpuEncodingToggle.tsx +28 -0
- package/src/components/renders/RenderQueue.test.tsx +20 -2
- package/src/components/renders/RenderQueue.tsx +13 -1
- package/src/components/renders/RenderQueuePanel.tsx +2 -1
- package/src/components/renders/renderSettings.ts +5 -2
- package/src/components/renders/useRenderQueue.ts +3 -0
- package/src/components/renders/useRenderQueueComposition.test.tsx +12 -0
- package/src/components/ui/Menu.test.tsx +67 -5
- package/src/components/ui/Menu.tsx +21 -4
- package/src/components/ui/Popover.tsx +11 -5
- package/src/hooks/gsapResizeIntercept.test.ts +46 -11
- package/src/hooks/gsapResizeIntercept.ts +25 -20
- package/src/hooks/useConsoleErrorCapture.test.tsx +21 -2
- package/src/hooks/useConsoleErrorCapture.ts +3 -2
- package/src/hooks/useThumbnailStripSize.test.tsx +123 -0
- package/src/hooks/useThumbnailStripSize.ts +19 -8
- package/src/index.ts +1 -0
- package/src/player/components/CompositionThumbnail.test.ts +3 -0
- package/src/player/components/ImageThumbnail.test.tsx +4 -1
- package/src/player/components/Player.test.ts +44 -0
- package/src/player/components/Player.tsx +11 -0
- package/src/player/components/VideoThumbnail.test.tsx +13 -2
- package/src/player/hooks/timelineSyncHydration.ts +2 -1
- package/src/player/lib/playbackTypes.ts +1 -0
- package/src/player/sceneSwap.test.ts +27 -1
- package/src/player/sceneSwap.ts +16 -2
- package/src/utils/authoredSource.test.ts +25 -0
- package/src/utils/authoredSource.ts +3 -5
- package/src/utils/rounding.ts +7 -0
- package/dist/chunk-H2DUOXTF.js.map +0 -1
- package/dist/chunk-LOVBLSMR.js.map +0 -1
- package/dist/chunk-TFLU2MGI.js.map +0 -1
- package/dist/chunk-UYWON5AX.js.map +0 -1
- /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-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
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
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/parsers": "0.8.
|
|
68
|
-
"@hyperframes/
|
|
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.
|
|
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
|
|
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
|
-
|
|
44
|
+
const cornerX =
|
|
46
45
|
def.x === "left"
|
|
47
|
-
? overlayRect.left + inset.left
|
|
48
|
-
: overlayRect.left + overlayRect.width - inset.right
|
|
49
|
-
|
|
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
|
|
52
|
-
: overlayRect.top + overlayRect.height - inset.bottom
|
|
53
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
|
287
|
+
announcePreviewDocumentLoaded(a);
|
|
288
288
|
});
|
|
289
289
|
expect(mocks.collect).not.toHaveBeenCalled();
|
|
290
290
|
|
|
291
291
|
act(() => {
|
|
292
|
-
b
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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("
|
|
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
|
|
316
|
+
act(() => announcePreviewDocumentLoaded(a));
|
|
316
317
|
expect(host.querySelector('[data-topology-lens="hidden"]')).toBeNull();
|
|
317
318
|
|
|
318
|
-
act(() => b
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
|
76
|
+
.filter((attribute) => !NOT_COPIED_ATTRS.has(attribute.name))
|
|
74
77
|
.map((attribute) => ({
|
|
75
78
|
name: attribute.name,
|
|
76
79
|
value: attribute.value,
|