@hyperframes/studio 0.8.124 → 0.8.126
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-ColOZ_78.js → hyperframes-player-BbbN-wWN.js} +1 -1
- package/dist/assets/{index-BuSTrK-Z.js → index-B8kU2xXl.js} +177 -177
- package/dist/assets/{index-CVuUfURE.js → index-Cll7uTNy.js} +1 -1
- package/dist/assets/{index-jQEqFKMk.js → index-nvLF29ZH.js} +1 -1
- package/dist/index.d.ts +43 -35
- package/dist/index.html +1 -1
- package/dist/index.js +4458 -4072
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
- package/src/components/editor/MotionPathNode.tsx +56 -0
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +140 -48
- package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
- package/src/components/editor/MotionPathOverlay.tsx +30 -61
- package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
- package/src/components/editor/domEditOverlayGestures.ts +2 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -0
- package/src/components/editor/dragUndoPaint.test.ts +170 -2
- package/src/components/editor/gestureUndoRevert.ts +104 -0
- package/src/components/editor/manualEditsSeekReapply.ts +3 -6
- package/src/components/editor/manualOffsetDrag.ts +3 -15
- package/src/components/editor/motionPathGeometry.test.ts +48 -1
- package/src/components/editor/motionPathGeometry.ts +46 -8
- package/src/components/editor/motionPathHome.ts +75 -0
- package/src/components/editor/motionPathLayerNode.test.ts +43 -35
- package/src/components/editor/motionPathLayerNode.ts +20 -16
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
- package/src/components/editor/studioMotion.ts +7 -19
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
- package/src/components/editor/useMotionPathData.test.tsx +236 -0
- package/src/components/editor/useMotionPathData.ts +23 -61
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
- package/src/hooks/gestureTransaction.test.ts +60 -0
- package/src/hooks/gestureTransaction.ts +5 -2
- package/src/hooks/gsapGestureOutcome.ts +9 -2
- package/src/hooks/gsapParsedTween.ts +16 -0
- package/src/hooks/gsapResizeDropPoint.test.ts +39 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +27 -0
- package/src/hooks/gsapResizeIntercept.ts +55 -68
- package/src/hooks/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.ts +2 -1
- package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
- package/src/hooks/intersectionObserverTestUtils.ts +16 -0
- package/src/hooks/resizeObserverTestUtils.ts +9 -2
- package/src/hooks/timelineEditingHelpers.ts +2 -9
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +29 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +21 -25
- package/src/hooks/useGsapAwareGroupMove.ts +4 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +42 -1
- package/src/hooks/useGsapScriptCommits.ts +12 -3
- package/src/hooks/useThumbnailStripSize.test.tsx +236 -1
- package/src/hooks/useThumbnailStripSize.ts +206 -17
- package/src/noWindowBoundDomChecks.test.ts +31 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -6
- package/src/player/components/ImageThumbnail.tsx +11 -5
- package/src/player/components/ThumbnailTiles.test.tsx +30 -0
- package/src/player/components/ThumbnailTiles.tsx +41 -0
- package/src/player/components/VideoThumbnail.test.tsx +116 -9
- package/src/player/components/VideoThumbnail.tsx +11 -5
- package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
- package/src/player/lib/timelineGroupInfo.ts +2 -1
- package/src/utils/studioPendingEdits.test.ts +52 -0
- package/src/utils/studioPendingEdits.ts +55 -10
- package/src/webmcp/webmcpTestUtils.ts +2 -2
|
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
5
|
+
import type { StudioEditInFlight } from "../utils/studioPendingEdits";
|
|
5
6
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
6
7
|
import type { KeyframeUsageAction } from "../utils/keyframeUsage";
|
|
7
8
|
|
|
@@ -47,6 +48,7 @@ export interface CommitMutationOptions {
|
|
|
47
48
|
/** Carries a deferred write not yet on screen (no patch, or a missed one) to the final render. */
|
|
48
49
|
previewFallbackLatch?: { pending: boolean };
|
|
49
50
|
beforeReload?: () => void;
|
|
51
|
+
pendingEdit?: StudioEditInFlight;
|
|
50
52
|
/**
|
|
51
53
|
* Serialize this commit against others sharing the same key. Used to chain
|
|
52
54
|
* per-animationId GSAP meta updates. Every commit independently takes the
|
package/src/hooks/gsapShared.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Centralises duplicated interfaces, constants, and small utilities
|
|
4
4
|
* to reduce drift risk.
|
|
5
5
|
*/
|
|
6
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
6
7
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
7
8
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
8
9
|
import {
|
|
@@ -223,7 +224,7 @@ function structuralSelector(element: Element): string | null {
|
|
|
223
224
|
const parts: string[] = [];
|
|
224
225
|
for (let node: Element | null = element; node; node = node.parentElement) {
|
|
225
226
|
if (node !== element) {
|
|
226
|
-
const id = node
|
|
227
|
+
const id = isHtmlElement(node) ? node.id : "";
|
|
227
228
|
const hfId = node.getAttribute("data-hf-id");
|
|
228
229
|
if (id) {
|
|
229
230
|
parts.unshift(idSelector(id));
|
|
@@ -110,6 +110,16 @@ describe("writeTargetSelector", () => {
|
|
|
110
110
|
expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
it("anchors on an ancestor's id in the preview, whichever window built its nodes", () => {
|
|
114
|
+
const frame = document.body.appendChild(document.createElement("iframe"));
|
|
115
|
+
const doc = frame.contentDocument!;
|
|
116
|
+
doc.body.innerHTML = `<div id="scene"><div></div><div></div></div>`;
|
|
117
|
+
const el = doc.querySelectorAll<HTMLElement>("#scene > div")[1]!;
|
|
118
|
+
const selection = { ...stableSelectionFor(el), selector: "div[unclosed" } as DomEditSelection;
|
|
119
|
+
|
|
120
|
+
expect(writeTargetSelector(selection)).toBe("#scene > div:nth-child(2)");
|
|
121
|
+
});
|
|
122
|
+
|
|
113
123
|
it("returns null when a live DOM is present and no rung addresses one element", () => {
|
|
114
124
|
document.body.innerHTML = `<div id="scene"><div class="group"></div><div class="group"></div></div>`;
|
|
115
125
|
const el = document.querySelectorAll<HTMLElement>(".group")[1]!;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Test-only IntersectionObserver stand-in: everything observed is reported near the screen, asynchronously.
|
|
2
|
+
export function NearScreenIntersectionObserver(callback: IntersectionObserverCallback) {
|
|
3
|
+
const observer = {
|
|
4
|
+
observe(target: Element) {
|
|
5
|
+
queueMicrotask(() =>
|
|
6
|
+
callback(
|
|
7
|
+
[{ isIntersecting: true, target } as IntersectionObserverEntry],
|
|
8
|
+
observer as unknown as IntersectionObserver,
|
|
9
|
+
),
|
|
10
|
+
);
|
|
11
|
+
},
|
|
12
|
+
unobserve() {},
|
|
13
|
+
disconnect() {},
|
|
14
|
+
};
|
|
15
|
+
return observer;
|
|
16
|
+
}
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
// Test-only ResizeObserver stand-in: a test delivers a size by hand with `reportResize`.
|
|
2
2
|
let resizeCallback: ResizeObserverCallback | null = null;
|
|
3
|
+
let observed: Element | null = null;
|
|
3
4
|
|
|
4
5
|
export function MockResizeObserver(callback: ResizeObserverCallback) {
|
|
5
6
|
resizeCallback = callback;
|
|
6
|
-
return {
|
|
7
|
+
return {
|
|
8
|
+
observe(target: Element) {
|
|
9
|
+
observed = target;
|
|
10
|
+
},
|
|
11
|
+
disconnect() {},
|
|
12
|
+
unobserve() {},
|
|
13
|
+
};
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
export function reportResize(width: number, height: number): void {
|
|
10
17
|
resizeCallback!(
|
|
11
|
-
[{ contentRect: { width, height } } as ResizeObserverEntry],
|
|
18
|
+
[{ contentRect: { width, height }, target: observed } as ResizeObserverEntry],
|
|
12
19
|
{} as ResizeObserver,
|
|
13
20
|
);
|
|
14
21
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
1
2
|
import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
|
|
2
3
|
import { toAuthoredStart } from "../player/store/timelineElement";
|
|
3
4
|
import {
|
|
@@ -35,14 +36,6 @@ import {
|
|
|
35
36
|
} from "../components/editor/domEditingElement";
|
|
36
37
|
export { deleteSelectedKeyframes } from "./deleteSelectedKeyframes";
|
|
37
38
|
export { readFileContent };
|
|
38
|
-
function isHTMLElement(element: Element | null): element is HTMLElement {
|
|
39
|
-
if (!element) return false;
|
|
40
|
-
// Use the element's OWN realm's HTMLElement: timeline clips live in the preview
|
|
41
|
-
// iframe, and cross-realm `element instanceof HTMLElement` (main window) is
|
|
42
|
-
// always false — which silently dropped every timeline z-index commit.
|
|
43
|
-
const Ctor = element.ownerDocument?.defaultView?.HTMLElement ?? globalThis.HTMLElement;
|
|
44
|
-
return element instanceof Ctor;
|
|
45
|
-
}
|
|
46
39
|
/**
|
|
47
40
|
* Resolve a timeline vertical move to a z-index stacking reorder and commit it
|
|
48
41
|
* through the shared layers-panel reorder path. Reads live sibling z-index from
|
|
@@ -96,7 +89,7 @@ export function applyTimelineStackingReorder(input: {
|
|
|
96
89
|
input.activeCompPath,
|
|
97
90
|
)
|
|
98
91
|
: null;
|
|
99
|
-
if (!
|
|
92
|
+
if (!isHtmlElement(element)) return Promise.resolve();
|
|
100
93
|
if (getElementZIndex(element) === change.zIndex) continue;
|
|
101
94
|
commitEntries.push({
|
|
102
95
|
element,
|
|
@@ -6,6 +6,10 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
7
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/domEditOverlayGestures";
|
|
8
8
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
9
|
+
import {
|
|
10
|
+
beginStudioPendingEdit,
|
|
11
|
+
paintBackNewestStudioPendingEdit,
|
|
12
|
+
} from "../utils/studioPendingEdits";
|
|
9
13
|
import { trackStudioEditBlocked } from "../utils/studioSaveDiagnostics";
|
|
10
14
|
import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome";
|
|
11
15
|
import { mountGsapAwareEditing } from "./useGsapAwareEditing.testHelpers";
|
|
@@ -65,7 +69,10 @@ function mountGroup(animations: GsapAnimation[]) {
|
|
|
65
69
|
batch: vi.fn().mockResolvedValue(undefined),
|
|
66
70
|
});
|
|
67
71
|
const showToast = vi.fn();
|
|
68
|
-
const stageElementPositionOffset = vi.fn(() =>
|
|
72
|
+
const stageElementPositionOffset = vi.fn((selection: DomEditSelection) => {
|
|
73
|
+
selection.element.style.setProperty("translate", "30px 0px");
|
|
74
|
+
return { save: vi.fn().mockResolvedValue(undefined), rollback: vi.fn() };
|
|
75
|
+
});
|
|
69
76
|
const { editing, root } = mountGsapAwareEditing({
|
|
70
77
|
gsapCommitMutation: commitMutation,
|
|
71
78
|
activeCompPath: "index.html",
|
|
@@ -158,3 +165,24 @@ describe("a group drag plans every member before its first write", () => {
|
|
|
158
165
|
act(() => h.root.unmount());
|
|
159
166
|
});
|
|
160
167
|
});
|
|
168
|
+
|
|
169
|
+
it("keeps a member it moves on its own CSS undone when undo painted the group back", async () => {
|
|
170
|
+
const h = mountGroup(["b", "c"].map((id) => positionTween(id)));
|
|
171
|
+
const plain = h.elements[0]!;
|
|
172
|
+
const edit = beginStudioPendingEdit(() => {
|
|
173
|
+
const shown = plain.getAttribute("style") ?? "";
|
|
174
|
+
plain.setAttribute("style", "");
|
|
175
|
+
return () => plain.setAttribute("style", shown);
|
|
176
|
+
});
|
|
177
|
+
const saved = edit.adopt(() =>
|
|
178
|
+
h.groupCommit(h.updates.map((u, i) => (i === 0 ? { ...u, plainTranslate: true } : u))),
|
|
179
|
+
);
|
|
180
|
+
edit.settle(saved);
|
|
181
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
182
|
+
|
|
183
|
+
await act(() => saved);
|
|
184
|
+
expect(plain.getAttribute("style")).toBe("");
|
|
185
|
+
shown!.showAgain();
|
|
186
|
+
expect(plain.getAttribute("style")).toContain("translate: 30px 0px");
|
|
187
|
+
act(() => h.root.unmount());
|
|
188
|
+
});
|
|
@@ -169,8 +169,10 @@ export function useGsapAwareEditing({
|
|
|
169
169
|
modifiers,
|
|
170
170
|
);
|
|
171
171
|
await saveMove(outcome, async () => {
|
|
172
|
-
const
|
|
173
|
-
|
|
172
|
+
const staged = writes.drawKeepingUndone(() =>
|
|
173
|
+
stageElementPositionOffset(selection, next, false),
|
|
174
|
+
);
|
|
175
|
+
writes.recordDomResult(await staged.save());
|
|
174
176
|
});
|
|
175
177
|
return writes.finish();
|
|
176
178
|
} catch (error) {
|
|
@@ -258,11 +260,8 @@ export function useGsapAwareEditing({
|
|
|
258
260
|
// Saved after the size, under its undo key, so the two are one step.
|
|
259
261
|
await saveMove(dragOutcome, async () => {
|
|
260
262
|
const plainAfterSettle = editsPlainCss(selection.element, "move");
|
|
261
|
-
anchorMove =
|
|
262
|
-
selection,
|
|
263
|
-
offset,
|
|
264
|
-
plainAfterSettle,
|
|
265
|
-
coalesceKey,
|
|
263
|
+
anchorMove = writes.drawKeepingUndone(() =>
|
|
264
|
+
stageElementPositionOffset(selection, offset, plainAfterSettle, coalesceKey),
|
|
266
265
|
);
|
|
267
266
|
});
|
|
268
267
|
}
|
|
@@ -270,17 +269,20 @@ export function useGsapAwareEditing({
|
|
|
270
269
|
await runGestureTransaction({
|
|
271
270
|
element: selection.element,
|
|
272
271
|
label: "Resize layer",
|
|
272
|
+
draw: writes.drawKeepingUndone,
|
|
273
273
|
settle: () => {
|
|
274
274
|
// Scale resize settles its center-scale residual after the scale commit
|
|
275
275
|
// renders. Width/height can settle its anchored position immediately.
|
|
276
276
|
if (!offset || scaleRoute || !selector) return;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
277
|
+
writes.drawKeepingUndone(() => {
|
|
278
|
+
const gsapPos = readGsapPositionFromIframe(previewIframeRef.current, selector) ?? {
|
|
279
|
+
x: 0,
|
|
280
|
+
y: 0,
|
|
281
|
+
};
|
|
282
|
+
const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
|
|
283
|
+
logResize("sync-settle", { gsapPos, offset, newX, newY });
|
|
284
|
+
setElementGsapPosition(selection.element, newX, newY);
|
|
285
|
+
});
|
|
284
286
|
},
|
|
285
287
|
persist: async (commit, coalesceKey) => {
|
|
286
288
|
if (writes.commit) {
|
|
@@ -294,16 +296,13 @@ export function useGsapAwareEditing({
|
|
|
294
296
|
commitMutation,
|
|
295
297
|
makeFetchFallback(selection),
|
|
296
298
|
offset,
|
|
299
|
+
writes.drawKeepingUndone,
|
|
297
300
|
);
|
|
298
301
|
assertGsapEditPersisted(outcome);
|
|
299
302
|
// Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
|
|
300
303
|
if (outcome.status === "element-size") {
|
|
301
|
-
const result = await
|
|
302
|
-
selection,
|
|
303
|
-
next,
|
|
304
|
-
undefined,
|
|
305
|
-
undefined,
|
|
306
|
-
coalesceKey,
|
|
304
|
+
const result = await writes.drawKeepingUndone(() =>
|
|
305
|
+
handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey),
|
|
307
306
|
);
|
|
308
307
|
writes.recordDomResult(result);
|
|
309
308
|
} else cropUndoKey = coalesceKey;
|
|
@@ -332,11 +331,8 @@ export function useGsapAwareEditing({
|
|
|
332
331
|
writes.recordDomResult(anchorResult);
|
|
333
332
|
// Only now is the size live for every caller, drag or not.
|
|
334
333
|
if (cropUndoKey) {
|
|
335
|
-
const cropResult = await
|
|
336
|
-
stageCrop,
|
|
337
|
-
selection,
|
|
338
|
-
commitPositionPatchToHtml,
|
|
339
|
-
cropUndoKey,
|
|
334
|
+
const cropResult = await writes.drawKeepingUndone(() =>
|
|
335
|
+
saveCropResize(stageCrop, selection, commitPositionPatchToHtml, cropUndoKey!),
|
|
340
336
|
);
|
|
341
337
|
writes.recordDomResult(cropResult);
|
|
342
338
|
}
|
|
@@ -123,12 +123,10 @@ export function useGsapAwareGroupMove({
|
|
|
123
123
|
renderOnCommit = index === lastScriptWrite;
|
|
124
124
|
const plain = offsetMembers.get(selection);
|
|
125
125
|
if (plain !== undefined) {
|
|
126
|
-
const
|
|
127
|
-
selection,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
coalesceKey,
|
|
131
|
-
).save();
|
|
126
|
+
const staged = writes.drawKeepingUndone(() =>
|
|
127
|
+
stageElementPositionOffset(selection, next, plain, coalesceKey),
|
|
128
|
+
);
|
|
129
|
+
const result = await staged.save();
|
|
132
130
|
writes.recordDomResult(result);
|
|
133
131
|
continue;
|
|
134
132
|
}
|
|
@@ -38,7 +38,12 @@ import type {
|
|
|
38
38
|
import { persistSdkSerialize } from "../utils/sdkCutover";
|
|
39
39
|
import { jsonResponse } from "./fetchStubTestUtils";
|
|
40
40
|
import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
beginStudioPendingEdit,
|
|
43
|
+
hasStudioPendingEdits,
|
|
44
|
+
paintBackNewestStudioPendingEdit,
|
|
45
|
+
} from "../utils/studioPendingEdits";
|
|
46
|
+
import { observeGsapGesture } from "./gsapGestureOutcome";
|
|
42
47
|
|
|
43
48
|
// ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
|
|
44
49
|
|
|
@@ -490,6 +495,42 @@ describe("a GSAP script commit", () => {
|
|
|
490
495
|
await act(async () => void (await committed));
|
|
491
496
|
expect(hasStudioPendingEdits()).toBe(false);
|
|
492
497
|
});
|
|
498
|
+
|
|
499
|
+
it("a gesture's write after an await is still its edit, and once undo paints that edit back it saves without drawing it", async () => {
|
|
500
|
+
applySoftReload.mockReturnValue("applied");
|
|
501
|
+
let respond!: () => void;
|
|
502
|
+
const body: MutationResult = { ok: true, changed: true, after: "AFTER", scriptText: "SCRIPT" };
|
|
503
|
+
vi.stubGlobal(
|
|
504
|
+
"fetch",
|
|
505
|
+
vi.fn(
|
|
506
|
+
() =>
|
|
507
|
+
new Promise((resolve) => {
|
|
508
|
+
respond = () => resolve({ ok: true, json: async () => body } as unknown as Response);
|
|
509
|
+
}),
|
|
510
|
+
),
|
|
511
|
+
);
|
|
512
|
+
const deps = renderCommitHook();
|
|
513
|
+
const edit = beginStudioPendingEdit(() => () => undefined);
|
|
514
|
+
const saved = edit.adopt(async () => {
|
|
515
|
+
const writes = observeGsapGesture(deps.api.commitMutation);
|
|
516
|
+
await Promise.resolve();
|
|
517
|
+
return writes.commit!(selection, { x: 10 }, { label: "Move layer", softReload: true });
|
|
518
|
+
});
|
|
519
|
+
edit.settle(saved);
|
|
520
|
+
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
|
521
|
+
|
|
522
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
523
|
+
expect(shown).not.toBeNull();
|
|
524
|
+
await act(async () => {
|
|
525
|
+
respond();
|
|
526
|
+
await saved;
|
|
527
|
+
});
|
|
528
|
+
expect(deps.onFileContentChanged).toHaveBeenCalledWith("index.html", "AFTER");
|
|
529
|
+
expect(applySoftReload).not.toHaveBeenCalled();
|
|
530
|
+
|
|
531
|
+
shown!.showAgain();
|
|
532
|
+
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
533
|
+
});
|
|
493
534
|
});
|
|
494
535
|
|
|
495
536
|
describe("runCommit — instantPatch wiring", () => {
|
|
@@ -113,11 +113,18 @@ function finishUnchangedMutation(
|
|
|
113
113
|
!options.skipReload &&
|
|
114
114
|
(instantPatchesFor(options).length > 0 || options.previewFallbackLatch?.pending)
|
|
115
115
|
) {
|
|
116
|
-
|
|
116
|
+
redrawUnlessPaintedBack(options, () =>
|
|
117
|
+
applyPreviewSync(iframe, result, options, reloadPreview),
|
|
118
|
+
);
|
|
117
119
|
}
|
|
118
120
|
return true;
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
function redrawUnlessPaintedBack(options: CommitMutationOptions, redraw: () => void): void {
|
|
124
|
+
if (options.pendingEdit) options.pendingEdit.drawUnlessUndone(redraw);
|
|
125
|
+
else redraw();
|
|
126
|
+
}
|
|
127
|
+
|
|
121
128
|
function refreshMutationPreview(
|
|
122
129
|
iframe: HTMLIFrameElement | null,
|
|
123
130
|
result: MutationResult,
|
|
@@ -126,8 +133,10 @@ function refreshMutationPreview(
|
|
|
126
133
|
onCacheInvalidate: () => void,
|
|
127
134
|
nestedFiles?: Map<string, string> | null,
|
|
128
135
|
): void {
|
|
129
|
-
options
|
|
130
|
-
|
|
136
|
+
redrawUnlessPaintedBack(options, () => {
|
|
137
|
+
options.beforeReload?.();
|
|
138
|
+
applyPreviewSync(iframe, result, options, reloadPreview, nestedFiles);
|
|
139
|
+
});
|
|
131
140
|
onCacheInvalidate();
|
|
132
141
|
}
|
|
133
142
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
|
-
import { expect, it } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { createHappyDomRootHarness } from "../player/components/testRootHarness";
|
|
6
|
+
import { NearScreenIntersectionObserver } from "./intersectionObserverTestUtils";
|
|
5
7
|
import { MockResizeObserver, reportResize } from "./resizeObserverTestUtils";
|
|
6
8
|
import { useThumbnailStripSize } from "./useThumbnailStripSize";
|
|
7
9
|
|
|
@@ -45,3 +47,236 @@ it("does not re-render the strip when the observer reports the size it already h
|
|
|
45
47
|
globalThis.ResizeObserver = originalResizeObserver;
|
|
46
48
|
}
|
|
47
49
|
});
|
|
50
|
+
|
|
51
|
+
describe("on a scroll", () => {
|
|
52
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
53
|
+
const originalIntersectionObserver = globalThis.IntersectionObserver;
|
|
54
|
+
const frames: FrameRequestCallback[] = [];
|
|
55
|
+
const harness = createHappyDomRootHarness();
|
|
56
|
+
let host: HTMLDivElement;
|
|
57
|
+
let root: ReturnType<typeof createRoot>;
|
|
58
|
+
let left = 0;
|
|
59
|
+
let top = 0;
|
|
60
|
+
let scrolled = 0;
|
|
61
|
+
let scrolledDown = 0;
|
|
62
|
+
let stripWidth = 1_000_000;
|
|
63
|
+
let renders = 0;
|
|
64
|
+
|
|
65
|
+
function Strip() {
|
|
66
|
+
renders += 1;
|
|
67
|
+
const [size, ref] = useThumbnailStripSize();
|
|
68
|
+
return (
|
|
69
|
+
<div>
|
|
70
|
+
<div ref={ref} data-in-view={`${size.inViewStart}-${size.inViewEnd}`} />
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const mountStrips = (count: number) =>
|
|
76
|
+
act(async () =>
|
|
77
|
+
root.render(Array.from({ length: count }, (_, index) => <Strip key={index} />)),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const spans = () =>
|
|
81
|
+
[...host.querySelectorAll("[data-in-view]")].map((strip) => strip.getAttribute("data-in-view"));
|
|
82
|
+
|
|
83
|
+
/** The timeline scrolls so the strips' left edge lands at `nextLeft`, then one frame runs. */
|
|
84
|
+
const scrollTo = (nextLeft: number) => {
|
|
85
|
+
scrolled += left - nextLeft;
|
|
86
|
+
left = nextLeft;
|
|
87
|
+
act(() => host.dispatchEvent(new Event("scroll")));
|
|
88
|
+
expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1);
|
|
89
|
+
act(() => frames.splice(0).forEach((frame) => frame(0)));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const neverReportedNear = () => {
|
|
93
|
+
globalThis.IntersectionObserver = class {
|
|
94
|
+
observe() {}
|
|
95
|
+
unobserve() {}
|
|
96
|
+
disconnect() {}
|
|
97
|
+
} as unknown as typeof IntersectionObserver;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
102
|
+
globalThis.IntersectionObserver =
|
|
103
|
+
NearScreenIntersectionObserver as unknown as typeof IntersectionObserver;
|
|
104
|
+
vi.spyOn(window, "requestAnimationFrame").mockImplementation((frame) => frames.push(frame));
|
|
105
|
+
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(
|
|
106
|
+
() => ({ left, top, width: stripWidth, height: 40 }) as DOMRect,
|
|
107
|
+
);
|
|
108
|
+
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => stripWidth);
|
|
109
|
+
left = 0;
|
|
110
|
+
top = 0;
|
|
111
|
+
scrolled = 0;
|
|
112
|
+
scrolledDown = 0;
|
|
113
|
+
stripWidth = 1_000_000;
|
|
114
|
+
renders = 0;
|
|
115
|
+
host = document.body.appendChild(document.createElement("div"));
|
|
116
|
+
host.setAttribute("data-timeline-scroll-viewport", "");
|
|
117
|
+
Object.defineProperty(host, "scrollLeft", { configurable: true, get: () => scrolled });
|
|
118
|
+
Object.defineProperty(host, "scrollTop", { configurable: true, get: () => scrolledDown });
|
|
119
|
+
root = harness.mount(host);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterEach(() => {
|
|
123
|
+
frames.length = 0;
|
|
124
|
+
vi.restoreAllMocks();
|
|
125
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
126
|
+
globalThis.IntersectionObserver = originalIntersectionObserver;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("re-measures every near strip in one shared frame, so a full timeline lays out once", async () => {
|
|
130
|
+
await mountStrips(50);
|
|
131
|
+
|
|
132
|
+
scrollTo(-10_000);
|
|
133
|
+
|
|
134
|
+
expect(spans()).toEqual(Array(50).fill("9216-11776"));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("keeps a visible strip measured when it is read after a scroll but before that scroll's frame", async () => {
|
|
138
|
+
stripWidth = 5_000;
|
|
139
|
+
left = -1_000;
|
|
140
|
+
await mountStrips(1);
|
|
141
|
+
|
|
142
|
+
scrolled += 3_000;
|
|
143
|
+
left = -4_000;
|
|
144
|
+
act(() => reportResize(stripWidth, 40));
|
|
145
|
+
act(() => host.dispatchEvent(new Event("scroll")));
|
|
146
|
+
act(() => frames.splice(0).forEach((frame) => frame(0)));
|
|
147
|
+
|
|
148
|
+
expect(spans()).toEqual(["3072-5000"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("keeps a strip on screen measured after a move without a scroll, then a scroll", async () => {
|
|
152
|
+
await mountStrips(1);
|
|
153
|
+
expect(spans()).toEqual(["0-1536"]);
|
|
154
|
+
|
|
155
|
+
left = -3_000;
|
|
156
|
+
scrollTo(-1_000);
|
|
157
|
+
expect(spans()).toEqual(["0-2560"]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("measures a strip a vertical scroll brings on screen in that same frame", async () => {
|
|
161
|
+
neverReportedNear();
|
|
162
|
+
top = 5_000;
|
|
163
|
+
await mountStrips(1);
|
|
164
|
+
expect(spans()).toEqual(["0-0"]);
|
|
165
|
+
|
|
166
|
+
scrolledDown += 4_900;
|
|
167
|
+
top = 100;
|
|
168
|
+
act(() => host.dispatchEvent(new Event("scroll")));
|
|
169
|
+
act(() => frames.splice(0).forEach((frame) => frame(0)));
|
|
170
|
+
|
|
171
|
+
expect(spans()).toEqual(["0-1536"]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("keeps the tiles of a strip that ends just off screen", async () => {
|
|
175
|
+
await mountStrips(1);
|
|
176
|
+
|
|
177
|
+
scrollTo(-(stripWidth + 100));
|
|
178
|
+
|
|
179
|
+
expect(spans()).toEqual(["999424-1000000"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("measures the strips a jump brings on screen in that same frame", async () => {
|
|
183
|
+
neverReportedNear();
|
|
184
|
+
left = 50_000;
|
|
185
|
+
await mountStrips(50);
|
|
186
|
+
expect(spans()).toEqual(Array(50).fill("0-0"));
|
|
187
|
+
|
|
188
|
+
scrollTo(0);
|
|
189
|
+
|
|
190
|
+
expect(spans()).toEqual(Array(50).fill("0-1536"));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("does not re-render a strip wholly on screen when it moves", async () => {
|
|
194
|
+
stripWidth = 300;
|
|
195
|
+
left = 100;
|
|
196
|
+
await mountStrips(1);
|
|
197
|
+
const settled = renders;
|
|
198
|
+
|
|
199
|
+
scrollTo(700);
|
|
200
|
+
|
|
201
|
+
expect(renders).toBe(settled);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("does not re-render strips that a jump carries from one side of the screen to the other", async () => {
|
|
205
|
+
left = 50_000;
|
|
206
|
+
await mountStrips(50);
|
|
207
|
+
const settled = renders;
|
|
208
|
+
|
|
209
|
+
scrollTo(-2_000_000);
|
|
210
|
+
|
|
211
|
+
expect(renders).toBe(settled);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("keeps every tile of a short clip, even far from the screen", async () => {
|
|
215
|
+
stripWidth = 4_096;
|
|
216
|
+
top = 5_000;
|
|
217
|
+
await mountStrips(1);
|
|
218
|
+
|
|
219
|
+
expect(spans()).toEqual(["0-4096"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("never re-measures a short clip on a scroll", async () => {
|
|
223
|
+
stripWidth = 300;
|
|
224
|
+
left = 100;
|
|
225
|
+
await mountStrips(1);
|
|
226
|
+
const reads = vi.mocked(Element.prototype.getBoundingClientRect);
|
|
227
|
+
reads.mockClear();
|
|
228
|
+
|
|
229
|
+
scrollTo(50);
|
|
230
|
+
|
|
231
|
+
expect(reads).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("mounts nothing for a strip far above or below the screen", async () => {
|
|
235
|
+
top = 5_000;
|
|
236
|
+
await mountStrips(1);
|
|
237
|
+
|
|
238
|
+
expect(spans()).toEqual(["0-0"]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("measures a strip as it comes near the screen, without waiting for a frame", async () => {
|
|
242
|
+
const reports: IntersectionObserverCallback[] = [];
|
|
243
|
+
globalThis.IntersectionObserver = class {
|
|
244
|
+
constructor(callback: IntersectionObserverCallback) {
|
|
245
|
+
reports.push(callback);
|
|
246
|
+
}
|
|
247
|
+
observe() {}
|
|
248
|
+
unobserve() {}
|
|
249
|
+
disconnect() {}
|
|
250
|
+
} as unknown as typeof IntersectionObserver;
|
|
251
|
+
await mountStrips(1);
|
|
252
|
+
const strip = host.querySelector("[data-in-view]")!;
|
|
253
|
+
|
|
254
|
+
left = -10_000;
|
|
255
|
+
const [presence] = reports;
|
|
256
|
+
act(() =>
|
|
257
|
+
presence!(
|
|
258
|
+
[
|
|
259
|
+
{
|
|
260
|
+
isIntersecting: true,
|
|
261
|
+
target: strip.parentElement!,
|
|
262
|
+
} as unknown as IntersectionObserverEntry,
|
|
263
|
+
],
|
|
264
|
+
{} as IntersectionObserver,
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
expect(frames).toHaveLength(0);
|
|
269
|
+
expect(strip.getAttribute("data-in-view")).toBe("9216-11776");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("reads no strip far from the screen, however many clips the timeline mounts", async () => {
|
|
273
|
+
left = 50_000;
|
|
274
|
+
await mountStrips(50);
|
|
275
|
+
const reads = vi.mocked(Element.prototype.getBoundingClientRect);
|
|
276
|
+
reads.mockClear();
|
|
277
|
+
|
|
278
|
+
scrollTo(49_900);
|
|
279
|
+
|
|
280
|
+
expect(reads).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
});
|