@hyperframes/studio 0.8.105 → 0.8.107
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-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
- package/dist/assets/index-CM1xA_jD.js +394 -0
- package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
- package/dist/assets/index-lrT-gfHa.css +1 -0
- package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
- package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
- package/dist/chunk-4OZC426H.js.map +1 -0
- package/dist/index.d.ts +381 -14
- package/dist/index.html +11 -2
- package/dist/index.js +9974 -9045
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +9 -9
- package/src/App.tsx +5 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +2 -1
- package/src/components/StudioHeader.dom.test.tsx +31 -9
- package/src/components/StudioHeader.tsx +4 -1
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/ThemeToggle.tsx +99 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/DomEditOverlay.tsx +31 -5
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- package/src/components/editor/SourceEditor.tsx +2 -6
- package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +58 -2
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +4 -1
- package/src/components/editor/marqueeCommit.test.tsx +69 -1
- package/src/components/editor/marqueeCommit.ts +6 -1
- package/src/components/editor/mediaAudioEdits.test.ts +212 -0
- package/src/components/editor/mediaAudioEdits.ts +253 -0
- package/src/components/editor/overlayFrameLoop.test.ts +27 -0
- package/src/components/editor/overlayFrameLoop.ts +21 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.test.tsx +80 -3
- package/src/components/editor/propertyPanelFont.tsx +69 -46
- package/src/components/editor/propertyPanelHelpers.ts +1 -2
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/studioCodeTheme.test.ts +20 -0
- package/src/components/editor/studioCodeTheme.ts +99 -0
- package/src/components/editor/useDomEditNudge.test.tsx +165 -2
- package/src/components/editor/useDomEditNudge.ts +43 -25
- package/src/components/editor/useInlineTextEditing.tsx +1 -1
- package/src/components/editor/useMotionPathData.test.tsx +64 -0
- package/src/components/editor/useMotionPathData.ts +5 -1
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/hooks/appHotkeysDispatch.ts +2 -1
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/previewPromotionSelection.test.tsx +180 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +49 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +4 -5
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useClipboard.source.test.ts +99 -19
- package/src/hooks/useClipboard.ts +145 -124
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.test.tsx +76 -72
- package/src/hooks/useDomEditSession.ts +9 -46
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
- package/src/hooks/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useGsapTweenCache.ts +1 -2
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +21 -5
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/useLivePreviewIframe.ts +18 -0
- package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
- package/src/hooks/usePreviewPersistence.ts +8 -5
- package/src/hooks/useRenderClipContent.ts +3 -3
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +9 -0
- package/src/livePreviewIframeExport.test.tsx +33 -0
- package/src/main.tsx +4 -1
- package/src/player/components/AudioWaveform.test.tsx +36 -15
- package/src/player/components/AudioWaveform.tsx +24 -7
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.test.tsx +38 -0
- package/src/player/components/TimelineClip.tsx +14 -6
- package/src/player/components/TimelineClipFades.test.tsx +376 -74
- package/src/player/components/TimelineClipFades.tsx +368 -137
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useTimelineKeyboardActor.ts +1 -1
- package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/hooks/useShadowPreviewReload.ts +72 -5
- package/src/player/lib/automationStoreSync.test.ts +18 -1
- package/src/player/lib/automationStoreSync.ts +29 -17
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +16 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/player/sceneSwap.ts +18 -0
- package/src/styles/components.css +26 -0
- package/src/styles/studio.css +6 -0
- package/src/styles/theme.css +10 -0
- package/src/utils/clipboardPayload.ts +10 -1
- package/src/utils/gsapSoftReload.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +63 -0
- package/src/utils/studioPendingEdits.ts +37 -7
- package/src/utils/studioTheme.test.tsx +141 -0
- package/src/utils/studioTheme.ts +34 -0
- package/src/utils/studioUiPreferences.ts +3 -0
- package/src/utils/typingTarget.test.ts +21 -1
- package/src/utils/typingTarget.ts +8 -0
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/assets/index-Cre9X4RS.css +0 -1
- package/dist/assets/index-DEDhPmPD.js +0 -399
- package/dist/chunk-QADHJFDF.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
2
2
|
import {
|
|
3
3
|
googleFontStylesheetUrl,
|
|
4
4
|
POPULAR_GOOGLE_FONT_FAMILIES,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type LocalFontData,
|
|
20
20
|
} from "./propertyPanelHelpers";
|
|
21
21
|
import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
|
|
22
|
+
import { runWhenInputIdle } from "./overlayFrameLoop";
|
|
22
23
|
|
|
23
24
|
/* ------------------------------------------------------------------ */
|
|
24
25
|
/* Font helper functions */
|
|
@@ -117,6 +118,62 @@ function loadImportedFontStylesheet(asset: ImportedFontAsset): void {
|
|
|
117
118
|
document.head.appendChild(style);
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
type FontLists = {
|
|
122
|
+
loaded: boolean;
|
|
123
|
+
google: readonly string[];
|
|
124
|
+
googleKeys: ReadonlySet<string>;
|
|
125
|
+
installed: string[];
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
let fontLists: FontLists = {
|
|
129
|
+
loaded: false,
|
|
130
|
+
google: POPULAR_GOOGLE_FONT_FAMILIES,
|
|
131
|
+
googleKeys: new Set(POPULAR_GOOGLE_FONT_FAMILIES.map((f) => f.toLowerCase())),
|
|
132
|
+
installed: [],
|
|
133
|
+
};
|
|
134
|
+
let fontListsRequest: Promise<void> | null = null;
|
|
135
|
+
const fontListListeners = new Set<() => void>();
|
|
136
|
+
|
|
137
|
+
async function fetchFontList(url: string): Promise<string[]> {
|
|
138
|
+
const data = (await (await fetch(url)).json()) as { fonts?: unknown };
|
|
139
|
+
if (!Array.isArray(data.fonts)) throw new Error(`${url} returned no font list`);
|
|
140
|
+
return data.fonts as string[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function loadFontLists(): void {
|
|
144
|
+
if (fontLists.loaded) return;
|
|
145
|
+
fontListsRequest ??= Promise.all([
|
|
146
|
+
fetchFontList("/api/fonts"),
|
|
147
|
+
fetchFontList("/api/fonts/google"),
|
|
148
|
+
])
|
|
149
|
+
.then(([installed, google]) => {
|
|
150
|
+
const names = google.concat(POPULAR_GOOGLE_FONT_FAMILIES);
|
|
151
|
+
const families: string[] = [];
|
|
152
|
+
const googleKeys = new Set<string>();
|
|
153
|
+
let next = 0;
|
|
154
|
+
runWhenInputIdle((timeLeft) => {
|
|
155
|
+
while (next < names.length && timeLeft() > 0) {
|
|
156
|
+
const batch = names.slice(next, next + 200);
|
|
157
|
+
next += batch.length;
|
|
158
|
+
families.push(...uniqueFontFamilies(batch, googleKeys));
|
|
159
|
+
}
|
|
160
|
+
if (next < names.length) return false;
|
|
161
|
+
fontLists = { loaded: true, google: families, googleKeys, installed };
|
|
162
|
+
for (const listener of fontListListeners) listener();
|
|
163
|
+
return true;
|
|
164
|
+
});
|
|
165
|
+
})
|
|
166
|
+
.catch(() => {
|
|
167
|
+
fontListsRequest = null;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function subscribeFontLists(listener: () => void): () => void {
|
|
172
|
+
fontListListeners.add(listener);
|
|
173
|
+
loadFontLists();
|
|
174
|
+
return () => fontListListeners.delete(listener);
|
|
175
|
+
}
|
|
176
|
+
|
|
120
177
|
/* ------------------------------------------------------------------ */
|
|
121
178
|
/* FontFamilyField */
|
|
122
179
|
/* ------------------------------------------------------------------ */
|
|
@@ -146,9 +203,8 @@ export function FontFamilyField({
|
|
|
146
203
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
147
204
|
const [localFonts, setLocalFonts] = useState<string[]>([]);
|
|
148
205
|
const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
|
|
149
|
-
const
|
|
206
|
+
const lists = useSyncExternalStore(subscribeFontLists, () => fontLists);
|
|
150
207
|
const [loadingLocalFonts, setLoadingLocalFonts] = useState(false);
|
|
151
|
-
const [loadingGoogleFonts, setLoadingGoogleFonts] = useState(false);
|
|
152
208
|
const [importingFonts, setImportingFonts] = useState(false);
|
|
153
209
|
const [fontNotice, setFontNotice] = useState<string | null>(null);
|
|
154
210
|
const canQueryLocalFonts =
|
|
@@ -175,46 +231,18 @@ export function FontFamilyField({
|
|
|
175
231
|
}, [open]);
|
|
176
232
|
|
|
177
233
|
useEffect(() => {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
181
|
-
.then((data: { fonts?: string[] } | null) => {
|
|
182
|
-
if (cancelled || !Array.isArray(data?.fonts)) return;
|
|
183
|
-
setLocalFonts((cur) => uniqueFontFamilies([...cur, ...data.fonts!]));
|
|
184
|
-
})
|
|
185
|
-
.catch(() => undefined);
|
|
186
|
-
return () => {
|
|
187
|
-
cancelled = true;
|
|
188
|
-
};
|
|
189
|
-
}, []);
|
|
190
|
-
|
|
191
|
-
useEffect(() => {
|
|
192
|
-
let cancelled = false;
|
|
193
|
-
setLoadingGoogleFonts(true);
|
|
194
|
-
void fetch("/api/fonts/google")
|
|
195
|
-
.then((r) => (r.ok ? r.json() : null))
|
|
196
|
-
.then((data: { fonts?: string[] } | null) => {
|
|
197
|
-
if (cancelled || !Array.isArray(data?.fonts)) return;
|
|
198
|
-
setGoogleFonts(uniqueFontFamilies([...data.fonts!, ...POPULAR_GOOGLE_FONT_FAMILIES]));
|
|
199
|
-
})
|
|
200
|
-
.catch(() => undefined)
|
|
201
|
-
.finally(() => {
|
|
202
|
-
if (!cancelled) setLoadingGoogleFonts(false);
|
|
203
|
-
});
|
|
204
|
-
return () => {
|
|
205
|
-
cancelled = true;
|
|
206
|
-
};
|
|
207
|
-
}, []);
|
|
234
|
+
if (open) loadFontLists();
|
|
235
|
+
}, [open]);
|
|
208
236
|
|
|
209
237
|
useEffect(() => {
|
|
210
|
-
if (
|
|
238
|
+
if (lists.googleKeys.has(currentFamily.toLowerCase())) {
|
|
211
239
|
loadGoogleFontStylesheet(currentFamily);
|
|
212
240
|
}
|
|
213
241
|
const imported = importedFonts.find(
|
|
214
242
|
(f) => f.family.toLowerCase() === currentFamily.toLowerCase(),
|
|
215
243
|
);
|
|
216
244
|
if (imported) loadImportedFontStylesheet(imported);
|
|
217
|
-
}, [currentFamily,
|
|
245
|
+
}, [currentFamily, lists, importedFonts]);
|
|
218
246
|
|
|
219
247
|
const loadBrowserLocalFonts = async () => {
|
|
220
248
|
if (!canQueryLocalFonts || !window.queryLocalFonts) {
|
|
@@ -278,24 +306,19 @@ export function FontFamilyField({
|
|
|
278
306
|
const options = useMemo(() => {
|
|
279
307
|
if (!open) return [];
|
|
280
308
|
const documentFonts = collectDocumentFontFamilies();
|
|
281
|
-
const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
|
|
282
|
-
const taggedLocal = localFonts.map(
|
|
283
|
-
(family): FontOption => ({
|
|
284
|
-
family,
|
|
285
|
-
source: googleSet.has(family.toLowerCase()) ? "Google" : "Local",
|
|
286
|
-
}),
|
|
287
|
-
);
|
|
288
309
|
return sortFontOptions(
|
|
289
310
|
uniqueFontOptions([
|
|
290
311
|
{ family: currentFamily, source: "Current" },
|
|
291
312
|
...documentFonts.map((f): FontOption => ({ family: f, source: "Document" })),
|
|
292
313
|
...projectFontAssets,
|
|
293
|
-
...
|
|
294
|
-
...
|
|
314
|
+
...lists.google.map((f): FontOption => ({ family: f, source: "Google" })),
|
|
315
|
+
...[...lists.installed, ...localFonts].map(
|
|
316
|
+
(f): FontOption => ({ family: f, source: "Local" }),
|
|
317
|
+
),
|
|
295
318
|
...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
|
|
296
319
|
]),
|
|
297
320
|
);
|
|
298
|
-
}, [open, currentFamily,
|
|
321
|
+
}, [open, currentFamily, lists, localFonts, projectFontAssets]);
|
|
299
322
|
|
|
300
323
|
const filteredOptions = useMemo(() => {
|
|
301
324
|
const matches = options.filter((o) => fontMatchesQuery(o.family, query));
|
|
@@ -392,7 +415,7 @@ export function FontFamilyField({
|
|
|
392
415
|
type="text"
|
|
393
416
|
value={query}
|
|
394
417
|
disabled={disabled}
|
|
395
|
-
placeholder={
|
|
418
|
+
placeholder={lists.loaded ? "Search fonts" : "Loading Google Fonts..."}
|
|
396
419
|
onChange={(e) => {
|
|
397
420
|
setQuery(e.target.value);
|
|
398
421
|
setActiveIndex(-1);
|
|
@@ -125,8 +125,7 @@ export function localFontSortScore(font: LocalFontData): number {
|
|
|
125
125
|
return 3;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
export function uniqueFontFamilies(values: string[]): string[] {
|
|
129
|
-
const seen = new Set<string>();
|
|
128
|
+
export function uniqueFontFamilies(values: string[], seen = new Set<string>()): string[] {
|
|
130
129
|
return values.reduce<string[]>((result, value) => {
|
|
131
130
|
const family = value.trim();
|
|
132
131
|
if (!family) return result;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
+
import { isVideoAudible } from "../../player/lib/timelineElementHelpers";
|
|
2
3
|
import { Check, ClipboardList, Film, Music, Scissors } from "../../icons/SystemIcons";
|
|
3
4
|
import type { DomEditSelection } from "./domEditing";
|
|
4
5
|
import {
|
|
@@ -22,23 +23,29 @@ import {
|
|
|
22
23
|
audioGainToFaderPosition,
|
|
23
24
|
audioGainToText,
|
|
24
25
|
} from "@hyperframes/core/audio-gain";
|
|
26
|
+
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
27
|
+
import { commitCutout, commitHasAudioToggle, commitMutedToggle } from "./mediaAudioEdits";
|
|
25
28
|
|
|
26
29
|
// fallow-ignore-next-line complexity
|
|
27
30
|
export function MediaSection({
|
|
31
|
+
projectId = null,
|
|
28
32
|
projectDir,
|
|
29
33
|
element,
|
|
30
34
|
styles,
|
|
31
35
|
onSetStyle,
|
|
32
36
|
onSetAttribute,
|
|
33
37
|
onSetHtmlAttribute,
|
|
38
|
+
onSetAttributeBatch,
|
|
34
39
|
onRemoveBackground,
|
|
35
40
|
}: {
|
|
41
|
+
projectId?: string | null;
|
|
36
42
|
projectDir: string | null;
|
|
37
43
|
element: DomEditSelection;
|
|
38
44
|
styles: Record<string, string>;
|
|
39
45
|
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
|
|
40
46
|
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
|
|
41
47
|
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
48
|
+
onSetAttributeBatch: CommitDomAttributeBatch;
|
|
42
49
|
onRemoveBackground?: (
|
|
43
50
|
inputPath: string,
|
|
44
51
|
options: {
|
|
@@ -62,7 +69,11 @@ export function MediaSection({
|
|
|
62
69
|
|
|
63
70
|
const hasLoop = el.hasAttribute("loop");
|
|
64
71
|
const hasMuted = el.hasAttribute("muted");
|
|
65
|
-
const hasAudio =
|
|
72
|
+
const hasAudio = isVideoAudible({
|
|
73
|
+
tag: el.tagName,
|
|
74
|
+
hasAudioAttr: element.dataAttributes["has-audio"],
|
|
75
|
+
muted: el.hasAttribute("muted"),
|
|
76
|
+
});
|
|
66
77
|
|
|
67
78
|
const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
|
|
68
79
|
|
|
@@ -96,13 +107,7 @@ export function MediaSection({
|
|
|
96
107
|
setCreatePlate(false);
|
|
97
108
|
}, [srcAttr]);
|
|
98
109
|
|
|
99
|
-
const
|
|
100
|
-
await onSetHtmlAttribute("src", result.outputPath);
|
|
101
|
-
if (isVideo) {
|
|
102
|
-
await onSetAttribute("has-audio", "");
|
|
103
|
-
await onSetHtmlAttribute("muted", "true");
|
|
104
|
-
}
|
|
105
|
-
};
|
|
110
|
+
const mediaEdit = { element, projectId, projectSrc, commit: onSetAttributeBatch };
|
|
106
111
|
|
|
107
112
|
const runBackgroundRemoval = async () => {
|
|
108
113
|
if (!onRemoveBackground || !projectSrc || removeBusy) return;
|
|
@@ -115,13 +120,8 @@ export function MediaSection({
|
|
|
115
120
|
quality,
|
|
116
121
|
onProgress: setRemoveProgress,
|
|
117
122
|
});
|
|
118
|
-
await
|
|
119
|
-
setRemoveProgress({
|
|
120
|
-
status: "complete",
|
|
121
|
-
progress: 100,
|
|
122
|
-
stage: "Applied cutout",
|
|
123
|
-
...result,
|
|
124
|
-
});
|
|
123
|
+
const stage = await commitCutout(mediaEdit, result.outputPath, hasAudio);
|
|
124
|
+
setRemoveProgress({ status: "complete", progress: 100, stage, ...result });
|
|
125
125
|
} catch (error) {
|
|
126
126
|
setRemoveProgress({
|
|
127
127
|
status: "failed",
|
|
@@ -320,9 +320,7 @@ export function MediaSection({
|
|
|
320
320
|
<SegmentedControl
|
|
321
321
|
trackName="Muted"
|
|
322
322
|
value={hasMuted ? "on" : "off"}
|
|
323
|
-
onChange={(next) =>
|
|
324
|
-
void onSetHtmlAttribute("muted", next === "on" ? "true" : null);
|
|
325
|
-
}}
|
|
323
|
+
onChange={(next) => void commitMutedToggle(mediaEdit, next === "on")}
|
|
326
324
|
options={[
|
|
327
325
|
{ label: "On", value: "on" },
|
|
328
326
|
{ label: "Off", value: "off" },
|
|
@@ -337,15 +335,7 @@ export function MediaSection({
|
|
|
337
335
|
<SegmentedControl
|
|
338
336
|
trackName="Has audio track"
|
|
339
337
|
value={hasAudio ? "yes" : "no"}
|
|
340
|
-
onChange={(next) =>
|
|
341
|
-
if (next === "yes") {
|
|
342
|
-
void onSetAttribute("has-audio", "true");
|
|
343
|
-
void onSetHtmlAttribute("muted", null);
|
|
344
|
-
} else {
|
|
345
|
-
void onSetAttribute("has-audio", "");
|
|
346
|
-
void onSetHtmlAttribute("muted", "true");
|
|
347
|
-
}
|
|
348
|
-
}}
|
|
338
|
+
onChange={(next) => void commitHasAudioToggle(mediaEdit, next === "yes")}
|
|
349
339
|
options={[
|
|
350
340
|
{ label: "Yes", value: "yes" },
|
|
351
341
|
{ label: "No", value: "no" },
|
|
@@ -3,6 +3,7 @@ import type { ArcPathSegment, GsapAnimation } from "@hyperframes/parsers/gsap-pa
|
|
|
3
3
|
import type { DomEditSelection } from "./domEditing";
|
|
4
4
|
import type { ImportedFontAsset } from "./fontAssets";
|
|
5
5
|
import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
|
|
6
|
+
import type { CommitDomAttributeBatch } from "../../hooks/domEditCommitTypes";
|
|
6
7
|
|
|
7
8
|
export interface BackgroundRemovalProgress {
|
|
8
9
|
status: "processing" | "complete" | "failed";
|
|
@@ -68,6 +69,7 @@ export interface PropertyPanelProps {
|
|
|
68
69
|
value: string | null,
|
|
69
70
|
) => Promise<{ changedFiles: number; changedElements: number }>;
|
|
70
71
|
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
|
|
72
|
+
onSetAttributeBatch: CommitDomAttributeBatch;
|
|
71
73
|
onRemoveBackground?: (
|
|
72
74
|
inputPath: string,
|
|
73
75
|
options: {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8");
|
|
5
|
+
const source = read("./studioCodeTheme.ts");
|
|
6
|
+
const themeCss = read("../../styles/theme.css");
|
|
7
|
+
|
|
8
|
+
describe("the code editor theme", () => {
|
|
9
|
+
it("takes every colour from a token theme.css declares", () => {
|
|
10
|
+
const used = [...source.matchAll(/var\((--color-[\w-]+)\)/g)].map((match) => match[1]!);
|
|
11
|
+
expect(used.length).toBeGreaterThan(20);
|
|
12
|
+
for (const token of new Set(used)) expect(themeCss, token).toContain(`${token}:`);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("paints no literal colour of its own", () => {
|
|
16
|
+
expect(source).not.toMatch(
|
|
17
|
+
/#[\da-f]{3,8}\b|\b(rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(|"(white|black|red|orange|yellow|green|blue|purple|pink|gray|grey|silver|lime|cyan|magenta)"/i,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
import { tags as t } from "@lezer/highlight";
|
|
4
|
+
|
|
5
|
+
// One Dark's layout on Studio's tokens: every colour is a theme.css variable, so the editor
|
|
6
|
+
// follows the light or dark theme the page paints with no editor reconfiguration.
|
|
7
|
+
const editorTheme = EditorView.theme({
|
|
8
|
+
"&": { height: "100%", color: "var(--color-text-0)", backgroundColor: "var(--color-surface)" },
|
|
9
|
+
".cm-scroller": { overflow: "auto" },
|
|
10
|
+
".cm-content": { caretColor: "var(--color-text-0)" },
|
|
11
|
+
".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--color-text-0)" },
|
|
12
|
+
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
|
13
|
+
{ backgroundColor: "var(--color-on)" },
|
|
14
|
+
".cm-activeLine": { backgroundColor: "var(--color-hover)" },
|
|
15
|
+
".cm-selectionMatch": {
|
|
16
|
+
backgroundColor: "transparent",
|
|
17
|
+
outline: "1px solid var(--color-text-muted)",
|
|
18
|
+
},
|
|
19
|
+
".cm-searchMatch": {
|
|
20
|
+
backgroundColor: "transparent",
|
|
21
|
+
outline: "1px solid var(--color-text-muted)",
|
|
22
|
+
},
|
|
23
|
+
".cm-searchMatch.cm-searchMatch-selected": {
|
|
24
|
+
backgroundColor: "transparent",
|
|
25
|
+
outline: "2px solid var(--color-accent-ink)",
|
|
26
|
+
},
|
|
27
|
+
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
|
|
28
|
+
backgroundColor: "var(--color-on)",
|
|
29
|
+
},
|
|
30
|
+
".cm-gutters": {
|
|
31
|
+
backgroundColor: "var(--color-surface)",
|
|
32
|
+
color: "var(--color-text-muted)",
|
|
33
|
+
border: "none",
|
|
34
|
+
},
|
|
35
|
+
".cm-activeLineGutter": { backgroundColor: "var(--color-hover)", color: "var(--color-text-2)" },
|
|
36
|
+
".cm-foldPlaceholder": {
|
|
37
|
+
backgroundColor: "transparent",
|
|
38
|
+
border: "none",
|
|
39
|
+
color: "var(--color-text-2)",
|
|
40
|
+
},
|
|
41
|
+
".cm-panels": { backgroundColor: "var(--color-raised)", color: "var(--color-text-0)" },
|
|
42
|
+
".cm-panels.cm-panels-top": { borderBottom: "1px solid var(--color-border)" },
|
|
43
|
+
".cm-panels.cm-panels-bottom": { borderTop: "1px solid var(--color-border)" },
|
|
44
|
+
".cm-textfield": {
|
|
45
|
+
backgroundColor: "var(--color-input)",
|
|
46
|
+
color: "var(--color-text-0)",
|
|
47
|
+
border: "1px solid var(--color-border-input)",
|
|
48
|
+
},
|
|
49
|
+
".cm-button": {
|
|
50
|
+
backgroundImage: "none",
|
|
51
|
+
backgroundColor: "var(--color-hover)",
|
|
52
|
+
color: "var(--color-text-0)",
|
|
53
|
+
border: "1px solid var(--color-border)",
|
|
54
|
+
},
|
|
55
|
+
"&.cm-editor .cm-button:active": {
|
|
56
|
+
backgroundImage: "none",
|
|
57
|
+
backgroundColor: "var(--color-press)",
|
|
58
|
+
},
|
|
59
|
+
".cm-specialChar": { color: "var(--color-danger-ink)" },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const highlightStyle = HighlightStyle.define([
|
|
63
|
+
{ tag: t.keyword, color: "var(--color-code-keyword)" },
|
|
64
|
+
{
|
|
65
|
+
tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName],
|
|
66
|
+
color: "var(--color-code-name)",
|
|
67
|
+
},
|
|
68
|
+
{ tag: [t.function(t.variableName), t.labelName], color: "var(--color-code-function)" },
|
|
69
|
+
{ tag: [t.color, t.constant(t.name), t.standard(t.name)], color: "var(--color-code-constant)" },
|
|
70
|
+
{ tag: [t.definition(t.name), t.separator], color: "var(--color-text-0)" },
|
|
71
|
+
{
|
|
72
|
+
tag: [
|
|
73
|
+
t.typeName,
|
|
74
|
+
t.className,
|
|
75
|
+
t.number,
|
|
76
|
+
t.changed,
|
|
77
|
+
t.annotation,
|
|
78
|
+
t.modifier,
|
|
79
|
+
t.self,
|
|
80
|
+
t.namespace,
|
|
81
|
+
],
|
|
82
|
+
color: "var(--color-code-type)",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.special(t.string)],
|
|
86
|
+
color: "var(--color-code-operator)",
|
|
87
|
+
},
|
|
88
|
+
{ tag: [t.meta, t.comment], color: "var(--color-code-comment)" },
|
|
89
|
+
{ tag: t.strong, fontWeight: "bold" },
|
|
90
|
+
{ tag: t.emphasis, fontStyle: "italic" },
|
|
91
|
+
{ tag: t.strikethrough, textDecoration: "line-through" },
|
|
92
|
+
{ tag: t.link, color: "var(--color-code-comment)", textDecoration: "underline" },
|
|
93
|
+
{ tag: t.heading, fontWeight: "bold", color: "var(--color-code-name)" },
|
|
94
|
+
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: "var(--color-code-constant)" },
|
|
95
|
+
{ tag: [t.processingInstruction, t.string, t.inserted], color: "var(--color-code-string)" },
|
|
96
|
+
{ tag: t.invalid, color: "var(--color-danger-ink)" },
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
export const studioCodeTheme = [editorTheme, syntaxHighlighting(highlightStyle)];
|
|
@@ -6,6 +6,7 @@ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelect
|
|
|
6
6
|
import { useDomEditNudge, type UseDomEditNudgeParams } from "./useDomEditNudge";
|
|
7
7
|
import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS, CANVAS_NUDGE_STEP_PX } from "./domEditNudge";
|
|
8
8
|
import { __resetForTests } from "../../utils/canvasNudgeGate";
|
|
9
|
+
import { flushStudioPendingEdits, hasStudioPendingEdits } from "../../utils/studioPendingEdits";
|
|
9
10
|
import type { DomEditSelection } from "./domEditing";
|
|
10
11
|
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
11
12
|
|
|
@@ -27,6 +28,7 @@ const REST_RECT: OverlayRect = {
|
|
|
27
28
|
// Stable across renders on purpose: the test targets the `selection` identity
|
|
28
29
|
// key specifically, so `groupSelections` must not itself be a source of churn.
|
|
29
30
|
const EMPTY_GROUP_SELECTIONS: DomEditSelection[] = [];
|
|
31
|
+
let flushNudge = () => {};
|
|
30
32
|
|
|
31
33
|
function Harness({
|
|
32
34
|
selection,
|
|
@@ -35,7 +37,7 @@ function Harness({
|
|
|
35
37
|
selection: DomEditSelection | null;
|
|
36
38
|
onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
|
|
37
39
|
}) {
|
|
38
|
-
useDomEditNudge({
|
|
40
|
+
flushNudge = useDomEditNudge({
|
|
39
41
|
selection,
|
|
40
42
|
groupSelections: EMPTY_GROUP_SELECTIONS,
|
|
41
43
|
allowCanvasMovement: true,
|
|
@@ -49,7 +51,7 @@ function Harness({
|
|
|
49
51
|
onBlockedMoveRef: makeRef(() => {}),
|
|
50
52
|
onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
|
|
51
53
|
onGroupPathOffsetCommitRef: makeRef(async () => {}),
|
|
52
|
-
});
|
|
54
|
+
}).flushNudge;
|
|
53
55
|
return null;
|
|
54
56
|
}
|
|
55
57
|
|
|
@@ -124,6 +126,37 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
|
|
|
124
126
|
element.remove();
|
|
125
127
|
});
|
|
126
128
|
|
|
129
|
+
it("leaves arrows on a focused slider alone", () => {
|
|
130
|
+
const host = document.createElement("div");
|
|
131
|
+
document.body.append(host);
|
|
132
|
+
const root = createRoot(host);
|
|
133
|
+
const element = document.createElement("div");
|
|
134
|
+
element.id = "dot-a";
|
|
135
|
+
const slider = document.createElement("div");
|
|
136
|
+
slider.setAttribute("role", "slider");
|
|
137
|
+
document.body.append(element, slider);
|
|
138
|
+
const commit = vi.fn();
|
|
139
|
+
act(() => {
|
|
140
|
+
root.render(
|
|
141
|
+
React.createElement(Harness, {
|
|
142
|
+
selection: makeSelection("Dot", element),
|
|
143
|
+
onPathOffsetCommit: commit,
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
act(() => {
|
|
148
|
+
slider.dispatchEvent(
|
|
149
|
+
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }),
|
|
150
|
+
);
|
|
151
|
+
vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
|
|
152
|
+
});
|
|
153
|
+
expect(commit).not.toHaveBeenCalled();
|
|
154
|
+
act(() => root.unmount());
|
|
155
|
+
host.remove();
|
|
156
|
+
element.remove();
|
|
157
|
+
slider.remove();
|
|
158
|
+
});
|
|
159
|
+
|
|
127
160
|
it("still flushes the burst when the selection actually changes to a different element", () => {
|
|
128
161
|
const host = document.createElement("div");
|
|
129
162
|
document.body.append(host);
|
|
@@ -228,6 +261,51 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
|
|
|
228
261
|
});
|
|
229
262
|
});
|
|
230
263
|
|
|
264
|
+
describe("useDomEditNudge — a focused native player owns the arrow keys", () => {
|
|
265
|
+
beforeEach(() => {
|
|
266
|
+
vi.useFakeTimers();
|
|
267
|
+
__resetForTests();
|
|
268
|
+
});
|
|
269
|
+
let root: ReturnType<typeof createRoot> | null = null;
|
|
270
|
+
afterEach(() => {
|
|
271
|
+
act(() => root?.unmount());
|
|
272
|
+
root = null;
|
|
273
|
+
document.body.innerHTML = "";
|
|
274
|
+
vi.useRealTimers();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it.each(["video", "audio"])(
|
|
278
|
+
"does not nudge the selection while a <%s controls> has focus",
|
|
279
|
+
(tag) => {
|
|
280
|
+
root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
281
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
282
|
+
element.id = "dot";
|
|
283
|
+
const player = document.body.appendChild(document.createElement(tag));
|
|
284
|
+
player.setAttribute("controls", "");
|
|
285
|
+
const commit = vi.fn();
|
|
286
|
+
act(() => {
|
|
287
|
+
root?.render(
|
|
288
|
+
React.createElement(Harness, {
|
|
289
|
+
selection: makeSelection("Dot", element),
|
|
290
|
+
onPathOffsetCommit: commit,
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
const event = new KeyboardEvent("keydown", {
|
|
295
|
+
key: "ArrowRight",
|
|
296
|
+
bubbles: true,
|
|
297
|
+
cancelable: true,
|
|
298
|
+
});
|
|
299
|
+
act(() => {
|
|
300
|
+
player.dispatchEvent(event);
|
|
301
|
+
vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
|
|
302
|
+
});
|
|
303
|
+
expect(event.defaultPrevented).toBe(false);
|
|
304
|
+
expect(commit).not.toHaveBeenCalled();
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
231
309
|
describe("useDomEditNudge carries the route its press chose", () => {
|
|
232
310
|
beforeEach(() => {
|
|
233
311
|
vi.useFakeTimers();
|
|
@@ -260,3 +338,88 @@ describe("useDomEditNudge carries the route its press chose", () => {
|
|
|
260
338
|
element.remove();
|
|
261
339
|
});
|
|
262
340
|
});
|
|
341
|
+
|
|
342
|
+
describe("useDomEditNudge — undo right after a burst", () => {
|
|
343
|
+
it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
|
|
344
|
+
__resetForTests();
|
|
345
|
+
vi.useFakeTimers();
|
|
346
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
347
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
348
|
+
element.id = "dot-undo";
|
|
349
|
+
let saved!: () => void;
|
|
350
|
+
const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
|
|
351
|
+
act(() => {
|
|
352
|
+
root.render(
|
|
353
|
+
React.createElement(Harness, {
|
|
354
|
+
selection: makeSelection("Dot", element),
|
|
355
|
+
onPathOffsetCommit: commit,
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
act(() => dispatchArrowRight());
|
|
360
|
+
|
|
361
|
+
let drained = false;
|
|
362
|
+
const drain = flushStudioPendingEdits().then(() => (drained = true));
|
|
363
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
364
|
+
vi.useRealTimers();
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
366
|
+
expect(drained).toBe(false);
|
|
367
|
+
saved();
|
|
368
|
+
await drain;
|
|
369
|
+
expect(drained).toBe(true);
|
|
370
|
+
act(() => root.unmount());
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("counts the burst as a pending edit from its first key until its save lands", async () => {
|
|
374
|
+
__resetForTests();
|
|
375
|
+
vi.useFakeTimers();
|
|
376
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
377
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
378
|
+
element.id = "dot-pending";
|
|
379
|
+
let saved!: () => void;
|
|
380
|
+
const commit = vi.fn(() => new Promise<void>((resolve) => (saved = resolve)));
|
|
381
|
+
act(() => {
|
|
382
|
+
root.render(
|
|
383
|
+
React.createElement(Harness, {
|
|
384
|
+
selection: makeSelection("Dot", element),
|
|
385
|
+
onPathOffsetCommit: commit,
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
390
|
+
act(() => dispatchArrowRight());
|
|
391
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
392
|
+
act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
|
|
393
|
+
expect(commit).toHaveBeenCalledTimes(1);
|
|
394
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
395
|
+
vi.useRealTimers();
|
|
396
|
+
saved();
|
|
397
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
398
|
+
act(() => root.unmount());
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe("useDomEditNudge — a commit that throws", () => {
|
|
403
|
+
it("still ends the burst's pending edit, so undo and export never wait on it", async () => {
|
|
404
|
+
__resetForTests();
|
|
405
|
+
const root = createRoot(document.body.appendChild(document.createElement("div")));
|
|
406
|
+
const element = document.body.appendChild(document.createElement("div"));
|
|
407
|
+
element.id = "dot-throws";
|
|
408
|
+
const commit = vi.fn(() => {
|
|
409
|
+
throw new Error("The commit threw.");
|
|
410
|
+
});
|
|
411
|
+
act(() => {
|
|
412
|
+
root.render(
|
|
413
|
+
React.createElement(Harness, {
|
|
414
|
+
selection: makeSelection("Dot", element),
|
|
415
|
+
onPathOffsetCommit: commit,
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
});
|
|
419
|
+
act(() => dispatchArrowRight());
|
|
420
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
421
|
+
expect(() => flushNudge()).toThrow("The commit threw.");
|
|
422
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
423
|
+
act(() => root.unmount());
|
|
424
|
+
});
|
|
425
|
+
});
|