@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
|
@@ -24,6 +24,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
24
24
|
import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
|
|
25
25
|
import type { RecordEditInput } from "./domEditDeleteMembers";
|
|
26
26
|
import type { DomEditTimelineParams } from "./useDomSelectionTypes";
|
|
27
|
+
import { useLivePreviewIframe } from "./useLivePreviewIframe";
|
|
27
28
|
// Re-exported: the delete rule lives in its own module; callers and its test import it from here.
|
|
28
29
|
export { membersForDelete };
|
|
29
30
|
|
|
@@ -86,14 +87,13 @@ export function useDomEditSession({
|
|
|
86
87
|
importedFontAssetsRef,
|
|
87
88
|
projectDir,
|
|
88
89
|
projectIdRef,
|
|
89
|
-
previewIframe,
|
|
90
|
+
previewIframe: hostPreviewIframe,
|
|
90
91
|
refreshKey,
|
|
91
92
|
previewDocumentVersion,
|
|
92
93
|
rightPanelTab,
|
|
93
94
|
applyStudioManualEditsToPreviewRef,
|
|
94
95
|
syncPreviewHotkeys,
|
|
95
96
|
reloadPreview,
|
|
96
|
-
setRefreshKey: _setRefreshKey,
|
|
97
97
|
openSourceForSelection,
|
|
98
98
|
sdkSession,
|
|
99
99
|
publishSdkSession,
|
|
@@ -103,7 +103,7 @@ export function useDomEditSession({
|
|
|
103
103
|
}: UseDomEditSessionParams) {
|
|
104
104
|
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
105
105
|
const previewCaptionEditMode = captionEditMode && !readOnlyPreview;
|
|
106
|
-
|
|
106
|
+
const previewIframe = useLivePreviewIframe(hostPreviewIframe);
|
|
107
107
|
const {
|
|
108
108
|
domEditSelection,
|
|
109
109
|
domEditGroupSelections,
|
|
@@ -170,32 +170,13 @@ export function useDomEditSession({
|
|
|
170
170
|
refreshDomEditSelectionFromPreview,
|
|
171
171
|
});
|
|
172
172
|
// ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
|
|
173
|
-
|
|
174
173
|
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
|
|
175
174
|
|
|
176
|
-
// ── GSAP script commits ──
|
|
177
|
-
|
|
178
175
|
const {
|
|
179
176
|
commitMutation: gsapCommitMutation,
|
|
180
|
-
updateGsapProperty,
|
|
181
|
-
updateGsapMeta,
|
|
182
|
-
deleteGsapAnimation,
|
|
183
|
-
deleteAllForSelector,
|
|
184
|
-
addGsapAnimation,
|
|
185
|
-
addGsapProperty,
|
|
186
|
-
removeGsapProperty,
|
|
187
|
-
updateGsapFromProperty,
|
|
188
|
-
addGsapFromProperty,
|
|
189
|
-
removeGsapFromProperty,
|
|
190
|
-
addKeyframe,
|
|
191
|
-
addKeyframeBatch,
|
|
192
|
-
removeKeyframe,
|
|
193
|
-
moveKeyframe,
|
|
194
|
-
resizeKeyframedTween,
|
|
195
|
-
convertToKeyframes,
|
|
196
|
-
removeAllKeyframes,
|
|
197
177
|
setArcPath,
|
|
198
178
|
updateArcSegment,
|
|
179
|
+
...gsapScriptEdits
|
|
199
180
|
} = useGsapScriptCommits({
|
|
200
181
|
projectIdRef,
|
|
201
182
|
activeCompPath,
|
|
@@ -211,8 +192,6 @@ export function useDomEditSession({
|
|
|
211
192
|
forceReloadSdkSession,
|
|
212
193
|
});
|
|
213
194
|
|
|
214
|
-
// ── DOM commit handlers ──
|
|
215
|
-
|
|
216
195
|
const {
|
|
217
196
|
resolveImportedFontAsset,
|
|
218
197
|
handleDomStyleCommit,
|
|
@@ -222,6 +201,7 @@ export function useDomEditSession({
|
|
|
222
201
|
handleDomAttributeQuietCommit,
|
|
223
202
|
handleDomHtmlAttributeCommit,
|
|
224
203
|
handleDomAttributesCommit,
|
|
204
|
+
handleDomAttributeBatchCommit,
|
|
225
205
|
handleDomTextCommit,
|
|
226
206
|
handleDomTextCommitForSelection,
|
|
227
207
|
handleDomRichTextCommit,
|
|
@@ -308,7 +288,6 @@ export function useDomEditSession({
|
|
|
308
288
|
});
|
|
309
289
|
|
|
310
290
|
// ── Element groups (wrap selected elements in a data-hf-group div) ──
|
|
311
|
-
|
|
312
291
|
const { groupSelection, ungroupSelection } = useGroupCommits({
|
|
313
292
|
activeCompPath,
|
|
314
293
|
showToast,
|
|
@@ -383,7 +362,6 @@ export function useDomEditSession({
|
|
|
383
362
|
}, [domEditSelectionRef, ungroupSelection, setActiveGroupElement, showToast]);
|
|
384
363
|
|
|
385
364
|
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
|
386
|
-
|
|
387
365
|
const {
|
|
388
366
|
onClickToSource,
|
|
389
367
|
selectedGsapAnimations,
|
|
@@ -430,23 +408,7 @@ export function useDomEditSession({
|
|
|
430
408
|
applyDomSelection,
|
|
431
409
|
buildDomSelectionFromTarget,
|
|
432
410
|
openSourceForSelection,
|
|
433
|
-
|
|
434
|
-
updateGsapMeta,
|
|
435
|
-
deleteGsapAnimation,
|
|
436
|
-
deleteAllForSelector,
|
|
437
|
-
addGsapAnimation,
|
|
438
|
-
addGsapProperty,
|
|
439
|
-
removeGsapProperty,
|
|
440
|
-
updateGsapFromProperty,
|
|
441
|
-
addGsapFromProperty,
|
|
442
|
-
removeGsapFromProperty,
|
|
443
|
-
addKeyframe,
|
|
444
|
-
addKeyframeBatch,
|
|
445
|
-
removeKeyframe,
|
|
446
|
-
moveKeyframe,
|
|
447
|
-
resizeKeyframedTween,
|
|
448
|
-
convertToKeyframes,
|
|
449
|
-
removeAllKeyframes,
|
|
411
|
+
...gsapScriptEdits,
|
|
450
412
|
handleDomManualEditsReset,
|
|
451
413
|
});
|
|
452
414
|
const {
|
|
@@ -492,8 +454,8 @@ export function useDomEditSession({
|
|
|
492
454
|
handleDomBoxSizeCommit,
|
|
493
455
|
handleDomRotationCommit: handleDomCssRotationCommit,
|
|
494
456
|
commitPositionPatchToHtml,
|
|
495
|
-
addGsapAnimation,
|
|
496
|
-
convertToKeyframes,
|
|
457
|
+
addGsapAnimation: gsapScriptEdits.addGsapAnimation,
|
|
458
|
+
convertToKeyframes: gsapScriptEdits.convertToKeyframes,
|
|
497
459
|
setArcPath,
|
|
498
460
|
updateArcSegment,
|
|
499
461
|
});
|
|
@@ -532,6 +494,7 @@ export function useDomEditSession({
|
|
|
532
494
|
handleDomAttributeQuietCommit,
|
|
533
495
|
handleDomHtmlAttributeCommit,
|
|
534
496
|
handleDomAttributesCommit,
|
|
497
|
+
handleDomAttributeBatchCommit,
|
|
535
498
|
handleDomPathOffsetCommit: handleGsapAwarePathOffsetCommit,
|
|
536
499
|
handleDomGroupPathOffsetCommit: handleGsapAwareGroupPathOffsetCommit,
|
|
537
500
|
handleDomZIndexReorderCommit,
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
2
|
import { normalizeDomEditStyleValue } from "../utils/studioHelpers";
|
|
3
|
-
import {
|
|
4
|
-
injectPreviewGoogleFont,
|
|
5
|
-
injectPreviewImportedFont,
|
|
6
|
-
ensureImportedFontFace,
|
|
7
|
-
} from "../utils/studioFontHelpers";
|
|
3
|
+
import { injectPreviewGoogleFont, injectPreviewImportedFont } from "../utils/studioFontHelpers";
|
|
8
4
|
import {
|
|
9
5
|
buildDomEditRichTextPatchOperation,
|
|
10
6
|
findElementForSelection,
|
|
@@ -99,6 +95,7 @@ export function useDomEditTextCommits({
|
|
|
99
95
|
handleDomAttributeQuietCommit,
|
|
100
96
|
handleDomHtmlAttributeCommit,
|
|
101
97
|
handleDomAttributesCommit,
|
|
98
|
+
handleDomAttributeBatchCommit,
|
|
102
99
|
} = useDomEditAttributeCommits({
|
|
103
100
|
activeCompPath,
|
|
104
101
|
previewIframeRef,
|
|
@@ -354,9 +351,7 @@ export function useDomEditTextCommits({
|
|
|
354
351
|
await persistDomEditOperations(selection, textCommit.operations, {
|
|
355
352
|
label: "Edit text",
|
|
356
353
|
skipRefresh: true,
|
|
357
|
-
|
|
358
|
-
? (html, sourceFile) => ensureImportedFontFace(html, importedFont, sourceFile)
|
|
359
|
-
: undefined,
|
|
354
|
+
importedFont: importedFont ?? undefined,
|
|
360
355
|
});
|
|
361
356
|
},
|
|
362
357
|
shouldRevert: () => isLatestTextCommit(),
|
|
@@ -485,6 +480,7 @@ export function useDomEditTextCommits({
|
|
|
485
480
|
handleDomAttributeQuietCommit,
|
|
486
481
|
handleDomHtmlAttributeCommit,
|
|
487
482
|
handleDomAttributesCommit,
|
|
483
|
+
handleDomAttributeBatchCommit,
|
|
488
484
|
handleDomTextCommit,
|
|
489
485
|
handleDomTextCommitForSelection,
|
|
490
486
|
handleDomRichTextCommit,
|
|
@@ -32,7 +32,13 @@ export interface UseDomGeometryCommitsParams {
|
|
|
32
32
|
commitPositionPatchToHtml: (
|
|
33
33
|
selection: DomEditSelection,
|
|
34
34
|
patches: PatchOperation[],
|
|
35
|
-
options: {
|
|
35
|
+
options: {
|
|
36
|
+
label: string;
|
|
37
|
+
coalesceKey: string;
|
|
38
|
+
coalesceMs?: number;
|
|
39
|
+
skipRefresh?: boolean;
|
|
40
|
+
deferRender?: boolean;
|
|
41
|
+
},
|
|
36
42
|
) => Promise<void>;
|
|
37
43
|
readOnlyPreview: boolean;
|
|
38
44
|
}
|
|
@@ -65,6 +71,7 @@ export function useDomGeometryCommits({
|
|
|
65
71
|
next: { width: number; height: number },
|
|
66
72
|
offset?: { x: number; y: number },
|
|
67
73
|
restore?: () => void,
|
|
74
|
+
undoKey?: string,
|
|
68
75
|
) => {
|
|
69
76
|
if (readOnlyPreview) return Promise.resolve();
|
|
70
77
|
const element = selection.element;
|
|
@@ -80,7 +87,9 @@ export function useDomGeometryCommits({
|
|
|
80
87
|
if (offset) patches.push(...writePlainMove(element, offset));
|
|
81
88
|
return commitPositionPatchToHtml(selection, patches, {
|
|
82
89
|
label: "Resize layer box",
|
|
83
|
-
|
|
90
|
+
...(undoKey
|
|
91
|
+
? { coalesceKey: undoKey, coalesceMs: Number.POSITIVE_INFINITY, deferRender: true }
|
|
92
|
+
: { coalesceKey: `box-size:${getDomEditTargetKey(selection)}` }),
|
|
84
93
|
}).catch((error) => {
|
|
85
94
|
restoreStudioBoxSize(element, beforeSize);
|
|
86
95
|
if (offset) restoreStudioPathOffset(element, beforeOffset);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { act, createElement } from "react";
|
|
2
|
+
import { act, createElement, type ReactElement } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
@@ -10,6 +10,11 @@ import {
|
|
|
10
10
|
openProjectHistory,
|
|
11
11
|
type StudioApiAdapter,
|
|
12
12
|
} from "@hyperframes/studio-server";
|
|
13
|
+
import { useDomEditNudge } from "../components/editor/useDomEditNudge";
|
|
14
|
+
import { DomEditProvider, useDomEditActionsContext } from "../contexts/DomEditContext";
|
|
15
|
+
import { __resetForTests as resetNudgeKeys } from "../utils/canvasNudgeGate";
|
|
16
|
+
import { trackedStudioEdit } from "../utils/studioPendingEdits";
|
|
17
|
+
import { makeSelection } from "./domSelectionTestHarness";
|
|
13
18
|
import { useEditHistoryActions } from "./useEditHistoryActions";
|
|
14
19
|
import { usePersistentEditHistory } from "./usePersistentEditHistory";
|
|
15
20
|
import { usePreviewPersistence } from "./usePreviewPersistence";
|
|
@@ -19,6 +24,7 @@ const page = (left: string, top = "0px") =>
|
|
|
19
24
|
const BEFORE = page("10px");
|
|
20
25
|
const AFTER = page("50px");
|
|
21
26
|
const OUTSIDE = page("50px", "99px");
|
|
27
|
+
const NUDGED = page("51px");
|
|
22
28
|
|
|
23
29
|
const cleanup: Array<() => unknown> = [];
|
|
24
30
|
let scratch = "";
|
|
@@ -78,14 +84,15 @@ async function studio() {
|
|
|
78
84
|
showToast: () => {},
|
|
79
85
|
syncHistoryPreviewAfterApply: persistence.syncHistoryPreviewAfterApply,
|
|
80
86
|
showHistoryRestoreNow: persistence.showHistoryRestoreNow,
|
|
81
|
-
waitForPendingDomEditSaves: persistence.
|
|
87
|
+
waitForPendingDomEditSaves: persistence.settlePendingEdits,
|
|
82
88
|
});
|
|
83
89
|
return null;
|
|
84
90
|
}
|
|
85
91
|
const root = createRoot(document.createElement("div"));
|
|
86
92
|
await act(async () => root.render(createElement(Harness)));
|
|
87
93
|
cleanup.push(() => act(() => root.unmount()));
|
|
88
|
-
const
|
|
94
|
+
const element = (id: string) => iframe.contentDocument!.getElementById(id)!;
|
|
95
|
+
const box = () => element("box").style.left;
|
|
89
96
|
const other = () => iframe.contentDocument!.getElementById("other")!.style.top;
|
|
90
97
|
const show = (html: string) => {
|
|
91
98
|
iframe.contentDocument!.documentElement.innerHTML = new DOMParser().parseFromString(
|
|
@@ -105,8 +112,16 @@ async function studio() {
|
|
|
105
112
|
);
|
|
106
113
|
await vi.waitFor(() => expect(history.undoLabel).toBe("Move layer"));
|
|
107
114
|
};
|
|
115
|
+
/** Mounts `ui` beside Studio, as the canvas is, for the rest of the test. */
|
|
116
|
+
const mount = (ui: ReactElement) => {
|
|
117
|
+
const canvas = createRoot(document.createElement("div"));
|
|
118
|
+
act(() => canvas.render(ui));
|
|
119
|
+
cleanup.push(() => act(() => canvas.unmount()));
|
|
120
|
+
};
|
|
108
121
|
return {
|
|
109
122
|
history: () => history,
|
|
123
|
+
element,
|
|
124
|
+
mount,
|
|
110
125
|
persistence: () => persistence,
|
|
111
126
|
actions: () => actions,
|
|
112
127
|
box,
|
|
@@ -164,3 +179,107 @@ it("an undo after an outside write ends with the preview showing the file the se
|
|
|
164
179
|
expect(s.other()).toBe("0px");
|
|
165
180
|
expect(s.box()).toBe("50px");
|
|
166
181
|
});
|
|
182
|
+
|
|
183
|
+
/** The canvas's arrow-key nudge on the box; `save` stands in for the burst's save once the keys stop. */
|
|
184
|
+
function Nudge({ target, save }: { target: HTMLElement; save: () => Promise<void> }) {
|
|
185
|
+
const selection = makeSelection("Box", target);
|
|
186
|
+
const ref = <T,>(current: T) => ({ current });
|
|
187
|
+
useDomEditNudge({
|
|
188
|
+
selection,
|
|
189
|
+
groupSelections: [],
|
|
190
|
+
allowCanvasMovement: true,
|
|
191
|
+
selectionRef: ref(selection),
|
|
192
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 100, height: 40, editScaleX: 1, editScaleY: 1 }),
|
|
193
|
+
groupOverlayItemsRef: ref([]),
|
|
194
|
+
gestureRef: ref(null),
|
|
195
|
+
groupGestureRef: ref(null),
|
|
196
|
+
blockedMoveRef: ref(null),
|
|
197
|
+
onManualDragStartRef: ref(() => {}),
|
|
198
|
+
onBlockedMoveRef: ref(() => {}),
|
|
199
|
+
onPathOffsetCommitRef: ref(save),
|
|
200
|
+
onGroupPathOffsetCommitRef: ref(async () => {}),
|
|
201
|
+
});
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
it("an undo pressed while a nudge waits for more keys never shows the move before it undone", async () => {
|
|
206
|
+
const s = await studio();
|
|
207
|
+
await s.edit();
|
|
208
|
+
resetNudgeKeys();
|
|
209
|
+
const save = vi.fn(async () => {
|
|
210
|
+
writeFileSync(s.path, NUDGED);
|
|
211
|
+
await s.history().recordEdit({
|
|
212
|
+
label: "Move layer",
|
|
213
|
+
files: { "index.html": { before: AFTER, after: NUDGED } },
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
s.mount(createElement(Nudge, { target: s.element("box"), save }));
|
|
217
|
+
act(() => {
|
|
218
|
+
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", cancelable: true }));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const undone = s.actions().undo();
|
|
222
|
+
expect(s.box()).toBe("50px");
|
|
223
|
+
await act(() => undone);
|
|
224
|
+
expect(save).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(s.file()).toBe(AFTER);
|
|
226
|
+
expect(s.box()).toBe("50px");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("an undo pressed while a queued save fails undoes the edit before it, file and box alike", async () => {
|
|
230
|
+
const s = await studio();
|
|
231
|
+
await s.edit();
|
|
232
|
+
const box = s.element("box");
|
|
233
|
+
let fail!: () => void;
|
|
234
|
+
const handleDomStyleCommit = vi.fn(async () => {
|
|
235
|
+
box.style.left = "70px";
|
|
236
|
+
try {
|
|
237
|
+
await s.persistence().queueDomEditSave(async () => {
|
|
238
|
+
await new Promise<void>((resolve) => (fail = resolve));
|
|
239
|
+
throw new Error("The save failed.");
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
box.style.left = "50px";
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
let actions!: ReturnType<typeof useDomEditActionsContext>;
|
|
247
|
+
function Canvas() {
|
|
248
|
+
actions = useDomEditActionsContext();
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const value = { handleDomStyleCommit } as unknown as Parameters<
|
|
252
|
+
typeof DomEditProvider
|
|
253
|
+
>[0]["value"];
|
|
254
|
+
s.mount(
|
|
255
|
+
<DomEditProvider value={value}>
|
|
256
|
+
<Canvas />
|
|
257
|
+
</DomEditProvider>,
|
|
258
|
+
);
|
|
259
|
+
const failed = actions.handleDomStyleCommit("left", "70px");
|
|
260
|
+
|
|
261
|
+
const undone = s.actions().undo();
|
|
262
|
+
await vi.waitFor(() => expect(fail).toBeTypeOf("function"));
|
|
263
|
+
fail();
|
|
264
|
+
await expect(failed).rejects.toThrow("The save failed.");
|
|
265
|
+
await act(() => undone);
|
|
266
|
+
expect(s.file()).toBe(BEFORE);
|
|
267
|
+
expect(s.box()).toBe("10px");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("an undo pressed while a tracked timeline edit fails still undoes the edit before it", async () => {
|
|
271
|
+
const s = await studio();
|
|
272
|
+
await s.edit();
|
|
273
|
+
let fail!: () => void;
|
|
274
|
+
const timelineEdit = trackedStudioEdit(
|
|
275
|
+
() => new Promise<void>((_, reject) => (fail = () => reject(new Error("The save failed.")))),
|
|
276
|
+
);
|
|
277
|
+
const failed = timelineEdit();
|
|
278
|
+
|
|
279
|
+
const undone = s.actions().undo();
|
|
280
|
+
fail();
|
|
281
|
+
await expect(failed).rejects.toThrow("The save failed.");
|
|
282
|
+
await act(() => undone);
|
|
283
|
+
expect(s.file()).toBe(BEFORE);
|
|
284
|
+
expect(s.box()).toBe("10px");
|
|
285
|
+
});
|
|
@@ -93,6 +93,7 @@ export interface UseGsapAwareEditingParams {
|
|
|
93
93
|
next: { width: number; height: number },
|
|
94
94
|
offset?: { x: number; y: number },
|
|
95
95
|
restore?: () => void,
|
|
96
|
+
undoKey?: string,
|
|
96
97
|
) => Promise<void>;
|
|
97
98
|
commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"];
|
|
98
99
|
// GSAP script commit ops (from useGsapScriptCommits)
|
|
@@ -394,12 +395,11 @@ export function useGsapAwareEditing({
|
|
|
394
395
|
makeFetchFallback(selection),
|
|
395
396
|
);
|
|
396
397
|
assertGsapEditPersisted(outcome);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
// element snapped back to its authored position on every drag.
|
|
398
|
+
// Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
|
|
399
|
+
if (outcome.status === "element-size")
|
|
400
|
+
await handleDomBoxSizeCommit(selection, next, undefined, undefined, coalesceKey);
|
|
401
|
+
else cropUndoKey = coalesceKey;
|
|
402
|
+
// What the resize did, not what its tweens suggest: a scale hold still commits a size.
|
|
403
403
|
const ownsDragOffset =
|
|
404
404
|
outcome.status === "persisted" && outcome.ownsDragOffset === true;
|
|
405
405
|
logResize("intercept-handled", {
|
|
@@ -30,6 +30,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
30
30
|
import type { MutationResult } from "./gsapScriptCommitTypes";
|
|
31
31
|
import { persistSdkSerialize } from "../utils/sdkCutover";
|
|
32
32
|
import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
33
|
+
import { hasStudioPendingEdits } from "../utils/studioPendingEdits";
|
|
33
34
|
|
|
34
35
|
// ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
|
|
35
36
|
|
|
@@ -426,6 +427,24 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
|
|
|
426
427
|
);
|
|
427
428
|
}
|
|
428
429
|
|
|
430
|
+
describe("a GSAP script commit", () => {
|
|
431
|
+
it("counts as a pending edit from its call until it lands, so a quick Cmd+Z waits for it", async () => {
|
|
432
|
+
mockFetchResult();
|
|
433
|
+
const deps = renderCommitHook();
|
|
434
|
+
let committed!: Promise<unknown>;
|
|
435
|
+
act(() => {
|
|
436
|
+
committed = deps.api.commitMutation(
|
|
437
|
+
selection,
|
|
438
|
+
{ type: "remove-all-keyframes", animationId: "a" },
|
|
439
|
+
{ label: "Remove all keyframes" },
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
expect(hasStudioPendingEdits()).toBe(true);
|
|
443
|
+
await act(async () => void (await committed));
|
|
444
|
+
expect(hasStudioPendingEdits()).toBe(false);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
429
448
|
describe("runCommit — instantPatch wiring", () => {
|
|
430
449
|
it("explains a deliberate mutation that the server safely rejected as unchanged", async () => {
|
|
431
450
|
mockFetchResult({ changed: false });
|
|
@@ -7,6 +7,7 @@ import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload"
|
|
|
7
7
|
import type { SoftReloadResult } from "../utils/gsapSoftReload";
|
|
8
8
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
9
9
|
import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
|
|
10
|
+
import { trackedStudioEdit } from "../utils/studioPendingEdits";
|
|
10
11
|
import {
|
|
11
12
|
getStudioSaveErrorMessage,
|
|
12
13
|
isStudioSaveErrorAlreadyToasted,
|
|
@@ -416,20 +417,20 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
416
417
|
}
|
|
417
418
|
return run();
|
|
418
419
|
};
|
|
419
|
-
const commit: CommitMutation = (selection, mutation, options) => {
|
|
420
|
+
const commit: CommitMutation = trackedStudioEdit((selection, mutation, options) => {
|
|
420
421
|
if (!activeProjectId) return Promise.resolve();
|
|
421
422
|
const file = selection.sourceFile || activeCompPath || "index.html";
|
|
422
423
|
return serializeCommit(file, options.serializeKey, () =>
|
|
423
424
|
runCommit(activeProjectId, activeCompPath, file, selection, mutation, options),
|
|
424
425
|
);
|
|
425
|
-
};
|
|
426
|
-
commit.batch = (calls, options) => {
|
|
426
|
+
}) as CommitMutation;
|
|
427
|
+
commit.batch = trackedStudioEdit((calls: CommitMutationCall[], options: CommitMutationOptions) => {
|
|
427
428
|
if (!activeProjectId) return Promise.resolve();
|
|
428
429
|
const file = calls[0]?.selection.sourceFile || activeCompPath || "index.html";
|
|
429
430
|
return serializeCommit(file, options.serializeKey, () =>
|
|
430
431
|
runBatchCommit(activeProjectId, activeCompPath, file, calls, options),
|
|
431
432
|
);
|
|
432
|
-
};
|
|
433
|
+
});
|
|
433
434
|
return commit;
|
|
434
435
|
}, [runCommit, runBatchCommit, activeCompPath, activeProjectId, writeProjectFile]);
|
|
435
436
|
const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
|
|
@@ -336,8 +336,7 @@ export function usePopulateKeyframeCacheForFile(
|
|
|
336
336
|
// fallow-ignore-next-line complexity
|
|
337
337
|
const tryRuntimeScan = () => {
|
|
338
338
|
if (runtimeScanDoneRef.current === `kf-cache:${projectId}:${sf}:${version}`) return true;
|
|
339
|
-
const iframe =
|
|
340
|
-
iframeRef?.current ?? document.querySelector<HTMLIFrameElement>("iframe[src*='/preview/']");
|
|
339
|
+
const iframe = iframeRef?.current;
|
|
341
340
|
if (!iframe) return false;
|
|
342
341
|
// Clip dims per element so the scan converts tween-relative keyframes to
|
|
343
342
|
// clip-relative (matching the static path) instead of timeline-relative.
|
|
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { PreviewReadOnlyProvider } from "../components/editor/previewReadOnlyContext";
|
|
7
7
|
import { useInlineTextEdit, type InlineTextEditControls } from "./useInlineTextEdit";
|
|
8
|
+
import { applyUndoRestoreToPreview } from "../utils/gsapUndoRestore";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
@@ -643,3 +644,29 @@ describe("useInlineTextEdit with styled runs", () => {
|
|
|
643
644
|
act(() => root.unmount());
|
|
644
645
|
});
|
|
645
646
|
});
|
|
647
|
+
|
|
648
|
+
describe("an undo that lands while a text edit is open", () => {
|
|
649
|
+
it("marks the edit as drawing nothing, so the undo reverts the move and keeps the edit", () => {
|
|
650
|
+
const wrap = (body: string) => `<html><body>${body}</body></html>`;
|
|
651
|
+
const moved = `<h1 id="t" style="translate: 90px 60px">Title</h1>`;
|
|
652
|
+
document.body.innerHTML = moved;
|
|
653
|
+
const element = document.getElementById("t")!;
|
|
654
|
+
const { controls, root } = mount();
|
|
655
|
+
act(() => {
|
|
656
|
+
controls().start(element);
|
|
657
|
+
});
|
|
658
|
+
const contentWindow = { __player: { seek: vi.fn() }, __hfStudioManualEditsApply: vi.fn() };
|
|
659
|
+
const iframe = { contentWindow, contentDocument: document } as unknown as HTMLIFrameElement;
|
|
660
|
+
const files = {
|
|
661
|
+
"index.html": { previous: wrap(moved), restored: wrap(`<h1 id="t">Title</h1>`) },
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
expect(element.getAttribute("data-hf-studio-manual-edit-gesture")).toMatch(/:edit$/);
|
|
665
|
+
expect(applyUndoRestoreToPreview(iframe, "index.html", files, 0, vi.fn())).toBe("soft");
|
|
666
|
+
|
|
667
|
+
expect(element.style.getPropertyValue("translate")).toBe("");
|
|
668
|
+
expect(element.getAttribute("contenteditable")).toBe("true");
|
|
669
|
+
expect(element.style.getPropertyValue("outline")).not.toBe("");
|
|
670
|
+
act(() => root.unmount());
|
|
671
|
+
});
|
|
672
|
+
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { sanitizeRichTextChildren } from "@hyperframes/core/rich-text-sanitize";
|
|
3
3
|
import { usePreviewReadOnly } from "../components/editor/previewReadOnlyContext";
|
|
4
|
+
import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
|
|
5
|
+
import {
|
|
6
|
+
beginStudioManualEditGesture,
|
|
7
|
+
endStudioManualEditGesture,
|
|
8
|
+
countStudioManualEditSave,
|
|
9
|
+
} from "../components/editor/manualEditsDom";
|
|
4
10
|
|
|
5
11
|
/**
|
|
6
12
|
* Editing an element's text where it sits, in the composition itself.
|
|
@@ -43,6 +49,8 @@ export interface InlineTextEditSession {
|
|
|
43
49
|
outline: string;
|
|
44
50
|
/** The element's own outline offset, restored with the outline. */
|
|
45
51
|
outlineOffset: string;
|
|
52
|
+
/** Holds the preview on screen while the edit is open. */
|
|
53
|
+
gesture?: string;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
/** The live markup to persist and the session snapshot to restore on failure. */
|
|
@@ -86,7 +94,7 @@ export function useInlineTextEdit({
|
|
|
86
94
|
onPause,
|
|
87
95
|
}: {
|
|
88
96
|
/** Where the edited text goes. The caller owns persistence. */
|
|
89
|
-
onCommit: (commit: InlineTextEditCommit) =>
|
|
97
|
+
onCommit: (commit: InlineTextEditCommit) => unknown;
|
|
90
98
|
/** Stop playback, so the element is not animating under the caret. */
|
|
91
99
|
onPause?: () => void;
|
|
92
100
|
}): InlineTextEditControls {
|
|
@@ -106,6 +114,7 @@ export function useInlineTextEdit({
|
|
|
106
114
|
framesRef.current = null;
|
|
107
115
|
}
|
|
108
116
|
openRef.current = null;
|
|
117
|
+
endStudioManualEditGesture(open.element, open.gesture);
|
|
109
118
|
setSession(null);
|
|
110
119
|
// An element removed from the document mid-session is not an error, it is
|
|
111
120
|
// just nothing left to clean up.
|
|
@@ -132,6 +141,7 @@ export function useInlineTextEdit({
|
|
|
132
141
|
original: element.innerHTML,
|
|
133
142
|
outline: element.style.outline,
|
|
134
143
|
outlineOffset: element.style.outlineOffset,
|
|
144
|
+
gesture: beginStudioManualEditGesture(element, "edit"),
|
|
135
145
|
};
|
|
136
146
|
// Drawn on the element itself, not in Studio's overlay above it. This is
|
|
137
147
|
// the only mark that says the caret is in the TEXT rather than the
|
|
@@ -168,10 +178,16 @@ export function useInlineTextEdit({
|
|
|
168
178
|
// saved rather than something the server will quietly cut down.
|
|
169
179
|
sanitizeRichTextChildren(open.element);
|
|
170
180
|
const html = open.element.innerHTML;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
181
|
+
// Counted and pending before the edit closes, so a reload waits for the save, not the mark.
|
|
182
|
+
let save: (saving: unknown) => void = () => {};
|
|
183
|
+
trackStudioPendingEdit(new Promise((resolve) => (save = resolve)));
|
|
184
|
+
const commitText = () => {
|
|
185
|
+
teardown();
|
|
186
|
+
// After teardown, so the commit path's own resync does not fight an
|
|
187
|
+
// element that is still editable.
|
|
188
|
+
return onCommit({ element: open.element, html, previousHtml: open.original });
|
|
189
|
+
};
|
|
190
|
+
save(countStudioManualEditSave(open.element, commitText));
|
|
175
191
|
}, [onCommit, teardown]);
|
|
176
192
|
|
|
177
193
|
const cancel = useCallback(() => {
|
|
@@ -86,6 +86,21 @@ describe("keyframe shortcuts", () => {
|
|
|
86
86
|
window.removeEventListener("keydown", playback);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
describe("with a focused picker", () => {
|
|
90
|
+
const picker = document.createElement("button");
|
|
91
|
+
picker.setAttribute("role", "combobox");
|
|
92
|
+
afterEach(() => picker.remove());
|
|
93
|
+
|
|
94
|
+
it("leaves K to a focused picker that owns typing, like a font combobox", () => {
|
|
95
|
+
const onAddKeyframe = mountKeyframeKeyboard(true);
|
|
96
|
+
document.body.append(picker);
|
|
97
|
+
picker.focus();
|
|
98
|
+
|
|
99
|
+
expect(press("k").defaultPrevented).toBe(false);
|
|
100
|
+
expect(onAddKeyframe).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
89
104
|
it("K stays playback's stop key when no keyframeable element is selected", () => {
|
|
90
105
|
const playback = vi.fn();
|
|
91
106
|
window.addEventListener("keydown", playback);
|
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
import { useEffect, useCallback } from "react";
|
|
2
2
|
import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
|
|
3
|
+
import { isTypingTarget } from "../utils/typingTarget";
|
|
3
4
|
|
|
4
5
|
interface KeyframeKeyboardOptions {
|
|
5
6
|
enabled: boolean;
|
|
6
7
|
onAddKeyframe?: () => void;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
function isTextInput(el: Element | null): boolean {
|
|
10
|
-
if (!el) return false;
|
|
11
|
-
const tag = el.tagName;
|
|
12
|
-
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
13
|
-
return (el as HTMLElement).isContentEditable === true;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
10
|
export function useKeyframeKeyboard({ enabled, onAddKeyframe }: KeyframeKeyboardOptions): void {
|
|
17
11
|
const handler = useCallback(
|
|
18
12
|
(e: KeyboardEvent) => {
|
|
19
13
|
if (!enabled || !onAddKeyframe) return;
|
|
20
|
-
if (
|
|
14
|
+
if (isTypingTarget(document.activeElement)) return;
|
|
21
15
|
if (e.metaKey || e.ctrlKey) return; // never shadow browser/system combos
|
|
22
16
|
if (e.key.toLowerCase() !== STUDIO_PLAIN_KEYS.addKeyframe) return;
|
|
23
17
|
// K is also playback's stop key: claiming it here keeps playback from also pausing.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { onPreviewPromoted } from "../player/sceneSwap";
|
|
3
|
+
|
|
4
|
+
/** The one source for which preview is on screen: the host's iframe, then each reload promoted in its place. */
|
|
5
|
+
export function useLivePreviewIframe(host: HTMLIFrameElement | null): HTMLIFrameElement | null {
|
|
6
|
+
const [promoted, setPromoted] = useState<{ host: HTMLIFrameElement; live: HTMLIFrameElement }>();
|
|
7
|
+
const live = host && promoted?.host === host ? promoted.live : host;
|
|
8
|
+
const liveRef = useRef(live);
|
|
9
|
+
liveRef.current = live;
|
|
10
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!host) return;
|
|
13
|
+
return onPreviewPromoted(host.ownerDocument, ({ retired, live: next }) => {
|
|
14
|
+
if (retired === liveRef.current) setPromoted({ host, live: next });
|
|
15
|
+
});
|
|
16
|
+
}, [host]);
|
|
17
|
+
return live;
|
|
18
|
+
}
|