@hyperframes/studio 0.8.107 → 0.8.109
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-h4mktSdm.js → hyperframes-player-CJYtikAy.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-BQZBDqMs.css} +1 -1
- package/dist/assets/{index-zoqPmBF6.js → index-CMSgrT2F.js} +1 -1
- package/dist/assets/{index-CM1xA_jD.js → index-CYP6dMd7.js} +212 -210
- package/dist/assets/{index-D8GKc2D9.js → index-HTFVHUJ4.js} +1 -1
- package/dist/{chunk-NCUBAVGK.js → chunk-5VSXRGIX.js} +4 -4
- package/dist/chunk-5VSXRGIX.js.map +1 -0
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{chunk-4OZC426H.js → chunk-QUXZVHOJ.js} +10 -504
- package/dist/chunk-QUXZVHOJ.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +53 -4
- package/dist/index.html +2 -2
- package/dist/index.js +15746 -13173
- package/dist/index.js.map +1 -1
- package/dist/player.js +3 -3
- package/dist/styles.css +1 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +8 -11
- package/src/components/EditorShell.tsx +6 -0
- package/src/components/LinkedSelectionToggle.tsx +73 -0
- package/src/components/TimelineToolbar.test.tsx +31 -0
- package/src/components/TimelineToolbar.tsx +2 -0
- package/src/components/editor/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/InlineTextCaret.test.tsx +150 -0
- package/src/components/editor/InlineTextCaret.tsx +211 -0
- package/src/components/editor/InlineTextToolbar.test.tsx +34 -31
- package/src/components/editor/InlineTextToolbar.tsx +7 -5
- package/src/components/editor/PropertyPanelFlat.tsx +4 -1
- package/src/components/editor/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditInlineText.ts +6 -4
- package/src/components/editor/domEditInlineTextElement.test.ts +7 -0
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/linkedSpeedEdits.test.tsx +164 -0
- package/src/components/editor/linkedSpeedEdits.ts +94 -0
- package/src/components/editor/manualOffsetDrag.ts +11 -0
- package/src/components/editor/mediaAudioEdits.test.ts +79 -4
- package/src/components/editor/mediaAudioEdits.ts +66 -24
- package/src/components/editor/mediaLinkEdits.test.ts +321 -0
- package/src/components/editor/mediaLinkEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +10 -5
- package/src/components/editor/propertyPanelMediaSection.tsx +6 -1
- package/src/components/editor/useDomEditNudge.ts +17 -12
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/editor/useInlineTextEditing.tsx +11 -4
- package/src/components/editor/useVolumeAutomation.ts +29 -7
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -0
- package/src/components/ui/Menu.test.tsx +3 -22
- package/src/components/ui/Menu.tsx +2 -2
- package/src/components/ui/Popover.tsx +2 -2
- package/src/components/ui/Select.tsx +2 -2
- package/src/components/ui/Tooltip.tsx +2 -2
- package/src/components/ui/mountHost.testHelpers.ts +8 -0
- package/src/components/ui/valueControls.test.tsx +2 -21
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/linkShortcuts.test.ts +70 -0
- package/src/hooks/linkShortcuts.ts +41 -0
- package/src/hooks/timelineLinkEditPlan.test.ts +113 -0
- package/src/hooks/timelineLinkEditPlan.ts +123 -0
- package/src/hooks/timelineMoveAdapter.ts +13 -0
- package/src/hooks/timelineTimingSync.test.ts +59 -0
- package/src/hooks/timelineTimingSync.ts +22 -3
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useEditHistoryActions.paint.test.tsx +41 -0
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +84 -2
- package/src/hooks/useGsapScriptCommits.ts +23 -3
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.history.test.tsx +76 -0
- package/src/hooks/usePreviewPersistence.ts +15 -0
- package/src/hooks/useRazorSplit.test.tsx +48 -0
- package/src/hooks/useRazorSplit.ts +26 -2
- package/src/hooks/useRenderClipContent.test.ts +108 -4
- package/src/hooks/useRenderClipContent.ts +33 -23
- package/src/hooks/useTimelineDeleteOps.ts +10 -4
- package/src/hooks/useTimelineEditGuard.ts +54 -0
- package/src/hooks/useTimelineEditing.ts +38 -57
- package/src/hooks/useTimelineLinkEditing.test.tsx +112 -0
- package/src/hooks/useTimelineLinkEditing.ts +153 -0
- package/src/index.ts +2 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/AudioWaveform.test.tsx +30 -3
- package/src/player/components/AudioWaveform.tsx +6 -7
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.groups.test.tsx +102 -0
- package/src/player/components/ClipContextMenu.test.tsx +10 -4
- package/src/player/components/ClipContextMenu.tsx +124 -77
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/OutOfSyncBadge.test.tsx +91 -0
- package/src/player/components/OutOfSyncBadge.tsx +119 -0
- package/src/player/components/TimelineClip.test.tsx +17 -0
- package/src/player/components/TimelineClip.tsx +8 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/audioClipLink.test.ts +228 -31
- package/src/player/components/audioClipLink.ts +72 -25
- package/src/player/components/clipMenuAudioItems.noShell.test.tsx +78 -0
- package/src/player/components/clipMenuAudioItems.test.tsx +168 -0
- package/src/player/components/clipMenuAudioItems.tsx +116 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +147 -0
- package/src/player/components/clipMenuLinkItems.test.ts +90 -0
- package/src/player/components/clipMenuLinkItems.tsx +121 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +233 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipSync.test.ts +67 -0
- package/src/player/components/clipSync.ts +80 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/linkLabelColor.test.ts +20 -0
- package/src/player/components/linkLabelColor.ts +24 -0
- package/src/player/components/timelineCallbacks.ts +12 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +9 -0
- package/src/player/components/timelineClipDragTypes.ts +2 -0
- package/src/player/components/timelineClipGestureHandlers.ts +4 -1
- package/src/player/components/timelineLinkSelection.ts +23 -0
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/components/useTimelineClipContextMenu.ts +2 -1
- package/src/player/components/useTimelineClipDrag.ts +21 -0
- package/src/player/components/useTimelineProviderState.tsx +3 -0
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +1 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -1
- package/src/player/lib/timelineDOM.test.ts +31 -0
- package/src/player/lib/timelineDOM.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +9 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/components.css +27 -11
- package/src/styles/theme.css +0 -1
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/gsapSoftReload.test.ts +238 -2
- package/src/utils/gsapSoftReload.ts +89 -74
- package/src/utils/gsapUndoRestore.test.ts +4 -3
- package/src/utils/gsapUndoRestore.ts +15 -0
- package/src/utils/linkedClipPreferences.ts +34 -0
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/softReloadTargets.ts +128 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioPendingEdits.test.ts +66 -0
- package/src/utils/studioPendingEdits.ts +45 -11
- package/src/utils/studioUiPreferences.ts +8 -0
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-4OZC426H.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-NCUBAVGK.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
|
|
2
|
-
import { findAuthoredElement
|
|
2
|
+
import { findAuthoredElement } from "./authoredSource";
|
|
3
3
|
import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
|
|
4
|
+
import { authoringFile, collectResetTargets, compositionFile, fileDocs } from "./softReloadTargets";
|
|
4
5
|
|
|
5
6
|
type IframeWindow = Window & {
|
|
6
7
|
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
|
|
@@ -131,15 +132,28 @@ function verifyTimelinesPopulated(win: IframeWindow, targetKeys: string[]): bool
|
|
|
131
132
|
return Object.keys(timelines).filter((k) => k !== "__proxied").length > 0;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
// GSAP masks a folded CSS translate/rotate/scale with `none
|
|
135
|
-
|
|
135
|
+
// GSAP masks a folded CSS translate/rotate/scale with `none`, and a tween writes what it animates
|
|
136
|
+
// inline; a fresh load has only what the file authors. Opacity has its own restore.
|
|
137
|
+
function restoreAuthoredStyle(
|
|
136
138
|
style: CSSStyleDeclaration,
|
|
137
139
|
authored: CSSStyleDeclaration | null,
|
|
140
|
+
tweened: Set<string>,
|
|
138
141
|
) {
|
|
139
142
|
style.transform = authored?.transform ?? "";
|
|
140
143
|
if (!authored) return;
|
|
141
|
-
for (const prop of ["translate", "rotate", "scale"]) {
|
|
142
|
-
|
|
144
|
+
for (const prop of ["translate", "rotate", "scale", ...tweened]) {
|
|
145
|
+
if (prop === "opacity") continue;
|
|
146
|
+
style.setProperty(prop, authored.getPropertyValue(prop), authored.getPropertyPriority(prop));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function runSuppressed(win: IframeWindow, reload: () => void): boolean {
|
|
151
|
+
try {
|
|
152
|
+
if (win.__hfSuppressSceneMutations) win.__hfSuppressSceneMutations(reload);
|
|
153
|
+
else reload();
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
143
157
|
}
|
|
144
158
|
}
|
|
145
159
|
|
|
@@ -196,6 +210,8 @@ export interface SoftReloadOptions {
|
|
|
196
210
|
/** After-write file HTML — the primary source for the authored opacity and transform restore. */
|
|
197
211
|
authoredHtml?: string;
|
|
198
212
|
reparse?: Element[];
|
|
213
|
+
/** Other composition files a reset element is written in, by path; null when one could not be read. */
|
|
214
|
+
nestedFiles?: Map<string, string> | null;
|
|
199
215
|
}
|
|
200
216
|
|
|
201
217
|
/**
|
|
@@ -247,6 +263,12 @@ export function applySoftReloadFinalization(
|
|
|
247
263
|
}
|
|
248
264
|
}
|
|
249
265
|
|
|
266
|
+
function timelineKeys(scriptText: string): string[] {
|
|
267
|
+
return [...scriptText.matchAll(/__timelines\s*\[\s*["'`]([^"'`]+)["'`]\s*\]/g)]
|
|
268
|
+
.map((m) => m[1]!)
|
|
269
|
+
.filter((key) => key !== "__proxied");
|
|
270
|
+
}
|
|
271
|
+
|
|
250
272
|
export function applySoftReload(
|
|
251
273
|
iframe: HTMLIFrameElement | null,
|
|
252
274
|
scriptText: string,
|
|
@@ -259,6 +281,8 @@ export function applySoftReload(
|
|
|
259
281
|
const doc = iframe.contentDocument;
|
|
260
282
|
if (!win || !doc) return "cannot-soft-reload";
|
|
261
283
|
if (!win.gsap || !win.__hfForceTimelineRebind) return "cannot-soft-reload";
|
|
284
|
+
// Without the file an element is written in, only a full load restores it.
|
|
285
|
+
if (options.nestedFiles === null) return "cannot-soft-reload";
|
|
262
286
|
|
|
263
287
|
// Which composition(s) does this script rebuild? A soft reload re-runs ONE
|
|
264
288
|
// composition's GSAP script, which re-registers its own window.__timelines[key].
|
|
@@ -267,9 +291,7 @@ export function applySoftReload(
|
|
|
267
291
|
// of the global timeline — so tearing down ALL of them (or the global timeline's
|
|
268
292
|
// children) and re-running a single script wipes every OTHER composition,
|
|
269
293
|
// reverting its edits. Scope the teardown to the keys THIS script re-registers.
|
|
270
|
-
const targetKeys =
|
|
271
|
-
.map((m) => m[1]!)
|
|
272
|
-
.filter((key) => key !== "__proxied");
|
|
294
|
+
const targetKeys = timelineKeys(scriptText);
|
|
273
295
|
if (targetKeys.length === 0) return "cannot-soft-reload"; // can't scope safely → full reload
|
|
274
296
|
const gsapScripts = findGsapScriptElements(doc);
|
|
275
297
|
if (gsapScripts.length === 0) return "cannot-soft-reload";
|
|
@@ -302,6 +324,7 @@ export function applySoftReload(
|
|
|
302
324
|
// once the plugin loads; the alternative (returning false) would trigger a
|
|
303
325
|
// full iframe reload that destroys the very WebGL context we're preserving.
|
|
304
326
|
let deferredToAsync = false;
|
|
327
|
+
let builtDom = false;
|
|
305
328
|
|
|
306
329
|
// Authored-opacity resolution for the restore loop below. Three-state:
|
|
307
330
|
// "0.98" — the element's authored inline opacity
|
|
@@ -311,16 +334,10 @@ export function applySoftReload(
|
|
|
311
334
|
// The just-written file (`authoredHtml`) is the current truth; the runtime's
|
|
312
335
|
// parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
|
|
313
336
|
// covers elements the file lookup can't resolve. Parsed lazily, at most once.
|
|
314
|
-
|
|
337
|
+
const docFor = fileDocs(compositionFile(doc, targetKeys), authoredHtml, options.nestedFiles);
|
|
315
338
|
const findAuthoredStyle = (el: HTMLElement): CSSStyleDeclaration | null => {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
|
|
319
|
-
} catch {
|
|
320
|
-
authoredDoc = null;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
const source = authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
|
|
339
|
+
const fileDoc = docFor(authoringFile(el));
|
|
340
|
+
const source = fileDoc ? findAuthoredElement(fileDoc, el) : null;
|
|
324
341
|
// The parsed file lives in this realm, so instanceof holds here, unlike for the iframe nodes below.
|
|
325
342
|
return source instanceof HTMLElement || source instanceof SVGElement ? source.style : null;
|
|
326
343
|
};
|
|
@@ -330,29 +347,14 @@ export function applySoftReload(
|
|
|
330
347
|
// fallow-ignore-next-line complexity
|
|
331
348
|
const doReload = () => {
|
|
332
349
|
const timelines = win.__timelines;
|
|
333
|
-
const
|
|
350
|
+
const targets = collectResetTargets(win, doc, targetKeys, reparse);
|
|
334
351
|
|
|
335
352
|
// Kill ONLY the target composition's timeline(s) — leaving every other
|
|
336
353
|
// composition's timeline (and its children on the global timeline) intact.
|
|
337
354
|
if (timelines) {
|
|
338
355
|
for (const key of targetKeys) {
|
|
339
|
-
const tl = timelines[key] as
|
|
340
|
-
| {
|
|
341
|
-
kill?: () => void;
|
|
342
|
-
clear?: () => void;
|
|
343
|
-
getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
|
|
344
|
-
}
|
|
345
|
-
| undefined;
|
|
356
|
+
const tl = timelines[key] as { kill?: () => void; clear?: () => void } | undefined;
|
|
346
357
|
if (!tl) continue;
|
|
347
|
-
if (tl.getChildren) {
|
|
348
|
-
try {
|
|
349
|
-
for (const child of tl.getChildren(true)) {
|
|
350
|
-
if (typeof child.targets === "function") {
|
|
351
|
-
for (const t of child.targets()) allTargets.push(t);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
} catch {}
|
|
355
|
-
}
|
|
356
358
|
try {
|
|
357
359
|
// kill() keeps the children, and the finalize seek renders this timeline until the rebind swaps it.
|
|
358
360
|
tl.clear?.();
|
|
@@ -362,29 +364,11 @@ export function applySoftReload(
|
|
|
362
364
|
}
|
|
363
365
|
}
|
|
364
366
|
|
|
365
|
-
// Also reset elements carrying a GSAP-applied inline `transform` that the
|
|
366
|
-
// timeline-children sweep above missed — a dragged element whose position
|
|
367
|
-
// was a standalone `gsap.set` (never a timeline child), or one whose
|
|
368
|
-
// keyframes were just removed (no longer in any timeline). Their last
|
|
369
|
-
// `gsap.set` transform is otherwise orphaned: the re-run won't re-set it
|
|
370
|
-
// and the sweep above can't see it, so the element renders offset from its
|
|
371
|
-
// source position (matching the overlay) until a full reload. The clear
|
|
372
|
-
// below runs BEFORE the re-run, which re-applies the transform for any
|
|
373
|
-
// element the new script still animates.
|
|
374
|
-
const seenTargets = new Set<Element>(allTargets);
|
|
375
|
-
for (const el of doc.querySelectorAll<HTMLElement>("[style*='transform']")) {
|
|
376
|
-
// Gate on the GSAP cache (`_gsap`) so we only reset transforms GSAP owns —
|
|
377
|
-
// never strip an authored, non-GSAP inline transform.
|
|
378
|
-
if (el.style.transform && "_gsap" in el && !seenTargets.has(el)) {
|
|
379
|
-
seenTargets.add(el);
|
|
380
|
-
allTargets.push(el);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
367
|
// Reset GSAP's internal transform cache so from() tweens don't read stale
|
|
385
368
|
// end values. `clearProps: "all"` is needed to flush the cache, but it also
|
|
386
369
|
// nukes the element's CSS base (position, width, height, etc.) from the
|
|
387
|
-
// HTML `style=""` attribute. Save → clear → restore →
|
|
370
|
+
// HTML `style=""` attribute. Save → clear → restore → what GSAP wrote, from the file.
|
|
371
|
+
const allTargets = [...targets.keys()];
|
|
388
372
|
if (allTargets.length > 0 && win.gsap?.set) {
|
|
389
373
|
const saved: Array<[HTMLElement, string]> = [];
|
|
390
374
|
for (const el of allTargets) {
|
|
@@ -399,7 +383,7 @@ export function applySoftReload(
|
|
|
399
383
|
for (const [el, css] of saved) {
|
|
400
384
|
const s = el.style;
|
|
401
385
|
s.cssText = css;
|
|
402
|
-
|
|
386
|
+
restoreAuthoredStyle(s, findAuthoredStyle(el), targets.get(el)!);
|
|
403
387
|
// The restored cssText carries RUNTIME opacity, not authored opacity:
|
|
404
388
|
// a mid-flight tween's interpolated value, or the color-grading hide
|
|
405
389
|
// (`opacity: 0 !important`). The re-run script's tweens re-initialize
|
|
@@ -430,7 +414,14 @@ export function applySoftReload(
|
|
|
430
414
|
}
|
|
431
415
|
const s = doc.createElement("script");
|
|
432
416
|
s.textContent = `(function(){${scriptText}\n})();`;
|
|
417
|
+
const ownScriptNode = 1;
|
|
418
|
+
const before = doc.body.querySelectorAll("*").length + ownScriptNode;
|
|
433
419
|
doc.body.appendChild(s);
|
|
420
|
+
// A script that builds DOM builds it again on every run; only a full load starts from the file.
|
|
421
|
+
// Not seen: text-only writes, nodes put in the head or a shadow root, nodes added
|
|
422
|
+
// later, and a builder that removes its old nodes first (which needs no full load).
|
|
423
|
+
builtDom = doc.body.querySelectorAll("*").length > before;
|
|
424
|
+
if (builtDom && deferredToAsync) onAsyncFailure?.();
|
|
434
425
|
finalizeSoftReload(win, currentTime);
|
|
435
426
|
};
|
|
436
427
|
|
|
@@ -479,23 +470,47 @@ export function applySoftReload(
|
|
|
479
470
|
executeScript();
|
|
480
471
|
};
|
|
481
472
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
473
|
+
const run = () => runSuppressed(win, doReload);
|
|
474
|
+
// The synchronous re-run threw or built DOM a second time: the preview is now
|
|
475
|
+
// genuinely broken (no timeline, or duplicated nodes). Escalate to a full reload.
|
|
476
|
+
if (!run() || builtDom) return "cannot-soft-reload";
|
|
477
|
+
// When MotionPath needs async loading, the script hasn't executed yet —
|
|
478
|
+
// skip the __timelines check and report success optimistically (the script
|
|
479
|
+
// WILL run on plugin load; onAsyncFailure covers the CDN-error case).
|
|
480
|
+
if (deferredToAsync) return "applied";
|
|
481
|
+
// The re-run executed. If the target keys read back, we're done; otherwise
|
|
482
|
+
// it's the TRANSIENT empty-timeline window (live state is correct) — surfaced
|
|
483
|
+
// as "verify-failed" so callers know NOT to escalate.
|
|
484
|
+
return verifyTimelinesPopulated(win, targetKeys) ? "applied" : "verify-failed";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Other composition files for a soft reload: undefined when none are needed, null when a read failed. */
|
|
488
|
+
export type NestedFiles = Map<string, string> | null | undefined;
|
|
489
|
+
|
|
490
|
+
export async function settleNestedReads(
|
|
491
|
+
reads: Promise<Map<string, string>> | null,
|
|
492
|
+
): Promise<NestedFiles> {
|
|
493
|
+
return reads ? reads.catch(() => null) : undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Reads the composition files, other than the reloaded one, that the elements `scriptText`'s
|
|
498
|
+
* re-run resets are written in; null when there are none. Read them before the soft reload.
|
|
499
|
+
*/
|
|
500
|
+
export function readNestedFiles(
|
|
501
|
+
iframe: HTMLIFrameElement | null,
|
|
502
|
+
scriptText: string,
|
|
503
|
+
readFile: (path: string) => Promise<string>,
|
|
504
|
+
): Promise<Map<string, string>> | null {
|
|
505
|
+
const win = iframe?.contentWindow as IframeWindow | null;
|
|
506
|
+
const doc = iframe?.contentDocument;
|
|
507
|
+
if (!win || !doc) return null;
|
|
508
|
+
const keys = timelineKeys(scriptText);
|
|
509
|
+
const own = compositionFile(doc, keys);
|
|
510
|
+
const files = [...collectResetTargets(win, doc, keys).keys()].map(authoringFile);
|
|
511
|
+
const paths = [...new Set(files)].filter((file): file is string => file !== null && file !== own);
|
|
512
|
+
if (paths.length === 0) return null;
|
|
513
|
+
return Promise.all(paths.map((path) => readFile(path))).then(
|
|
514
|
+
(texts) => new Map(paths.map((path, i) => [path, texts[i]!])),
|
|
515
|
+
);
|
|
501
516
|
}
|
|
@@ -170,9 +170,10 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
170
170
|
|
|
171
171
|
describe("an undo that re-runs a changed script matches a fresh load of the restored file", () => {
|
|
172
172
|
const script = (extra: string) => `window.__timelines["root"]=gsap.timeline();${extra}`;
|
|
173
|
+
const root = (body: string) => `<div data-composition-id="root">${body}</div>`;
|
|
173
174
|
const undoScriptEdit = (live: string, authored: string, edit: string) => {
|
|
174
175
|
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
175
|
-
`${live}<script>${script(edit)}</script>`,
|
|
176
|
+
`${root(live)}<script>${script(edit)}</script>`,
|
|
176
177
|
);
|
|
177
178
|
const clearProps = (targets: HTMLElement[]) =>
|
|
178
179
|
targets.forEach((t) => t.removeAttribute("style"));
|
|
@@ -180,8 +181,8 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
180
181
|
for (const el of doc.querySelectorAll("[style*=transform]")) Object.assign(el, { _gsap: {} });
|
|
181
182
|
const files = {
|
|
182
183
|
[ROOT]: {
|
|
183
|
-
previous: wrap(`${authored}<script>${script(edit)}</script>`),
|
|
184
|
-
restored: wrap(`${authored}<script>${script("")}</script>`),
|
|
184
|
+
previous: wrap(`${root(authored)}<script>${script(edit)}</script>`),
|
|
185
|
+
restored: wrap(`${root(authored)}<script>${script("")}</script>`),
|
|
185
186
|
},
|
|
186
187
|
};
|
|
187
188
|
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
applySoftReloadFinalization,
|
|
8
8
|
extractGsapScriptText,
|
|
9
9
|
findGsapScriptElements,
|
|
10
|
+
readNestedFiles,
|
|
10
11
|
} from "./gsapSoftReload";
|
|
11
12
|
import { isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
|
|
12
13
|
import { findAuthoredElement, parseSavedSource } from "./authoredSource";
|
|
@@ -324,6 +325,18 @@ export function showRestoreInPlace(
|
|
|
324
325
|
return null;
|
|
325
326
|
}
|
|
326
327
|
|
|
328
|
+
/** The other composition files a re-run of the restored script resets elements of; null when none. */
|
|
329
|
+
export function readUndoNestedFiles(
|
|
330
|
+
iframe: HTMLIFrameElement | null,
|
|
331
|
+
activeCompPath: string | null,
|
|
332
|
+
files: RestoreFiles | undefined,
|
|
333
|
+
readFile: (path: string) => Promise<string>,
|
|
334
|
+
): Promise<Map<string, string>> | null {
|
|
335
|
+
const active = files?.[activeCompPath ?? "index.html"];
|
|
336
|
+
const script = active ? extractGsapScriptText(active.restored) : null;
|
|
337
|
+
return script ? readNestedFiles(iframe, script, readFile) : null;
|
|
338
|
+
}
|
|
339
|
+
|
|
327
340
|
/**
|
|
328
341
|
* Soft-apply an undo/redo restore to the live preview WITHOUT a full iframe
|
|
329
342
|
* remount (which blanks the frame black and re-flashes the WebGL context). Eligible
|
|
@@ -358,6 +371,7 @@ export function applyUndoRestoreToPreview(
|
|
|
358
371
|
files: RestoreFiles | undefined,
|
|
359
372
|
currentTime: number,
|
|
360
373
|
reload: () => void,
|
|
374
|
+
nestedFiles?: Map<string, string> | null,
|
|
361
375
|
): "soft" | "full" {
|
|
362
376
|
// The master view carries a NULL activeCompPath but the root iframe shows
|
|
363
377
|
// index.html — the codebase-wide convention (`activeCompPath || "index.html"`).
|
|
@@ -398,6 +412,7 @@ export function applyUndoRestoreToPreview(
|
|
|
398
412
|
currentTimeOverride: currentTime,
|
|
399
413
|
authoredHtml: active.restored,
|
|
400
414
|
reparse,
|
|
415
|
+
nestedFiles,
|
|
401
416
|
});
|
|
402
417
|
if (result === "cannot-soft-reload") {
|
|
403
418
|
reloadPreview();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { STUDIO_PREVIEW_FPS } from "../player/lib/time";
|
|
3
|
+
import { readStudioUiPreferences, writeStudioUiPreferences } from "./studioUiPreferences";
|
|
4
|
+
|
|
5
|
+
interface LinkedClipPreferences {
|
|
6
|
+
/** Premiere's Linked Selection: clicks and edits reach every link partner. */
|
|
7
|
+
linkedSelection: boolean;
|
|
8
|
+
setLinkedSelection: (enabled: boolean) => void;
|
|
9
|
+
/** Red offset badges on a video and audio from one source that drifted apart. */
|
|
10
|
+
syncIndicatorsVisible: boolean;
|
|
11
|
+
setSyncIndicatorsVisible: (visible: boolean) => void;
|
|
12
|
+
compositionFps: number;
|
|
13
|
+
setCompositionFps: (fps: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const useLinkedClipPreferences = create<LinkedClipPreferences>((set) => ({
|
|
17
|
+
linkedSelection: readStudioUiPreferences().linkedSelectionEnabled ?? true,
|
|
18
|
+
setLinkedSelection: (enabled) => {
|
|
19
|
+
writeStudioUiPreferences({ linkedSelectionEnabled: enabled });
|
|
20
|
+
set({ linkedSelection: enabled });
|
|
21
|
+
},
|
|
22
|
+
syncIndicatorsVisible: readStudioUiPreferences().syncIndicatorsVisible ?? true,
|
|
23
|
+
setSyncIndicatorsVisible: (visible) => {
|
|
24
|
+
writeStudioUiPreferences({ syncIndicatorsVisible: visible });
|
|
25
|
+
set({ syncIndicatorsVisible: visible });
|
|
26
|
+
},
|
|
27
|
+
compositionFps: STUDIO_PREVIEW_FPS,
|
|
28
|
+
setCompositionFps: (fps) => {
|
|
29
|
+
if (Number.isFinite(fps) && fps > 0) set({ compositionFps: fps });
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
export const isLinkedSelectionOn = (): boolean =>
|
|
34
|
+
useLinkedClipPreferences.getState().linkedSelection;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "./projectRouting";
|
|
2
|
+
|
|
3
|
+
/** Resolve a media src to its project-relative preview path, or null. */
|
|
4
|
+
export function resolvePreviewRelative(
|
|
5
|
+
src: string | undefined,
|
|
6
|
+
pid: string,
|
|
7
|
+
origin: string,
|
|
8
|
+
): string | null {
|
|
9
|
+
if (!src) return null;
|
|
10
|
+
try {
|
|
11
|
+
const parsed = new URL(src, origin);
|
|
12
|
+
const base = new URL(buildProjectApiPath(pid, `/preview/`), origin).pathname;
|
|
13
|
+
return parsed.pathname.startsWith(base)
|
|
14
|
+
? decodeURIComponent(parsed.pathname.slice(base.length))
|
|
15
|
+
: null;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Which elements a GSAP soft reload resets, what their tweens wrote, and which file writes each.
|
|
2
|
+
import { parseSavedSource } from "./authoredSource";
|
|
3
|
+
|
|
4
|
+
type TweenLike = {
|
|
5
|
+
targets?: () => Element[];
|
|
6
|
+
vars?: Record<string, unknown>;
|
|
7
|
+
getChildren?: (deep: boolean) => TweenLike[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const NESTED_VARS = new Set(["css", "startAt", "keyframes"]);
|
|
11
|
+
|
|
12
|
+
// The CSS names a tween's vars write; GSAP's own keys (ease, duration) are not CSS and restore as no-ops.
|
|
13
|
+
function tweenedProps(vars: unknown): string[] {
|
|
14
|
+
if (!vars || typeof vars !== "object") return [];
|
|
15
|
+
return Object.entries(vars).flatMap(([key, value]) => [
|
|
16
|
+
key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`),
|
|
17
|
+
...(NESTED_VARS.has(key) || /^\d|%$/.test(key) ? tweenedProps(value) : []),
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A nested composition's timeline sits inside the root one but only its own script rebuilds it.
|
|
22
|
+
function ownTweens(tl: TweenLike, nested: Set<unknown>): TweenLike[] {
|
|
23
|
+
return (tl.getChildren?.(false) ?? []).flatMap((child) => {
|
|
24
|
+
if (nested.has(child)) return [];
|
|
25
|
+
return child.getChildren ? ownTweens(child, nested) : [child];
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The preview marks each inlined composition's host with its file; the host itself is the parent's.
|
|
30
|
+
export function authoringFile(el: Element): string | null {
|
|
31
|
+
return (
|
|
32
|
+
el.parentElement?.closest("[data-composition-file]")?.getAttribute("data-composition-file") ??
|
|
33
|
+
null
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function addTweenTargets(tween: TweenLike, targets: Map<Element, Set<string>>): void {
|
|
38
|
+
const props = tweenedProps(tween.vars);
|
|
39
|
+
for (const el of tween.targets?.() ?? []) {
|
|
40
|
+
const seen = targets.get(el) ?? new Set<string>();
|
|
41
|
+
for (const prop of props) seen.add(prop);
|
|
42
|
+
targets.set(el, seen);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Each element the re-run rebuilds, with the CSS names its killed tweens wrote. */
|
|
47
|
+
export function collectResetTargets(
|
|
48
|
+
win: { __timelines?: Record<string, unknown> },
|
|
49
|
+
doc: Document,
|
|
50
|
+
targetKeys: string[],
|
|
51
|
+
reparse: Element[] = [],
|
|
52
|
+
): Map<Element, Set<string>> {
|
|
53
|
+
// Elements the caller needs GSAP to parse again, with nothing tweened yet.
|
|
54
|
+
const targets = new Map<Element, Set<string>>(reparse.map((el) => [el, new Set<string>()]));
|
|
55
|
+
const timelines = (win.__timelines ?? {}) as Record<string, TweenLike | undefined>;
|
|
56
|
+
const others = new Set<unknown>(
|
|
57
|
+
Object.entries(timelines).flatMap(([key, tl]) => (targetKeys.includes(key) ? [] : [tl])),
|
|
58
|
+
);
|
|
59
|
+
for (const key of targetKeys) {
|
|
60
|
+
try {
|
|
61
|
+
for (const tween of ownTweens(timelines[key] ?? {}, others)) addTweenTargets(tween, targets);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
sweepHeldElements(doc, targetKeys, others, targets);
|
|
65
|
+
return targets;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function tweenTargetsIn(timelines: Set<unknown>): Set<Element> {
|
|
69
|
+
const els = new Set<Element>();
|
|
70
|
+
for (const tl of timelines) {
|
|
71
|
+
try {
|
|
72
|
+
for (const tween of (tl as TweenLike | undefined)?.getChildren?.(true) ?? []) {
|
|
73
|
+
for (const el of tween.targets?.() ?? []) els.add(el);
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
return els;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function compositionRoot(doc: Document, key: string): Element | undefined {
|
|
81
|
+
return doc.querySelectorAll(`[data-composition-id="${CSS.escape(key)}"]`)[0];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function compositionFile(doc: Document, targetKeys: string[]): string | null {
|
|
85
|
+
const root = targetKeys.map((key) => compositionRoot(doc, key)).find(Boolean);
|
|
86
|
+
return root?.closest("[data-composition-file]")?.getAttribute("data-composition-file") ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A standalone gsap.set, or keyframes just removed, leaves GSAP state no timeline child shows.
|
|
90
|
+
// The nearest composition root says whose script set it; another timeline's targets stay its own.
|
|
91
|
+
function sweepHeldElements(
|
|
92
|
+
doc: Document,
|
|
93
|
+
targetKeys: string[],
|
|
94
|
+
others: Set<unknown>,
|
|
95
|
+
targets: Map<Element, Set<string>>,
|
|
96
|
+
): void {
|
|
97
|
+
const elsewhere = tweenTargetsIn(others);
|
|
98
|
+
const roots = new Set<Element | null | undefined>(targetKeys.map((k) => compositionRoot(doc, k)));
|
|
99
|
+
roots.delete(undefined);
|
|
100
|
+
for (const root of roots) {
|
|
101
|
+
for (const el of root ? [root, ...root.querySelectorAll("*")] : []) {
|
|
102
|
+
if (targets.has(el) || elsewhere.has(el) || !("_gsap" in el)) continue;
|
|
103
|
+
if (el === root || roots.has(el.parentElement?.closest("[data-composition-id]")))
|
|
104
|
+
targets.set(el, new Set());
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Each file a reset element may be written in, parsed at most once per reload: the reloaded
|
|
110
|
+
// composition's own file is the one just written, any other comes from the caller.
|
|
111
|
+
export function fileDocs(
|
|
112
|
+
ownFile: string | null,
|
|
113
|
+
written: string | undefined,
|
|
114
|
+
nestedFiles: Map<string, string> | null | undefined,
|
|
115
|
+
): (file: string | null) => Document | null {
|
|
116
|
+
const docs = new Map<string | null, Document | null>();
|
|
117
|
+
return (file) => {
|
|
118
|
+
if (!docs.has(file)) {
|
|
119
|
+
const text = file === ownFile ? written : nestedFiles?.get(file ?? "");
|
|
120
|
+
try {
|
|
121
|
+
docs.set(file, text ? parseSavedSource(text) : null);
|
|
122
|
+
} catch {
|
|
123
|
+
docs.set(file, null);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return docs.get(file) ?? null;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -21,7 +21,7 @@ export function escapeHtmlAttribute(value: string): string {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/** Reverse escapeHtmlAttribute so callers get the original value. */
|
|
24
|
-
function unescapeHtmlAttribute(value: string): string {
|
|
24
|
+
export function unescapeHtmlAttribute(value: string): string {
|
|
25
25
|
return value
|
|
26
26
|
.replace(/"/g, '"')
|
|
27
27
|
.replace(/</g, "<")
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
addStudioPendingEditFlushListener,
|
|
5
|
+
beginStudioPendingEdit,
|
|
5
6
|
flushStudioPendingEdits,
|
|
6
7
|
hasStudioPendingEdits,
|
|
8
|
+
revertNewestStudioPendingEdit,
|
|
7
9
|
trackStudioPendingEdit,
|
|
8
10
|
trackedStudioEdit,
|
|
9
11
|
} from "./studioPendingEdits";
|
|
@@ -211,3 +213,67 @@ describe("trackedStudioEdit", () => {
|
|
|
211
213
|
expect(hasStudioPendingEdits()).toBe(false);
|
|
212
214
|
});
|
|
213
215
|
});
|
|
216
|
+
|
|
217
|
+
describe("a pending edit undo can paint back", () => {
|
|
218
|
+
const shown: string[] = [];
|
|
219
|
+
const edit = (name: string) =>
|
|
220
|
+
beginStudioPendingEdit(() => {
|
|
221
|
+
shown.push(`${name} undone`);
|
|
222
|
+
return () => shown.push(`${name} again`);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("paints back only the newest edit, once, and shows it again on request", () => {
|
|
226
|
+
shown.length = 0;
|
|
227
|
+
const first = edit("first");
|
|
228
|
+
const second = edit("second");
|
|
229
|
+
const again = revertNewestStudioPendingEdit();
|
|
230
|
+
expect(shown).toEqual(["second undone"]);
|
|
231
|
+
expect(second.reverted()).toBe(true);
|
|
232
|
+
expect(first.reverted()).toBe(false);
|
|
233
|
+
expect(revertNewestStudioPendingEdit()).toBeNull();
|
|
234
|
+
again!();
|
|
235
|
+
expect(shown).toEqual(["second undone", "second again"]);
|
|
236
|
+
first.settle();
|
|
237
|
+
second.settle();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("paints nothing when the newest edit has no revert", async () => {
|
|
241
|
+
shown.length = 0;
|
|
242
|
+
const move = edit("move");
|
|
243
|
+
let saved!: () => void;
|
|
244
|
+
trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve)));
|
|
245
|
+
expect(revertNewestStudioPendingEdit()).toBeNull();
|
|
246
|
+
expect(shown).toEqual([]);
|
|
247
|
+
saved();
|
|
248
|
+
move.settle();
|
|
249
|
+
await flushStudioPendingEdits();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("counts what the edit starts inside adopt as that edit, not a newer one", async () => {
|
|
253
|
+
shown.length = 0;
|
|
254
|
+
const move = edit("move");
|
|
255
|
+
let saved!: () => void;
|
|
256
|
+
const save = move.adopt(() =>
|
|
257
|
+
trackStudioPendingEdit(new Promise<void>((resolve) => (saved = resolve))),
|
|
258
|
+
);
|
|
259
|
+
move.settle(save);
|
|
260
|
+
revertNewestStudioPendingEdit();
|
|
261
|
+
expect(shown).toEqual(["move undone"]);
|
|
262
|
+
saved();
|
|
263
|
+
await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
|
|
264
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("a pending edit whose start throws", () => {
|
|
269
|
+
it("ends, so undo and export never wait on it", async () => {
|
|
270
|
+
const edit = beginStudioPendingEdit(null);
|
|
271
|
+
expect(() =>
|
|
272
|
+
edit.adopt(() => {
|
|
273
|
+
throw new Error("The commit threw.");
|
|
274
|
+
}),
|
|
275
|
+
).toThrow("The commit threw.");
|
|
276
|
+
await expect(flushStudioPendingEdits()).resolves.toEqual({ status: "clean" });
|
|
277
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
278
|
+
});
|
|
279
|
+
});
|