@hyperframes/studio 0.8.115 → 0.8.117
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-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
- package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
- package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
- package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.js} +1 -1
- package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
- package/dist/chunk-3F47EFCT.js.map +1 -0
- package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
- package/dist/index.d.ts +132 -41
- package/dist/index.html +1 -1
- package/dist/index.js +1184 -1084
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/captions/hooks/useCaptionSync.ts +10 -6
- package/src/components/ProjectUnreachableBanner.tsx +3 -3
- package/src/components/editor/DomEditOverlay.tsx +1 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
- package/src/components/editor/domGroupMoveBy.ts +95 -0
- package/src/components/editor/dragUndoPaint.test.ts +5 -5
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
- package/src/components/editor/manualEditingAvailability.ts +8 -0
- package/src/components/editor/manualOffsetDrag.ts +8 -9
- package/src/components/editor/motionPathSelection.test.ts +6 -31
- package/src/components/editor/plainTranslate.ts +5 -3
- package/src/components/editor/probeSourceElement.ts +2 -1
- package/src/components/editor/propertyPanelFont.tsx +3 -2
- package/src/components/editor/propertyPanelHelpers.ts +2 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
- package/src/components/editor/rotationDraft.ts +2 -1
- package/src/components/editor/useColorGradingController.ts +2 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
- package/src/components/editor/useFxCarveNodes.ts +2 -1
- package/src/components/editor/useFxLevelling.ts +2 -1
- package/src/components/feedback/projectProvenance.ts +2 -1
- package/src/components/nle/NLEContext.test.ts +1 -0
- package/src/components/nle/NLEContext.tsx +2 -1
- package/src/components/nle/NLEPreview.tsx +12 -10
- package/src/components/nle/PreviewPoster.tsx +2 -1
- package/src/components/renders/useFfmpegStatus.ts +2 -1
- package/src/components/renders/useRenderQueue.ts +5 -4
- package/src/components/sidebar/AssetsTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/domSelectionTestHarness.ts +23 -0
- package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
- package/src/hooks/domStyleCommit.ts +8 -1
- package/src/hooks/draggedGsapPosition.ts +10 -0
- package/src/hooks/gsapDragCommit.test.ts +75 -164
- package/src/hooks/gsapDragCommit.ts +2 -105
- package/src/hooks/gsapDragPositionCommit.ts +30 -393
- package/src/hooks/gsapEditOutcome.ts +22 -2
- package/src/hooks/gsapGroupPreflight.ts +34 -0
- package/src/hooks/gsapMutationClient.ts +4 -3
- package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
- package/src/hooks/gsapParsedTween.test.ts +62 -0
- package/src/hooks/gsapParsedTween.ts +243 -0
- package/src/hooks/gsapPositionDetection.test.ts +78 -0
- package/src/hooks/gsapPositionDetection.ts +52 -33
- package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
- package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
- package/src/hooks/gsapResizeIntercept.test.ts +63 -11
- package/src/hooks/gsapResizeIntercept.ts +79 -169
- package/src/hooks/gsapResizeSweep.test.ts +16 -33
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
- package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
- package/src/hooks/gsapRuntimeBridge.ts +105 -131
- package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +9 -1
- package/src/hooks/gsapShared.ts +7 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
- package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
- package/src/hooks/gsapValueAtPlayhead.ts +292 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
- package/src/hooks/keyframeCacheAstLoad.ts +2 -1
- package/src/hooks/newTweenTarget.test.ts +41 -76
- package/src/hooks/plainRotation.ts +5 -1
- package/src/hooks/timelineTimingSync.test.ts +1 -0
- package/src/hooks/timelineTimingSync.ts +2 -1
- package/src/hooks/useAskAgentModal.ts +2 -1
- package/src/hooks/useBlockCatalog.ts +2 -1
- package/src/hooks/useCaptionDetection.ts +2 -1
- package/src/hooks/useCompositionContentLoader.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
- package/src/hooks/useDomEditPersist.test.tsx +335 -0
- package/src/hooks/useDomEditSession.test.tsx +30 -1
- package/src/hooks/useDomEditSession.ts +10 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
- package/src/hooks/useDomGeometryCommit.ts +7 -9
- package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
- package/src/hooks/useEditHistoryActions.test.tsx +15 -0
- package/src/hooks/useEditHistoryActions.ts +16 -5
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useEnableKeyframes.test.ts +32 -1
- package/src/hooks/useEnableKeyframes.ts +2 -1
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
- package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
- package/src/hooks/useFileManager.ts +14 -10
- package/src/hooks/useFileTree.ts +3 -2
- package/src/hooks/useFrameCapture.ts +5 -1
- package/src/hooks/useFreezeFrame.test.ts +39 -3
- package/src/hooks/useFreezeFrame.ts +7 -3
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
- package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
- package/src/hooks/useGsapAwareEditing.ts +17 -8
- package/src/hooks/useGsapAwareGroupMove.ts +18 -18
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
- package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
- package/src/hooks/useGsapScriptCommits.ts +21 -17
- package/src/hooks/useLintModal.ts +2 -1
- package/src/hooks/usePersistentEditHistory.test.ts +302 -3
- package/src/hooks/usePersistentEditHistory.ts +50 -17
- package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
- package/src/hooks/usePreviewPersistence.ts +13 -11
- package/src/hooks/useProjectFileWriter.ts +20 -19
- package/src/hooks/useRemoveBackground.ts +2 -1
- package/src/hooks/useSdkSession.ts +2 -1
- package/src/hooks/useServerConnection.ts +4 -3
- package/src/hooks/useStudioSelectionPublisher.ts +2 -1
- package/src/hooks/useThumbnailStill.test.ts +100 -0
- package/src/hooks/useThumbnailStill.ts +37 -0
- package/src/hooks/useTimelineDeleteOps.ts +2 -1
- package/src/index.ts +4 -0
- package/src/player/components/AudioWaveform.tsx +3 -2
- package/src/player/components/CompositionThumbnail.tsx +2 -1
- package/src/player/components/clipMenuNormalize.ts +2 -1
- package/src/player/components/clipPeakMap.ts +2 -1
- package/src/player/hooks/useTimelinePlayer.ts +5 -1
- package/src/player/lib/thumbnailScheduler.test.ts +16 -0
- package/src/player/lib/thumbnailScheduler.ts +10 -2
- package/src/player/sceneSwap.ts +2 -1
- package/src/telemetry/client.ts +2 -1
- package/src/utils/blockInstaller.ts +2 -1
- package/src/utils/elementGsap.test.ts +9 -0
- package/src/utils/elementGsap.ts +7 -0
- package/src/utils/gsapSoftReload.test.ts +39 -0
- package/src/utils/gsapSoftReload.ts +9 -14
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
- package/src/utils/gsapUndoRestore.ts +3 -6
- package/src/utils/previewPrefetch.ts +2 -1
- package/src/utils/razorSplitTransaction.ts +12 -8
- package/src/utils/softReloadTargets.ts +5 -6
- package/src/utils/studioApiFetch.test.ts +75 -0
- package/src/utils/studioApiFetch.ts +13 -0
- package/src/utils/studioFileHistory.ts +5 -1
- package/src/utils/studioHelpers.ts +7 -1
- package/src/utils/studioPendingEdits.test.ts +23 -6
- package/src/utils/studioPendingEdits.ts +32 -8
- package/src/utils/studioTelemetry.ts +2 -1
- package/src/utils/timelineCompositionInsert.ts +3 -2
- package/src/webmcp/StudioAgentTools.tsx +2 -1
- package/dist/chunk-TXZMMOMA.js.map +0 -1
- /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
|
@@ -36,6 +36,7 @@ import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFi
|
|
|
36
36
|
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
|
37
37
|
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
|
38
38
|
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
|
39
|
+
import { isStudioManualEditGestureLiveIn } from "../../components/editor/manualEditsDom";
|
|
39
40
|
import { createPreviewMessageHandler } from "./previewMessageRouter";
|
|
40
41
|
import { timelineElementsChanged } from "./timelinePlayerSync";
|
|
41
42
|
import { safeContentDocument } from "./timelineSyncHydration";
|
|
@@ -486,7 +487,10 @@ export function useTimelinePlayer({
|
|
|
486
487
|
// A newer edit, or anything replacing the live preview (a reload, a composition switch), wins.
|
|
487
488
|
const isCurrent = () => gen === refreshGenRef.current && slot === previewGeneration();
|
|
488
489
|
const swap = sceneSwapFor(iframe);
|
|
489
|
-
|
|
490
|
+
const swapWouldReplaceGestureNode =
|
|
491
|
+
!!iframe.contentDocument && isStudioManualEditGestureLiveIn(iframe.contentDocument);
|
|
492
|
+
if (!swap || isRefreshingRef.current || swapWouldReplaceGestureNode)
|
|
493
|
+
return reloadWholeFilm(url.toString());
|
|
490
494
|
swap(url.toString(), isCurrent, cancel.signal).catch((error: unknown) => {
|
|
491
495
|
if (!isCurrent()) return;
|
|
492
496
|
logReload("scene-swap-refused", { reason: String(error) });
|
|
@@ -219,6 +219,22 @@ describe("ThumbnailScheduler", () => {
|
|
|
219
219
|
expect(scheduler.getDiagnostics()).toMatchObject({ queued: 0, active: 0, leases: 0 });
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
+
it("frees an aborted job's slot at once, even when its loader ignores the abort", async () => {
|
|
223
|
+
const scheduler = new ThumbnailScheduler(
|
|
224
|
+
resolveTimelineViewportBudgets({ concurrentVideoDecodes: 1 }),
|
|
225
|
+
);
|
|
226
|
+
const stuck = deferred<ThumbnailLoadedResult>();
|
|
227
|
+
const lease = scheduler.acquire(
|
|
228
|
+
request("stuck", () => stuck.promise, "visible", { kind: "video" }),
|
|
229
|
+
vi.fn(),
|
|
230
|
+
);
|
|
231
|
+
lease.release();
|
|
232
|
+
const nextLoad = vi.fn(async () => result("next"));
|
|
233
|
+
scheduler.acquire(request("next", nextLoad, "visible", { kind: "video" }), vi.fn());
|
|
234
|
+
await flush();
|
|
235
|
+
expect(nextLoad).toHaveBeenCalledTimes(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
222
238
|
it("disposes a late result exactly once after its final lease releases", async () => {
|
|
223
239
|
const scheduler = new ThumbnailScheduler();
|
|
224
240
|
const pending = deferred<ThumbnailLoadedResult>();
|
|
@@ -310,6 +310,15 @@ export class ThumbnailScheduler {
|
|
|
310
310
|
entry.controller = controller;
|
|
311
311
|
this.activeByBucket[bucket]++;
|
|
312
312
|
this.activeByKind[entry.request.kind]++;
|
|
313
|
+
let slotFree = false;
|
|
314
|
+
const freeSlot = () => {
|
|
315
|
+
if (slotFree) return;
|
|
316
|
+
slotFree = true;
|
|
317
|
+
this.activeByBucket[bucket]--;
|
|
318
|
+
this.activeByKind[entry.request.kind]--;
|
|
319
|
+
this.pump();
|
|
320
|
+
};
|
|
321
|
+
controller.signal.addEventListener("abort", freeSlot, { once: true });
|
|
313
322
|
this.notify(entry);
|
|
314
323
|
|
|
315
324
|
const pending = this.loadWithTimeout(entry, controller);
|
|
@@ -331,8 +340,7 @@ export class ThumbnailScheduler {
|
|
|
331
340
|
})
|
|
332
341
|
.finally(() => {
|
|
333
342
|
if (entry.controller === controller) entry.controller = null;
|
|
334
|
-
|
|
335
|
-
this.activeByKind[entry.request.kind]--;
|
|
343
|
+
freeSlot();
|
|
336
344
|
this.pump();
|
|
337
345
|
});
|
|
338
346
|
}
|
package/src/player/sceneSwap.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
1
2
|
type SwapWindow = Window & {
|
|
2
3
|
__hfSwapScenes?: (html: string, signal?: AbortSignal) => Promise<void>;
|
|
3
4
|
};
|
|
@@ -72,7 +73,7 @@ export function sceneSwapFor(
|
|
|
72
73
|
);
|
|
73
74
|
cancel?.addEventListener("abort", () => deadline.abort(cancel.reason), { once: true });
|
|
74
75
|
const swapping = (async () => {
|
|
75
|
-
const response = await
|
|
76
|
+
const response = await studioApiFetch(url, { signal: deadline.signal });
|
|
76
77
|
if (!response.ok) throw new Error(`preview request failed with ${response.status}`);
|
|
77
78
|
const html = await response.text();
|
|
78
79
|
if (!isCurrent()) throw new Error("superseded by a newer reload");
|
package/src/telemetry/client.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { browserTelemetryAllowed } from "./policy";
|
|
|
9
9
|
import { getBrowserSystemMeta } from "./system";
|
|
10
10
|
import { canaryEventProperties } from "./canary";
|
|
11
11
|
import { recordBreadcrumb } from "./breadcrumbs";
|
|
12
|
+
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
12
13
|
|
|
13
14
|
// Write-only PostHog project key, safe to embed in client code.
|
|
14
15
|
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
|
@@ -83,7 +84,7 @@ function send(url: string, payload: string): void {
|
|
|
83
84
|
// Prefer fetch with keepalive (survives page navigation). sendBeacon is a
|
|
84
85
|
// fallback for older runtimes where fetch isn't available.
|
|
85
86
|
try {
|
|
86
|
-
void
|
|
87
|
+
void studioApiFetch(url, {
|
|
87
88
|
method: "POST",
|
|
88
89
|
headers: { "Content-Type": "application/json" },
|
|
89
90
|
body: payload,
|
|
@@ -12,6 +12,7 @@ import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
|
|
12
12
|
import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
|
|
13
13
|
import { extendRootDurationInSource } from "./rootDuration";
|
|
14
14
|
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
15
|
+
import { studioApiFetch } from "./studioApiFetch";
|
|
15
16
|
|
|
16
17
|
function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
|
|
17
18
|
try {
|
|
@@ -65,7 +66,7 @@ async function installRegistryItem({
|
|
|
65
66
|
block: RegistryItem;
|
|
66
67
|
compositionFile: string;
|
|
67
68
|
} | null> {
|
|
68
|
-
const response = await
|
|
69
|
+
const response = await studioApiFetch(buildProjectApiPath(projectId, `/registry/install`), {
|
|
69
70
|
method: "POST",
|
|
70
71
|
headers: { "Content-Type": "application/json" },
|
|
71
72
|
body: JSON.stringify({ blockName }),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
import { elementTargets } from "./elementGsap";
|
|
4
|
+
|
|
5
|
+
it("keeps only the targets GSAP styles: not a plain object, not an XML-namespace element", () => {
|
|
6
|
+
const box = document.createElement("div");
|
|
7
|
+
const item = document.createElementNS("urn:x", "item");
|
|
8
|
+
expect(elementTargets({ targets: () => [{}, item, box, null] })).toEqual([box]);
|
|
9
|
+
});
|
package/src/utils/elementGsap.ts
CHANGED
|
@@ -43,3 +43,10 @@ export function readElementGsapNumber(element: HTMLElement, prop: string): numbe
|
|
|
43
43
|
const value = Number(gsapOf(element)?.getProperty?.(element, prop));
|
|
44
44
|
return Number.isFinite(value) ? value : null;
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/** The targets CSSPlugin styles: not plain objects (the runtime's filler) or XML-namespace elements. */
|
|
48
|
+
export function elementTargets(tween: { targets?: () => unknown[] }): Element[] {
|
|
49
|
+
return (tween.targets?.() ?? []).filter((t): t is Element =>
|
|
50
|
+
Boolean((t as HTMLElement | null)?.style && (t as Node).nodeType),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -481,6 +481,45 @@ describe("applySoftReload authored-style restore", () => {
|
|
|
481
481
|
return el.style.getPropertyValue("opacity");
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
+
it("flushes only elements when a tween targets a plain object (the runtime's duration filler)", () => {
|
|
485
|
+
const el = document.createElement("div");
|
|
486
|
+
// GSAP's clearProps writes target.style.cssText, which throws on a plain object.
|
|
487
|
+
const set = vi.fn((targets: Array<{ style: CSSStyleDeclaration }>) => {
|
|
488
|
+
for (const t of targets) t.style.cssText = "";
|
|
489
|
+
});
|
|
490
|
+
const { iframe } = buildIframeWithTarget(el, {
|
|
491
|
+
gsap: { timeline: vi.fn(), set },
|
|
492
|
+
__timelines: {
|
|
493
|
+
root: {
|
|
494
|
+
kill: vi.fn(),
|
|
495
|
+
getChildren: () => [{ targets: () => [el] }, { targets: () => [{}] }],
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("applied");
|
|
501
|
+
expect(set).toHaveBeenCalledWith([el], { clearProps: "all" });
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("falls back to a full reload, and says why, when the flush throws", () => {
|
|
505
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
506
|
+
const { iframe } = buildIframeWithTarget(document.createElement("div"), {
|
|
507
|
+
gsap: {
|
|
508
|
+
timeline: vi.fn(),
|
|
509
|
+
set: vi.fn(() => {
|
|
510
|
+
throw new Error("flush failed");
|
|
511
|
+
}),
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe("cannot-soft-reload");
|
|
516
|
+
expect(error).toHaveBeenCalledWith(
|
|
517
|
+
expect.stringContaining("soft reload threw"),
|
|
518
|
+
expect.any(Error),
|
|
519
|
+
);
|
|
520
|
+
error.mockRestore();
|
|
521
|
+
});
|
|
522
|
+
|
|
484
523
|
it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
|
|
485
524
|
const el = document.createElement("img");
|
|
486
525
|
el.setAttribute("data-hf-id", "hf-1");
|
|
@@ -152,7 +152,8 @@ function runSuppressed(win: IframeWindow, reload: () => void): boolean {
|
|
|
152
152
|
if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
|
|
153
153
|
else reload();
|
|
154
154
|
return true;
|
|
155
|
-
} catch {
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error("[Studio] GSAP soft reload threw; falling back to a full reload", error);
|
|
156
157
|
return false;
|
|
157
158
|
}
|
|
158
159
|
}
|
|
@@ -209,7 +210,6 @@ export interface SoftReloadOptions {
|
|
|
209
210
|
currentTimeOverride?: number;
|
|
210
211
|
/** After-write file HTML — the primary source for the authored opacity and transform restore. */
|
|
211
212
|
authoredHtml?: string;
|
|
212
|
-
reparse?: Element[];
|
|
213
213
|
/** Other composition files a reset element is written in, by path; null when one could not be read. */
|
|
214
214
|
nestedFiles?: Map<string, string> | null;
|
|
215
215
|
}
|
|
@@ -274,7 +274,7 @@ export function applySoftReload(
|
|
|
274
274
|
scriptText: string,
|
|
275
275
|
options: SoftReloadOptions = {},
|
|
276
276
|
): SoftReloadResult {
|
|
277
|
-
const { onAsyncFailure, currentTimeOverride, authoredHtml
|
|
277
|
+
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
|
|
278
278
|
if (!iframe || !scriptText) return "cannot-soft-reload";
|
|
279
279
|
|
|
280
280
|
const win = iframe.contentWindow as IframeWindow | null;
|
|
@@ -347,7 +347,7 @@ export function applySoftReload(
|
|
|
347
347
|
// fallow-ignore-next-line complexity
|
|
348
348
|
const doReload = () => {
|
|
349
349
|
const timelines = win.__timelines;
|
|
350
|
-
const targets = collectResetTargets(win, doc, targetKeys
|
|
350
|
+
const targets = collectResetTargets(win, doc, targetKeys);
|
|
351
351
|
|
|
352
352
|
// Kill ONLY the target composition's timeline(s) — leaving every other
|
|
353
353
|
// composition's timeline (and its children on the global timeline) intact.
|
|
@@ -370,16 +370,11 @@ export function applySoftReload(
|
|
|
370
370
|
// HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
|
|
371
371
|
const allTargets = [...targets.keys()];
|
|
372
372
|
if (allTargets.length > 0 && win.gsap?.set) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
|
|
379
|
-
}
|
|
380
|
-
try {
|
|
381
|
-
win.gsap.set(allTargets, { clearProps: "all" });
|
|
382
|
-
} catch {}
|
|
373
|
+
// Iframe-realm nodes: instanceof HTMLElement fails across realms, so style access is duck-typed.
|
|
374
|
+
const saved = allTargets.map(
|
|
375
|
+
(el) => [el as HTMLElement, (el as HTMLElement).style.cssText] as const,
|
|
376
|
+
);
|
|
377
|
+
win.gsap.set(allTargets, { clearProps: "all" });
|
|
383
378
|
for (const [el, css] of saved) {
|
|
384
379
|
const s = el.style;
|
|
385
380
|
s.cssText = css;
|
|
@@ -16,11 +16,9 @@ const CROP = "inset(0px 40px 0px 0px)";
|
|
|
16
16
|
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
|
|
17
17
|
let browser: Browser;
|
|
18
18
|
let undoBundle: string;
|
|
19
|
+
let softReloadBundle: string;
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
const executablePath = findSystemChrome();
|
|
22
|
-
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
23
|
-
browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
|
21
|
+
async function bundle(file: string, name: string): Promise<string> {
|
|
24
22
|
const out = await build({
|
|
25
23
|
configFile: false,
|
|
26
24
|
logLevel: "silent",
|
|
@@ -30,44 +28,46 @@ beforeAll(async () => {
|
|
|
30
28
|
build: {
|
|
31
29
|
write: false,
|
|
32
30
|
minify: false,
|
|
33
|
-
lib: {
|
|
34
|
-
entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
|
|
35
|
-
formats: ["iife"],
|
|
36
|
-
name: "hfUndo",
|
|
37
|
-
},
|
|
31
|
+
lib: { entry: fileURLToPath(new URL(file, import.meta.url)), formats: ["iife"], name },
|
|
38
32
|
},
|
|
39
33
|
});
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
return (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0].code;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
const executablePath = findSystemChrome();
|
|
39
|
+
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
40
|
+
browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
|
41
|
+
undoBundle = await bundle("./gsapUndoRestore.ts", "hfUndo");
|
|
42
|
+
softReloadBundle = await bundle("./gsapSoftReload.ts", "hfSoftReload");
|
|
42
43
|
});
|
|
43
44
|
|
|
44
45
|
afterAll(() => browser?.close());
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
])("an undone crop leaves a $gsap $placement r$rotation layer where it was", async (spec) => {
|
|
47
|
+
type Spec = { gsap: string; placement: string; rotation: number; time: number };
|
|
48
|
+
|
|
49
|
+
/** The bench's root crop fixture, shown at `spec.time` with the module under test loaded. */
|
|
50
|
+
async function openCase(spec: Spec, moduleBundle: string) {
|
|
51
51
|
const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
|
|
52
52
|
writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
|
|
53
53
|
const restored = readFileSync(join(dir, "index.html"), "utf8");
|
|
54
|
-
const previous = restored.replace(
|
|
55
|
-
`<div id="target"`,
|
|
56
|
-
`<div id="target" style="clip-path: ${CROP}"`,
|
|
57
|
-
);
|
|
58
54
|
const page = await browser.newPage();
|
|
59
55
|
await page.setViewport({ width: 1920, height: 1080 });
|
|
60
56
|
await page.setRequestInterception(true);
|
|
57
|
+
// Only the fixture's GSAP CDN script is served; anything else is refused, never fetched.
|
|
61
58
|
page.on("request", (request) =>
|
|
62
59
|
request.url().endsWith("/gsap.min.js")
|
|
63
60
|
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
64
|
-
: request.
|
|
61
|
+
: request.abort("blockedbyclient"),
|
|
65
62
|
);
|
|
66
63
|
await page.setContent(restored, { waitUntil: "load" });
|
|
67
64
|
await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
68
65
|
await page.waitForFunction(() => "__player" in window);
|
|
69
|
-
await page.evaluate(
|
|
70
|
-
|
|
66
|
+
await page.evaluate(moduleBundle);
|
|
67
|
+
await page.evaluate(
|
|
68
|
+
(time) => (window as unknown as { __player: { seek(t: number): void } }).__player.seek(time),
|
|
69
|
+
spec.time,
|
|
70
|
+
);
|
|
71
71
|
const box = () =>
|
|
72
72
|
page.evaluate(() => {
|
|
73
73
|
const { left, top, width, height } = document
|
|
@@ -75,22 +75,75 @@ it.each([
|
|
|
75
75
|
.getBoundingClientRect();
|
|
76
76
|
return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
|
|
77
77
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
);
|
|
81
|
-
const before = await box();
|
|
82
|
-
const outcome = await page.evaluate(
|
|
83
|
-
(files, crop) => {
|
|
84
|
-
document.getElementById("target")!.style.setProperty("clip-path", crop);
|
|
85
|
-
const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
|
|
86
|
-
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
|
|
87
|
-
return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
|
|
88
|
-
},
|
|
89
|
-
{ "index.html": { previous, restored } },
|
|
90
|
-
CROP,
|
|
91
|
-
);
|
|
78
|
+
return { page, restored, box };
|
|
79
|
+
}
|
|
92
80
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
81
|
+
// The first case opens the cold browser's first page, which overran 5 s on a loaded CI runner.
|
|
82
|
+
it.each([
|
|
83
|
+
{ gsap: "hold", placement: "px", rotation: 0, time: 1 },
|
|
84
|
+
{ gsap: "tween", placement: "px", rotation: 0, time: 1 },
|
|
85
|
+
{ gsap: "tween", placement: "xpercent", rotation: 30, time: 1 },
|
|
86
|
+
{ gsap: "keys", placement: "px", rotation: 0, time: 1 },
|
|
87
|
+
{ gsap: "keys", placement: "px", rotation: 30, time: 2 },
|
|
88
|
+
{ gsap: "scale", placement: "px", rotation: 0, time: 1 },
|
|
89
|
+
{ gsap: "spin", placement: "px", rotation: 30, time: 1 },
|
|
90
|
+
{ gsap: "fromto", placement: "px", rotation: 0, time: 1 },
|
|
91
|
+
])(
|
|
92
|
+
"an undone crop leaves a $gsap $placement r$rotation layer at t$time where it was",
|
|
93
|
+
async (spec) => {
|
|
94
|
+
const { page, restored, box } = await openCase(spec, undoBundle);
|
|
95
|
+
const previous = restored.replace(
|
|
96
|
+
`<div id="target"`,
|
|
97
|
+
`<div id="target" style="clip-path: ${CROP}"`,
|
|
98
|
+
);
|
|
99
|
+
const before = await box();
|
|
100
|
+
const outcome = await page.evaluate(
|
|
101
|
+
(files, crop, time) => {
|
|
102
|
+
document.getElementById("target")!.style.setProperty("clip-path", crop);
|
|
103
|
+
const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
|
|
104
|
+
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
|
|
105
|
+
return undo.applyUndoRestoreToPreview(iframe, "index.html", files, time, () => {});
|
|
106
|
+
},
|
|
107
|
+
{ "index.html": { previous, restored } },
|
|
108
|
+
CROP,
|
|
109
|
+
spec.time,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(outcome).toBe("soft");
|
|
113
|
+
expect(await box()).toEqual(before);
|
|
114
|
+
await page.close();
|
|
115
|
+
},
|
|
116
|
+
30_000,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// A drop saves the file and soft-reloads it; the re-run must place an animated layer where it was.
|
|
120
|
+
it.each([
|
|
121
|
+
{ gsap: "tween", placement: "px", rotation: 0, time: 1 },
|
|
122
|
+
{ gsap: "keys", placement: "px", rotation: 30, time: 2 },
|
|
123
|
+
{ gsap: "spin", placement: "px", rotation: 0, time: 1 },
|
|
124
|
+
])(
|
|
125
|
+
"a soft reload keeps a $gsap $placement r$rotation layer at t$time where it was",
|
|
126
|
+
async (spec) => {
|
|
127
|
+
const { page, restored, box } = await openCase(spec, softReloadBundle);
|
|
128
|
+
const before = await box();
|
|
129
|
+
const outcome = await page.evaluate(
|
|
130
|
+
(html, time) => {
|
|
131
|
+
const soft = (window as unknown as { hfSoftReload: typeof import("./gsapSoftReload") })
|
|
132
|
+
.hfSoftReload;
|
|
133
|
+
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
|
|
134
|
+
const script = soft.extractGsapScriptText(html)!;
|
|
135
|
+
return soft.applySoftReload(iframe, script, {
|
|
136
|
+
currentTimeOverride: time,
|
|
137
|
+
authoredHtml: html,
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
restored,
|
|
141
|
+
spec.time,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(outcome).toBe("applied");
|
|
145
|
+
expect(await box()).toEqual(before);
|
|
146
|
+
await page.close();
|
|
147
|
+
},
|
|
148
|
+
30_000,
|
|
149
|
+
);
|
|
@@ -398,20 +398,17 @@ export function applyUndoRestoreToPreview(
|
|
|
398
398
|
const active = files[activeDocPath];
|
|
399
399
|
const restoredScript = active ? extractGsapScriptText(active.restored) : null;
|
|
400
400
|
const previousScript = active ? extractGsapScriptText(active.previous) : null;
|
|
401
|
-
const reparse = plan.scripted
|
|
402
|
-
|
|
403
|
-
: [];
|
|
404
|
-
if (reparse.length && !restoredScript) {
|
|
401
|
+
const reparse = plan.scripted && plan.targets.some(({ live }) => "_gsap" in live);
|
|
402
|
+
if (reparse && !restoredScript) {
|
|
405
403
|
reloadPreview();
|
|
406
404
|
return "full";
|
|
407
405
|
}
|
|
408
|
-
if (restoredScript && (restoredScript !== previousScript || reparse
|
|
406
|
+
if (restoredScript && (restoredScript !== previousScript || reparse)) {
|
|
409
407
|
syncStaleEditMarks(doc, active);
|
|
410
408
|
const result = applySoftReload(iframe, restoredScript, {
|
|
411
409
|
onAsyncFailure: reloadPreview,
|
|
412
410
|
currentTimeOverride: currentTime,
|
|
413
411
|
authoredHtml: active.restored,
|
|
414
|
-
reparse,
|
|
415
412
|
nestedFiles,
|
|
416
413
|
});
|
|
417
414
|
if (result === "cannot-soft-reload") {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { applyPreviewVariablesToUrl } from "../hooks/previewVariablesStore";
|
|
2
2
|
import { buildProjectApiPath, parseProjectIdFromHash } from "./projectRouting";
|
|
3
|
+
import { studioApiFetch } from "./studioApiFetch";
|
|
3
4
|
|
|
4
5
|
let prefetched: string | null = null;
|
|
5
6
|
|
|
@@ -10,5 +11,5 @@ export function prefetchPreviewForHash(hash: string): void {
|
|
|
10
11
|
prefetched = projectId;
|
|
11
12
|
const url = new URL(buildProjectApiPath(projectId, "/preview"), window.location.origin);
|
|
12
13
|
applyPreviewVariablesToUrl(url);
|
|
13
|
-
|
|
14
|
+
studioApiFetch(url).catch(() => undefined);
|
|
14
15
|
}
|
|
@@ -6,6 +6,7 @@ import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
|
|
|
6
6
|
import { buildProjectApiPath } from "./projectRouting";
|
|
7
7
|
import { markStudioWriteToken } from "./studioFileVersion";
|
|
8
8
|
import { resolveElementTrack } from "./studioHelpers";
|
|
9
|
+
import { studioApiFetch } from "./studioApiFetch";
|
|
9
10
|
|
|
10
11
|
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
11
12
|
|
|
@@ -101,7 +102,7 @@ export function buildAtomicCutIntents(
|
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
async function readFileVersion(projectId: string, path: string): Promise<string> {
|
|
104
|
-
const response = await
|
|
105
|
+
const response = await studioApiFetch(
|
|
105
106
|
buildProjectApiPath(projectId, `/files/${encodeURIComponent(path)}`),
|
|
106
107
|
);
|
|
107
108
|
if (!response.ok) throw new Error(`Failed to read ${path} before cut (${response.status})`);
|
|
@@ -124,14 +125,17 @@ async function requestAtomicCut(
|
|
|
124
125
|
}
|
|
125
126
|
const transactionToken = `cut:${crypto.randomUUID()}`;
|
|
126
127
|
markStudioWriteToken(transactionToken);
|
|
127
|
-
const response = await
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
const response = await studioApiFetch(
|
|
129
|
+
buildProjectApiPath(projectId, "/file-mutations/split-batch"),
|
|
130
|
+
{
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: {
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
"X-Hyperframes-Write-Token": transactionToken,
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({ files, transactionToken }),
|
|
132
137
|
},
|
|
133
|
-
|
|
134
|
-
});
|
|
138
|
+
);
|
|
135
139
|
const body = (await response.json().catch(() => null)) as
|
|
136
140
|
| (Partial<CutBatchResponse> & { error?: string; outcome?: string })
|
|
137
141
|
| null;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Which elements a GSAP soft reload resets, what their tweens wrote, and which file writes each.
|
|
2
2
|
import { parseSavedSource } from "./authoredSource";
|
|
3
|
+
import { elementTargets } from "./elementGsap";
|
|
3
4
|
|
|
4
5
|
type TweenLike = {
|
|
5
|
-
targets?: () =>
|
|
6
|
+
targets?: () => unknown[];
|
|
6
7
|
vars?: Record<string, unknown>;
|
|
7
8
|
getChildren?: (deep: boolean) => TweenLike[];
|
|
8
9
|
};
|
|
@@ -36,7 +37,7 @@ export function authoringFile(el: Element): string | null {
|
|
|
36
37
|
|
|
37
38
|
function addTweenTargets(tween: TweenLike, targets: Map<Element, Set<string>>): void {
|
|
38
39
|
const props = tweenedProps(tween.vars);
|
|
39
|
-
for (const el of tween
|
|
40
|
+
for (const el of elementTargets(tween)) {
|
|
40
41
|
const seen = targets.get(el) ?? new Set<string>();
|
|
41
42
|
for (const prop of props) seen.add(prop);
|
|
42
43
|
targets.set(el, seen);
|
|
@@ -48,10 +49,8 @@ export function collectResetTargets(
|
|
|
48
49
|
win: { __timelines?: Record<string, unknown> },
|
|
49
50
|
doc: Document,
|
|
50
51
|
targetKeys: string[],
|
|
51
|
-
reparse: Element[] = [],
|
|
52
52
|
): Map<Element, Set<string>> {
|
|
53
|
-
|
|
54
|
-
const targets = new Map<Element, Set<string>>(reparse.map((el) => [el, new Set<string>()]));
|
|
53
|
+
const targets = new Map<Element, Set<string>>();
|
|
55
54
|
const timelines = (win.__timelines ?? {}) as Record<string, TweenLike | undefined>;
|
|
56
55
|
const others = new Set<unknown>(
|
|
57
56
|
Object.entries(timelines).flatMap(([key, tl]) => (targetKeys.includes(key) ? [] : [tl])),
|
|
@@ -70,7 +69,7 @@ function tweenTargetsIn(timelines: Set<unknown>): Set<Element> {
|
|
|
70
69
|
for (const tl of timelines) {
|
|
71
70
|
try {
|
|
72
71
|
for (const tween of (tl as TweenLike | undefined)?.getChildren?.(true) ?? []) {
|
|
73
|
-
for (const el of tween
|
|
72
|
+
for (const el of elementTargets(tween)) els.add(el);
|
|
74
73
|
}
|
|
75
74
|
} catch {}
|
|
76
75
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
async function loadWithEnv(env: Record<string, string>) {
|
|
4
|
+
vi.resetModules();
|
|
5
|
+
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
|
|
6
|
+
return import("./studioApiFetch");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("studioApiFetch", () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllGlobals();
|
|
12
|
+
vi.unstubAllEnvs();
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("sends Studio's requests without credentials, keeping the caller's options", async () => {
|
|
17
|
+
const fetchMock = vi.fn(async () => new Response("ok"));
|
|
18
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
19
|
+
const { studioApiFetch } = await loadWithEnv({});
|
|
20
|
+
const signal = new AbortController().signal;
|
|
21
|
+
|
|
22
|
+
await studioApiFetch("/api/projects/demo/history/step", { method: "POST", signal });
|
|
23
|
+
|
|
24
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/history/step", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
signal,
|
|
27
|
+
credentials: "omit",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each([
|
|
32
|
+
["the CLI or Studio dev server", "http://localhost:5190/"],
|
|
33
|
+
["Desktop's own server", "http://127.0.0.1:43117/"],
|
|
34
|
+
["IPv6 loopback", "http://[::1]:8080/"],
|
|
35
|
+
])("leaves cookies out when Studio is served from %s", async (_, page) => {
|
|
36
|
+
const fetchMock = vi.fn(async () => new Response("ok"));
|
|
37
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
38
|
+
vi.stubGlobal("location", new URL(page));
|
|
39
|
+
const { studioApiFetch } = await loadWithEnv({});
|
|
40
|
+
|
|
41
|
+
await studioApiFetch("/api/projects/demo/files/index.html");
|
|
42
|
+
|
|
43
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
|
|
44
|
+
credentials: "omit",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps same-origin cookies off loopback, where a proxy or tunnel may authenticate with them", async () => {
|
|
49
|
+
const fetchMock = vi.fn(async () => new Response("ok"));
|
|
50
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
51
|
+
vi.stubGlobal("location", new URL("https://studio.example.com/editor"));
|
|
52
|
+
const { studioApiFetch } = await loadWithEnv({});
|
|
53
|
+
|
|
54
|
+
await studioApiFetch("/api/projects/demo/files/index.html");
|
|
55
|
+
|
|
56
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
|
|
57
|
+
credentials: "same-origin",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("sends same-origin credentials on loopback for a host that opted out", async () => {
|
|
62
|
+
vi.stubGlobal("location", new URL("http://localhost:5190/"));
|
|
63
|
+
const fetchMock = vi.fn(async () => new Response("ok"));
|
|
64
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
65
|
+
const { studioApiFetch } = await loadWithEnv({
|
|
66
|
+
VITE_STUDIO_API_SAME_ORIGIN_CREDENTIALS: "true",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await studioApiFetch("/api/projects/demo/files/index.html");
|
|
70
|
+
|
|
71
|
+
expect(fetchMock).toHaveBeenCalledWith("/api/projects/demo/files/index.html", {
|
|
72
|
+
credentials: "same-origin",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { STUDIO_API_SAME_ORIGIN_CREDENTIALS } from "../components/editor/manualEditingAvailability";
|
|
2
|
+
|
|
3
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
4
|
+
|
|
5
|
+
function studioApiCredentials(): RequestCredentials {
|
|
6
|
+
if (STUDIO_API_SAME_ORIGIN_CREDENTIALS) return "same-origin";
|
|
7
|
+
const host = globalThis.location?.hostname;
|
|
8
|
+
return !host || LOOPBACK_HOSTS.has(host) ? "omit" : "same-origin";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function studioApiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
12
|
+
return globalThis.fetch(input, { ...init, credentials: studioApiCredentials() });
|
|
13
|
+
}
|