@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
|
@@ -455,10 +455,31 @@ it("does not mix the scale shorthand into a tween that speaks longhands", async
|
|
|
455
455
|
* The fixture models the geometry the browser reported: a 630x252 element
|
|
456
456
|
* dragged from x=432 to x=587 with its box drafted down to 320x128, dropped at
|
|
457
457
|
* a committed scale of 0.837. Scaling about the centre puts it back on the drop
|
|
458
|
-
* point at its pre-gesture position, so the
|
|
459
|
-
*
|
|
458
|
+
* point at its pre-gesture position, so the persisted correction is only the
|
|
459
|
+
* drop's sub-pixel offset.
|
|
460
460
|
*/
|
|
461
|
-
it(
|
|
461
|
+
it.each([
|
|
462
|
+
{
|
|
463
|
+
name: "does not move a statically positioned element when a scale resize lands",
|
|
464
|
+
shift: { x: 0, y: 0 },
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: "puts a statically positioned element on a sub-pixel drop point",
|
|
468
|
+
shift: { x: 0.3, y: -0.2 },
|
|
469
|
+
writes: true,
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
name: "leaves a static position alone when the drop is off by less than the file's precision",
|
|
473
|
+
shift: { x: 0.0003, y: 0 },
|
|
474
|
+
writes: false,
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: "leaves an animated position alone for a sub-pixel drop point",
|
|
478
|
+
shift: { x: 0.3, y: -0.2 },
|
|
479
|
+
animated: true,
|
|
480
|
+
writes: false,
|
|
481
|
+
},
|
|
482
|
+
])("$name", async ({ shift, animated, writes }) => {
|
|
462
483
|
document.body.innerHTML = "";
|
|
463
484
|
const el = document.createElement("div");
|
|
464
485
|
el.id = "clip";
|
|
@@ -476,7 +497,7 @@ it("does not move a statically positioned element when a scale resize lands", as
|
|
|
476
497
|
el.style.height = "128px";
|
|
477
498
|
document.body.append(el);
|
|
478
499
|
|
|
479
|
-
const pos = { x: 587, y: 235 };
|
|
500
|
+
const pos = { x: 587 + shift.x, y: 235 + shift.y };
|
|
480
501
|
const scale = { x: 1.648, y: 1.648 };
|
|
481
502
|
const [LEFT, TOP] = [120, 520];
|
|
482
503
|
el.getBoundingClientRect = () => {
|
|
@@ -517,6 +538,15 @@ it("does not move a statically positioned element when a scale resize lands", as
|
|
|
517
538
|
duration: 0,
|
|
518
539
|
global: true,
|
|
519
540
|
} as unknown as GsapAnimation;
|
|
541
|
+
const positionTween = {
|
|
542
|
+
...positionHold,
|
|
543
|
+
id: "#clip-to-0-position",
|
|
544
|
+
method: "to",
|
|
545
|
+
properties: { x: 500 },
|
|
546
|
+
duration: 4,
|
|
547
|
+
global: false,
|
|
548
|
+
} as unknown as GsapAnimation;
|
|
549
|
+
const position = animated ? positionTween : positionHold;
|
|
520
550
|
const selection = { id: "clip", selector: "#clip", element: el } as DomEditSelection;
|
|
521
551
|
usePlayerStore.setState({ currentTime: 0.5 });
|
|
522
552
|
const commitMutation = vi.fn();
|
|
@@ -524,22 +554,27 @@ it("does not move a statically positioned element when a scale resize lands", as
|
|
|
524
554
|
await tryGsapResizeIntercept(
|
|
525
555
|
selection,
|
|
526
556
|
{ width: 320, height: 128 },
|
|
527
|
-
[keyframedScaleFixture(),
|
|
557
|
+
[keyframedScaleFixture(), position],
|
|
528
558
|
iframe,
|
|
529
559
|
commitMutation,
|
|
530
|
-
async () => [keyframedScaleFixture(),
|
|
560
|
+
async () => [keyframedScaleFixture(), position],
|
|
531
561
|
);
|
|
532
562
|
|
|
533
563
|
const positionWrites = commitMutation.mock.calls
|
|
534
564
|
.map((call) => call[1] as { properties?: Record<string, number> })
|
|
535
565
|
.filter((mutation) => mutation.properties?.x != null || mutation.properties?.y != null);
|
|
536
|
-
//
|
|
566
|
+
// No position write, or one that moves the hold by exactly the drop's sub-pixel offset.
|
|
567
|
+
if (writes === false) expect(positionWrites).toEqual([]);
|
|
568
|
+
if (writes) expect(positionWrites.length).toBeGreaterThan(0);
|
|
537
569
|
for (const write of positionWrites) {
|
|
538
|
-
expect(write.properties?.x).
|
|
539
|
-
expect(write.properties?.y).
|
|
570
|
+
expect(write.properties?.x).toBeCloseTo(432 + shift.x, 3);
|
|
571
|
+
expect(write.properties?.y).toBeCloseTo(173 + shift.y, 3);
|
|
540
572
|
}
|
|
541
|
-
// And the live
|
|
542
|
-
|
|
573
|
+
// And the live box is centred where it was dropped (left 120 + 587 + 160, top 520 + 235 + 64).
|
|
574
|
+
const landed = animated ? { x: 0, y: 0 } : shift;
|
|
575
|
+
const box = el.getBoundingClientRect();
|
|
576
|
+
expect(box.x + box.width / 2).toBeCloseTo(867 + landed.x, 3);
|
|
577
|
+
expect(box.y + box.height / 2).toBeCloseTo(819 + landed.y, 3);
|
|
543
578
|
});
|
|
544
579
|
|
|
545
580
|
function titleSelection(): DomEditSelection {
|
|
@@ -170,6 +170,11 @@ function tweenUsesScaleLonghands(anim: GsapAnimation | null): boolean {
|
|
|
170
170
|
|
|
171
171
|
// ── Resize intercept ──────────────────────────────────────────────────────
|
|
172
172
|
|
|
173
|
+
// The resize is centre-anchored, so a scale that cannot reproduce the dragged size splits the gap evenly.
|
|
174
|
+
function rectCentre(rect: DOMRect): { x: number; y: number } {
|
|
175
|
+
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
|
176
|
+
}
|
|
177
|
+
|
|
173
178
|
// fallow-ignore-next-line complexity
|
|
174
179
|
export async function tryGsapResizeIntercept(
|
|
175
180
|
selection: DomEditSelection,
|
|
@@ -371,15 +376,10 @@ export async function tryGsapResizeIntercept(
|
|
|
371
376
|
committedScale = useScaleLonghands
|
|
372
377
|
? { x: newScaleX, y: newScaleY }
|
|
373
378
|
: { x: newScaleX, y: newScaleX };
|
|
374
|
-
// Where the user DROPPED the box: the draft
|
|
375
|
-
//
|
|
376
|
-
// what the preview showed at release. The committed scale renders around
|
|
377
|
-
// the element CENTER instead — the finalize step below measures that
|
|
378
|
-
// difference and compensates, so release matches the drop pixel-for-pixel
|
|
379
|
-
// regardless of live scale or repeat resizes.
|
|
379
|
+
// Where the user DROPPED the box: the draft is still applied, so this is what the preview showed
|
|
380
|
+
// at release. The finalize step below moves the committed scale's box onto it.
|
|
380
381
|
if (el) {
|
|
381
|
-
|
|
382
|
-
scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y };
|
|
382
|
+
scaleDraftDropPoint = draw(() => rectCentre(el.getBoundingClientRect()));
|
|
383
383
|
}
|
|
384
384
|
} else {
|
|
385
385
|
resizeProps = {
|
|
@@ -396,8 +396,8 @@ export async function tryGsapResizeIntercept(
|
|
|
396
396
|
// position (a `tl.set` hold or none) — a keyframed position path has no
|
|
397
397
|
// single anchor to preserve, so it keeps the plain center-scale behavior.
|
|
398
398
|
// The size route hands its draft to GSAP itself (handOverDraftSize).
|
|
399
|
-
// ponytail:
|
|
400
|
-
//
|
|
399
|
+
// ponytail: the centre of an AABB is exact for any 2D transform; under
|
|
400
|
+
// perspective it is approximate.
|
|
401
401
|
// fallow-ignore-next-line complexity
|
|
402
402
|
const finalizeScaleResizeCommit = async (): Promise<boolean> => {
|
|
403
403
|
// Only the scale route captures the element, so a null draft means this
|
|
@@ -428,15 +428,9 @@ export async function tryGsapResizeIntercept(
|
|
|
428
428
|
);
|
|
429
429
|
const base = { x: baseGsapX, y: baseGsapY };
|
|
430
430
|
setElementGsapPosition(draftEl, base.x, base.y);
|
|
431
|
-
const post = draftEl.getBoundingClientRect();
|
|
431
|
+
const post = rectCentre(draftEl.getBoundingClientRect());
|
|
432
432
|
const residual = { x: dropPoint.x - post.x, y: dropPoint.y - post.y };
|
|
433
433
|
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return null;
|
|
434
|
-
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
|
|
435
|
-
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
|
|
436
|
-
// Settled, with nothing to write. Still ours: forwarding the drag offset
|
|
437
|
-
// on top would move the box off the point it is already sitting on.
|
|
438
|
-
return "settled" as const;
|
|
439
|
-
}
|
|
440
434
|
// The ONE corrected position — rounded once so the live runtime and the
|
|
441
435
|
// persisted file agree exactly (commitStaticGsapPosition composes the same
|
|
442
436
|
// rounded value from this delta).
|
|
@@ -444,9 +438,15 @@ export async function tryGsapResizeIntercept(
|
|
|
444
438
|
x: roundTo3(base.x + residual.x),
|
|
445
439
|
y: roundTo3(base.y + residual.y),
|
|
446
440
|
};
|
|
441
|
+
if (corrected.x === roundTo3(base.x) && corrected.y === roundTo3(base.y)) {
|
|
442
|
+
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
|
|
443
|
+
// Settled, with nothing to write. Still ours: forwarding the drag offset
|
|
444
|
+
// on top would move the box off the point it is already sitting on.
|
|
445
|
+
return "settled" as const;
|
|
446
|
+
}
|
|
447
447
|
logResize("scale-finalize", {
|
|
448
448
|
dropPoint,
|
|
449
|
-
post
|
|
449
|
+
post,
|
|
450
450
|
residual,
|
|
451
451
|
gsapPos,
|
|
452
452
|
base,
|
|
@@ -455,11 +455,11 @@ export async function tryGsapResizeIntercept(
|
|
|
455
455
|
// Correct the live box in the same task as the measurement, so no frame shows it off the drop
|
|
456
456
|
// point while the position write is in flight.
|
|
457
457
|
setElementGsapPosition(draftEl, corrected.x, corrected.y);
|
|
458
|
-
return { base, corrected };
|
|
458
|
+
return { base, corrected, residual };
|
|
459
459
|
});
|
|
460
460
|
if (measured === null) return false;
|
|
461
461
|
if (measured === "settled") return true;
|
|
462
|
-
const { base, corrected } = measured;
|
|
462
|
+
const { base, corrected, residual } = measured;
|
|
463
463
|
// Re-fetch: the scale commit above just rewrote the script, so the caller's
|
|
464
464
|
// animation list (and its ids) may be stale for the position lookup.
|
|
465
465
|
const currentAnimations = fetchFallbackAnimations
|
|
@@ -482,6 +482,11 @@ export async function tryGsapResizeIntercept(
|
|
|
482
482
|
moment.time,
|
|
483
483
|
);
|
|
484
484
|
if (positionTween) {
|
|
485
|
+
// A sub-pixel correction is not worth a keyframe in an authored tween.
|
|
486
|
+
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
|
|
487
|
+
draw(() => setElementGsapPosition(draftEl, base.x, base.y));
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
485
490
|
logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
|
|
486
491
|
assertGsapEditPersisted(
|
|
487
492
|
await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
|
|
@@ -4,6 +4,7 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, expect, it } from "vitest";
|
|
6
6
|
import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
|
|
7
|
+
import { announcePreviewDocumentLoaded } from "../player/sceneSwap";
|
|
7
8
|
import { useConsoleErrorCapture } from "./useConsoleErrorCapture";
|
|
8
9
|
|
|
9
10
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
@@ -36,7 +37,7 @@ it("shows the errors a preview raised before its load, once each", async () => {
|
|
|
36
37
|
root = createRoot(document.createElement("div"));
|
|
37
38
|
await act(async () => root?.render(React.createElement(Harness, { iframe })));
|
|
38
39
|
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raisedBeforeLoad] });
|
|
39
|
-
await act(async () => iframe
|
|
40
|
+
await act(async () => announcePreviewDocumentLoaded(iframe));
|
|
40
41
|
|
|
41
42
|
expect(shown()).toEqual([raisedBeforeLoad]);
|
|
42
43
|
});
|
|
@@ -79,9 +80,27 @@ it("clears a document's errors when its load replaces it, and when the preview g
|
|
|
79
80
|
previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: old" })),
|
|
80
81
|
);
|
|
81
82
|
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new"] });
|
|
82
|
-
await act(async () => iframe
|
|
83
|
+
await act(async () => announcePreviewDocumentLoaded(iframe));
|
|
83
84
|
expect(shown()).toEqual(["Uncaught Error: new"]);
|
|
84
85
|
|
|
85
86
|
await act(async () => root?.render(React.createElement(NullableHarness, { frame: null })));
|
|
86
87
|
expect(capture.consoleErrors).toBeNull();
|
|
88
|
+
await act(async () => announcePreviewDocumentLoaded(iframe));
|
|
89
|
+
expect(capture.consoleErrors).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps a page's errors through its own late load, and clears them for the next announced page", async () => {
|
|
93
|
+
const { iframe, previewWindow } = previewFrame();
|
|
94
|
+
root = createRoot(document.createElement("div"));
|
|
95
|
+
await act(async () => root?.render(React.createElement(Harness, { iframe })));
|
|
96
|
+
await act(async () =>
|
|
97
|
+
previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: kept" })),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await act(async () => iframe.dispatchEvent(new Event("load")));
|
|
101
|
+
expect(shown()).toEqual(["Uncaught Error: kept"]);
|
|
102
|
+
|
|
103
|
+
Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new page"] });
|
|
104
|
+
await act(async () => announcePreviewDocumentLoaded(iframe));
|
|
105
|
+
expect(shown()).toEqual(["Uncaught Error: new page"]);
|
|
87
106
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
|
|
3
|
+
import { onPreviewDocumentLoaded } from "../player/sceneSwap";
|
|
3
4
|
import type { LintFinding } from "../components/LintModal";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -73,9 +74,9 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
|
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
attachErrorCapture();
|
|
76
|
-
previewIframe
|
|
77
|
+
const stopLoaded = onPreviewDocumentLoaded(previewIframe, attachErrorCapture);
|
|
77
78
|
return () => {
|
|
78
|
-
|
|
79
|
+
stopLoaded();
|
|
79
80
|
detachErrorCapture();
|
|
80
81
|
};
|
|
81
82
|
}, [previewIframe, resetErrors]);
|
|
@@ -18,9 +18,125 @@ vi.mock("../player/components/timelineZoomInput", () => ({
|
|
|
18
18
|
|
|
19
19
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
20
20
|
|
|
21
|
+
/** Runs the frames the hook asked for, as the browser does after the observer's delivery. */
|
|
22
|
+
function nextFrames() {
|
|
23
|
+
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
|
|
24
|
+
return () => act(() => vi.advanceTimersToNextFrame());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A strip that re-rendered inside the observer's delivery resized the timeline Chromium had already measured that
|
|
28
|
+
// frame, which it reported as a ResizeObserver loop on every frame of a trim.
|
|
29
|
+
it("applies a reported size on the next frame, never inside the observer's delivery", () => {
|
|
30
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
31
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
32
|
+
const runFrames = nextFrames();
|
|
33
|
+
const host = document.createElement("div");
|
|
34
|
+
document.body.append(host);
|
|
35
|
+
Object.defineProperty(host, "clientWidth", { configurable: true, value: 300 });
|
|
36
|
+
Object.defineProperty(host, "clientHeight", { configurable: true, value: 40 });
|
|
37
|
+
const root = createRoot(host);
|
|
38
|
+
function Harness() {
|
|
39
|
+
const [size, ref] = useThumbnailStripSize();
|
|
40
|
+
return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
act(() => root.render(<Harness />));
|
|
44
|
+
reportResize(310, 40);
|
|
45
|
+
reportResize(320, 40);
|
|
46
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
47
|
+
expect(host.textContent).toBe("300x40");
|
|
48
|
+
runFrames();
|
|
49
|
+
expect(host.textContent).toBe("320x40");
|
|
50
|
+
} finally {
|
|
51
|
+
act(() => root.unmount());
|
|
52
|
+
host.remove();
|
|
53
|
+
vi.useRealTimers();
|
|
54
|
+
vi.restoreAllMocks();
|
|
55
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("cancels a pending resize frame when the last strip unmounts", () => {
|
|
60
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
61
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
62
|
+
const runFrames = nextFrames();
|
|
63
|
+
const host = document.createElement("div");
|
|
64
|
+
document.body.append(host);
|
|
65
|
+
const root = createRoot(host);
|
|
66
|
+
function Harness() {
|
|
67
|
+
const [, ref] = useThumbnailStripSize();
|
|
68
|
+
return <div ref={ref} />;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
act(() => root.render(<Harness />));
|
|
72
|
+
reportResize(320, 40);
|
|
73
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
74
|
+
act(() => root.unmount());
|
|
75
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
76
|
+
runFrames();
|
|
77
|
+
} finally {
|
|
78
|
+
act(() => root.unmount());
|
|
79
|
+
host.remove();
|
|
80
|
+
vi.useRealTimers();
|
|
81
|
+
vi.restoreAllMocks();
|
|
82
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("drops a pending size when its strip unmounts, so a strip remounted on that box keeps its own", () => {
|
|
87
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
88
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
89
|
+
const runFrames = nextFrames();
|
|
90
|
+
const host = document.createElement("div");
|
|
91
|
+
document.body.append(host);
|
|
92
|
+
const root = createRoot(host);
|
|
93
|
+
function Strip() {
|
|
94
|
+
const [size, ref] = useThumbnailStripSize();
|
|
95
|
+
return <div ref={ref}>{`${size.width}x${size.height}`}</div>;
|
|
96
|
+
}
|
|
97
|
+
function Box({ id }: { id: string }) {
|
|
98
|
+
const box = (element: HTMLDivElement | null) => {
|
|
99
|
+
if (!element) return;
|
|
100
|
+
Object.defineProperty(element, "clientWidth", { configurable: true, value: 300 });
|
|
101
|
+
Object.defineProperty(element, "clientHeight", { configurable: true, value: 40 });
|
|
102
|
+
};
|
|
103
|
+
return (
|
|
104
|
+
<div ref={box} data-testid="box">
|
|
105
|
+
<Strip key={id} />
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
// The other strip keeps the shared observer alive across the remount.
|
|
110
|
+
const render = (id: string) =>
|
|
111
|
+
act(() =>
|
|
112
|
+
root.render(
|
|
113
|
+
<>
|
|
114
|
+
<div>
|
|
115
|
+
<Strip />
|
|
116
|
+
</div>
|
|
117
|
+
<Box id={id} />
|
|
118
|
+
</>,
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
try {
|
|
122
|
+
render("a");
|
|
123
|
+
reportResize(320, 40);
|
|
124
|
+
render("b");
|
|
125
|
+
runFrames();
|
|
126
|
+
expect(host.querySelector('[data-testid="box"]')?.textContent).toBe("300x40");
|
|
127
|
+
} finally {
|
|
128
|
+
act(() => root.unmount());
|
|
129
|
+
host.remove();
|
|
130
|
+
vi.useRealTimers();
|
|
131
|
+
vi.restoreAllMocks();
|
|
132
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
21
136
|
it("does not re-render the strip when the observer reports the size it already holds", () => {
|
|
22
137
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
23
138
|
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
139
|
+
const runFrames = nextFrames();
|
|
24
140
|
const host = document.createElement("div");
|
|
25
141
|
document.body.append(host);
|
|
26
142
|
Object.defineProperty(host, "clientWidth", { configurable: true, value: 300 });
|
|
@@ -45,14 +161,18 @@ it("does not re-render the strip when the observer reports the size it already h
|
|
|
45
161
|
const settled = stripRenders;
|
|
46
162
|
|
|
47
163
|
act(() => reportResize(300, 40));
|
|
164
|
+
runFrames();
|
|
48
165
|
expect(stripRenders).toBe(settled);
|
|
49
166
|
|
|
50
167
|
act(() => reportResize(320, 40));
|
|
168
|
+
runFrames();
|
|
51
169
|
expect(stripRenders).toBe(settled + 1);
|
|
52
170
|
expect(host.textContent).toBe("320x40");
|
|
53
171
|
} finally {
|
|
54
172
|
act(() => root.unmount());
|
|
55
173
|
host.remove();
|
|
174
|
+
vi.useRealTimers();
|
|
175
|
+
vi.restoreAllMocks();
|
|
56
176
|
globalThis.ResizeObserver = originalResizeObserver;
|
|
57
177
|
}
|
|
58
178
|
});
|
|
@@ -60,6 +180,7 @@ it("does not re-render the strip when the observer reports the size it already h
|
|
|
60
180
|
it("measures a strip once a zoom preview ends, not while the preview scales it", () => {
|
|
61
181
|
const originalResizeObserver = globalThis.ResizeObserver;
|
|
62
182
|
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
183
|
+
const runFrames = nextFrames();
|
|
63
184
|
const host = document.createElement("div");
|
|
64
185
|
document.body.append(host);
|
|
65
186
|
let width = 300;
|
|
@@ -75,6 +196,7 @@ it("measures a strip once a zoom preview ends, not while the preview scales it",
|
|
|
75
196
|
zoom.previewing = true;
|
|
76
197
|
width = 600;
|
|
77
198
|
act(() => reportResize(600, 40));
|
|
199
|
+
runFrames();
|
|
78
200
|
expect(host.textContent).toBe("300x40");
|
|
79
201
|
zoom.previewing = false;
|
|
80
202
|
act(() => zoom.listeners.forEach((listener) => listener()));
|
|
@@ -83,6 +205,7 @@ it("measures a strip once a zoom preview ends, not while the preview scales it",
|
|
|
83
205
|
zoom.previewing = false;
|
|
84
206
|
act(() => root.unmount());
|
|
85
207
|
host.remove();
|
|
208
|
+
vi.useRealTimers();
|
|
86
209
|
globalThis.ResizeObserver = originalResizeObserver;
|
|
87
210
|
}
|
|
88
211
|
});
|
|
@@ -152,13 +152,20 @@ const measure = (entries: { target: Element; size?: { width: number; height: num
|
|
|
152
152
|
const onPresence = (entries: IntersectionObserverEntry[]) =>
|
|
153
153
|
measure(entries.filter((entry) => entry.isIntersecting));
|
|
154
154
|
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
155
|
+
const pendingSizes = new Map<Element, { width: number; height: number }>();
|
|
156
|
+
let pendingSizesFrame = 0;
|
|
157
|
+
const applyPendingSizes = () => {
|
|
158
|
+
pendingSizesFrame = 0;
|
|
159
|
+
const entries = [...pendingSizes].map(([target, size]) => ({ target, size }));
|
|
160
|
+
pendingSizes.clear();
|
|
161
|
+
measure(entries);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const applyResizesNextFrame = (entries: ResizeObserverEntry[]) => {
|
|
165
|
+
for (const { target, contentRect } of entries)
|
|
166
|
+
pendingSizes.set(target, { width: contentRect.width, height: contentRect.height });
|
|
167
|
+
pendingSizesFrame ||= requestAnimationFrame(applyPendingSizes);
|
|
168
|
+
};
|
|
162
169
|
|
|
163
170
|
const observeIntersections = (callback: IntersectionObserverCallback) =>
|
|
164
171
|
typeof IntersectionObserver === "undefined"
|
|
@@ -168,7 +175,7 @@ const observeIntersections = (callback: IntersectionObserverCallback) =>
|
|
|
168
175
|
function acquire() {
|
|
169
176
|
if (users++ === 0) {
|
|
170
177
|
shared = {
|
|
171
|
-
resize: new ResizeObserver(
|
|
178
|
+
resize: new ResizeObserver(applyResizesNextFrame),
|
|
172
179
|
presence: observeIntersections(onPresence),
|
|
173
180
|
gaps: observeIntersections(
|
|
174
181
|
(entries) => entries.some((entry) => entry.isIntersecting) && scheduleRefresh(),
|
|
@@ -190,6 +197,9 @@ function release() {
|
|
|
190
197
|
window.removeEventListener("scroll", scheduleRefresh, { capture: true });
|
|
191
198
|
cancelAnimationFrame(frame);
|
|
192
199
|
frame = 0;
|
|
200
|
+
cancelAnimationFrame(pendingSizesFrame);
|
|
201
|
+
pendingSizesFrame = 0;
|
|
202
|
+
pendingSizes.clear();
|
|
193
203
|
}
|
|
194
204
|
|
|
195
205
|
const watchGap = (gap: HTMLDivElement | null) => {
|
|
@@ -234,6 +244,7 @@ export function useThumbnailStripSize() {
|
|
|
234
244
|
resize.unobserve(target);
|
|
235
245
|
presence?.unobserve(target);
|
|
236
246
|
strips.delete(target);
|
|
247
|
+
pendingSizes.delete(target);
|
|
237
248
|
release();
|
|
238
249
|
};
|
|
239
250
|
}, []);
|
package/src/index.ts
CHANGED
|
@@ -202,6 +202,7 @@ export type { UseClipboardOptions } from "./hooks/useClipboard";
|
|
|
202
202
|
export { useDomEditSession } from "./hooks/useDomEditSession";
|
|
203
203
|
export type { UseDomEditSessionParams } from "./hooks/useDomEditSession";
|
|
204
204
|
export { useLivePreviewIframe } from "./hooks/useLivePreviewIframe";
|
|
205
|
+
export { announcePreviewDocumentLoaded } from "./player/sceneSwap";
|
|
205
206
|
export type { PreviewPromotion } from "./player/sceneSwap";
|
|
206
207
|
export { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
207
208
|
export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence";
|
|
@@ -47,6 +47,7 @@ beforeEach(() => {
|
|
|
47
47
|
afterEach(() => {
|
|
48
48
|
act(() => root?.unmount());
|
|
49
49
|
root = null;
|
|
50
|
+
vi.useRealTimers();
|
|
50
51
|
thumbnailScheduler.invalidateProject("/api/projects/demo/preview");
|
|
51
52
|
globalThis.ResizeObserver = originalResizeObserver;
|
|
52
53
|
globalThis.Image = originalImage;
|
|
@@ -175,7 +176,9 @@ describe("CompositionThumbnail", () => {
|
|
|
175
176
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
176
177
|
});
|
|
177
178
|
|
|
179
|
+
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
|
|
178
180
|
act(() => reportResize(500, 40));
|
|
181
|
+
act(() => vi.advanceTimersToNextFrame());
|
|
179
182
|
|
|
180
183
|
expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
|
|
181
184
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { MockResizeObserver, reportResize } from "../../hooks/resizeObserverTestUtils";
|
|
7
7
|
import { thumbnailScheduler } from "../lib/thumbnailScheduler";
|
|
8
8
|
import { ImageThumbnail } from "./ImageThumbnail";
|
|
@@ -64,6 +64,7 @@ beforeEach(() => {
|
|
|
64
64
|
afterEach(() => {
|
|
65
65
|
act(() => root?.unmount());
|
|
66
66
|
root = null;
|
|
67
|
+
vi.useRealTimers();
|
|
67
68
|
thumbnailScheduler.invalidateProject("p");
|
|
68
69
|
globalThis.IntersectionObserver = originalIO;
|
|
69
70
|
globalThis.ResizeObserver = originalRO;
|
|
@@ -187,7 +188,9 @@ describe("ImageThumbnail", () => {
|
|
|
187
188
|
probe.onload?.();
|
|
188
189
|
});
|
|
189
190
|
|
|
191
|
+
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "cancelAnimationFrame"] });
|
|
190
192
|
act(() => reportResize(500, 40));
|
|
193
|
+
act(() => vi.advanceTimersToNextFrame());
|
|
191
194
|
|
|
192
195
|
expect(host.querySelector("img")?.parentElement?.style.width).toBe("108px");
|
|
193
196
|
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
shouldShowCompositionLoadingOverlay,
|
|
11
11
|
} from "./Player";
|
|
12
12
|
import { usePlayerStore } from "../store/playerStore";
|
|
13
|
+
import { onPreviewDocumentLoaded } from "../sceneSwap";
|
|
13
14
|
|
|
14
15
|
vi.mock("@hyperframes/player", () => ({}));
|
|
15
16
|
|
|
@@ -196,6 +197,49 @@ describe("callbacks after the player is already mounted", () => {
|
|
|
196
197
|
expect(first).not.toHaveBeenCalled();
|
|
197
198
|
});
|
|
198
199
|
|
|
200
|
+
it("loads the preview on the runtime's ready while a stalled font holds the window's load", async () => {
|
|
201
|
+
const onLoad = vi.fn();
|
|
202
|
+
const { player } = await mountPlayer({ onLoad });
|
|
203
|
+
const iframe = (player as TestHyperframesPlayer).iframeElement;
|
|
204
|
+
let page = {};
|
|
205
|
+
Object.defineProperty(iframe, "contentDocument", { get: () => page });
|
|
206
|
+
Object.defineProperty(iframe, "contentWindow", { get: () => ({ __playerReady: true }) });
|
|
207
|
+
const announced = vi.fn();
|
|
208
|
+
onPreviewDocumentLoaded(iframe, announced);
|
|
209
|
+
|
|
210
|
+
act(() => void player.dispatchEvent(new Event("ready")));
|
|
211
|
+
expect(onLoad).toHaveBeenCalledTimes(1);
|
|
212
|
+
|
|
213
|
+
act(() => void iframe.dispatchEvent(new Event("load")));
|
|
214
|
+
act(() => void player.dispatchEvent(new Event("ready")));
|
|
215
|
+
expect(onLoad).toHaveBeenCalledTimes(1);
|
|
216
|
+
|
|
217
|
+
page = {};
|
|
218
|
+
act(() => void player.dispatchEvent(new Event("ready")));
|
|
219
|
+
expect(onLoad).toHaveBeenCalledTimes(2);
|
|
220
|
+
expect(announced).toHaveBeenCalledTimes(2);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("leaves a ready that arrives before the page's runtime boots to the page's own load", async () => {
|
|
224
|
+
const onLoad = vi.fn();
|
|
225
|
+
const { player } = await mountPlayer({ onLoad });
|
|
226
|
+
const iframe = (player as TestHyperframesPlayer).iframeElement;
|
|
227
|
+
const page = {};
|
|
228
|
+
let runtime: { __playerReady?: boolean } = {};
|
|
229
|
+
Object.defineProperty(iframe, "contentDocument", { get: () => page });
|
|
230
|
+
Object.defineProperty(iframe, "contentWindow", { get: () => runtime });
|
|
231
|
+
|
|
232
|
+
act(() => void player.dispatchEvent(new Event("ready")));
|
|
233
|
+
expect(onLoad).not.toHaveBeenCalled();
|
|
234
|
+
|
|
235
|
+
act(() => void iframe.dispatchEvent(new Event("load")));
|
|
236
|
+
expect(onLoad).toHaveBeenCalledTimes(1);
|
|
237
|
+
|
|
238
|
+
runtime = { __playerReady: true };
|
|
239
|
+
act(() => void player.dispatchEvent(new Event("ready")));
|
|
240
|
+
expect(onLoad).toHaveBeenCalledTimes(1);
|
|
241
|
+
});
|
|
242
|
+
|
|
199
243
|
it("reports the preview error cause", async () => {
|
|
200
244
|
const onPreviewError = vi.fn();
|
|
201
245
|
const { player } = await mountPlayer({ onPreviewError });
|
|
@@ -5,6 +5,8 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
5
5
|
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
|
6
6
|
import { HyperframesLoader } from "../../components/ui";
|
|
7
7
|
import { usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import { announcePreviewDocumentLoaded } from "../sceneSwap";
|
|
9
|
+
import type { IframeWindow } from "../lib/playbackTypes";
|
|
8
10
|
// Importing "@hyperframes/player" registers a class extending HTMLElement at
|
|
9
11
|
// module load, which throws under SSR, hence the dynamic import behind a
|
|
10
12
|
// `typeof window` guard. Kicking it here rather than in the mount effect puts
|
|
@@ -128,6 +130,9 @@ export function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean
|
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
const runtimeBootedIn = (iframe: HTMLIFrameElement) =>
|
|
134
|
+
(iframe.contentWindow as IframeWindow | null)?.__playerReady === true;
|
|
135
|
+
|
|
131
136
|
/**
|
|
132
137
|
* Renders a composition preview using the <hyperframes-player> web component.
|
|
133
138
|
*
|
|
@@ -228,9 +233,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
228
233
|
const loading = getShaderTransitionLoading(event);
|
|
229
234
|
if (loading !== null) setShaderTransitionLoading(loading);
|
|
230
235
|
};
|
|
236
|
+
let loadedDocument: Document | null = null;
|
|
231
237
|
const handleReady = () => {
|
|
232
238
|
setPreviewError(null);
|
|
233
239
|
setCompositionLoading(false);
|
|
240
|
+
if (iframe.contentDocument && runtimeBootedIn(iframe)) handleLoad();
|
|
234
241
|
};
|
|
235
242
|
const handlePainted = () => {
|
|
236
243
|
setPainted(true);
|
|
@@ -243,6 +250,9 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
243
250
|
setCompositionLoading(false);
|
|
244
251
|
};
|
|
245
252
|
const handleLoad = () => {
|
|
253
|
+
const doc = iframe.contentDocument;
|
|
254
|
+
if (doc && doc === loadedDocument) return;
|
|
255
|
+
loadedDocument = doc;
|
|
246
256
|
loadCountRef.current++;
|
|
247
257
|
setLoaded(true);
|
|
248
258
|
setPreviewError(null);
|
|
@@ -258,6 +268,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
258
268
|
container.addEventListener("animationend", onEnd, { once: true });
|
|
259
269
|
}
|
|
260
270
|
onLoadRef.current();
|
|
271
|
+
announcePreviewDocumentLoaded(iframe);
|
|
261
272
|
|
|
262
273
|
// Show a loading overlay until every `<video>`/`<audio>` and Lottie
|
|
263
274
|
// asset is ready. Without this users can click play before audio has
|