@hyperframes/studio 0.8.106 → 0.8.108
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-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
- package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +377 -12
- package/dist/index.html +2 -2
- package/dist/index.js +13445 -11874
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/EditorShell.tsx +2 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- 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/DomEditOverlay.tsx +12 -18
- 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/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/domEditOverlayStartGesture.ts +5 -2
- 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/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +15 -1
- package/src/components/editor/mediaAudioEdits.test.ts +271 -0
- package/src/components/editor/mediaAudioEdits.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 +290 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +46 -23
- 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/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- 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/contexts/TimelineEditContext.tsx +1 -0
- 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/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- 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.ts +2 -3
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
- 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/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +8 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.tsx +2 -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/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -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 +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +25 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- 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.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/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +2 -2
- 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 +129 -0
- package/src/utils/studioPendingEdits.ts +61 -18
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
|
@@ -506,34 +506,6 @@ export function shouldShowTimelineShortcutHint(
|
|
|
506
506
|
return scrollHeight - clientHeight <= 1;
|
|
507
507
|
}
|
|
508
508
|
|
|
509
|
-
export function shouldHandleTimelineDeleteKey(input: {
|
|
510
|
-
key: string;
|
|
511
|
-
metaKey?: boolean;
|
|
512
|
-
ctrlKey?: boolean;
|
|
513
|
-
altKey?: boolean;
|
|
514
|
-
target?: EventTarget | null;
|
|
515
|
-
}): boolean {
|
|
516
|
-
if (input.key !== "Delete" && input.key !== "Backspace") return false;
|
|
517
|
-
if (input.metaKey || input.ctrlKey || input.altKey) return false;
|
|
518
|
-
const target =
|
|
519
|
-
input.target && typeof input.target === "object"
|
|
520
|
-
? (input.target as {
|
|
521
|
-
tagName?: string;
|
|
522
|
-
isContentEditable?: boolean;
|
|
523
|
-
closest?: (selector: string) => Element | null;
|
|
524
|
-
})
|
|
525
|
-
: null;
|
|
526
|
-
if (target) {
|
|
527
|
-
const tag = target.tagName?.toLowerCase() ?? "";
|
|
528
|
-
if (target.isContentEditable) return false;
|
|
529
|
-
if (["input", "textarea", "select"].includes(tag)) return false;
|
|
530
|
-
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
|
|
531
|
-
return false;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return true;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
509
|
/* ── Asset drop ───────────────────────────────────────────────────── */
|
|
538
510
|
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
|
|
539
511
|
if (trackOrder.length === 0) return 0;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
|
|
3
|
+
import { HF_AUDIO_CARVE_ATTR } from "@hyperframes/core/audio-carve";
|
|
4
|
+
import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
|
|
5
|
+
import { HF_COLOR_GRADING_ATTR } from "@hyperframes/core/color-grading";
|
|
6
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { useLivePreviewIframe } from "../store/previewIframeStore";
|
|
8
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
9
|
+
import { findTimelineElementInIframe } from "../../hooks/timelineEditingHelpers";
|
|
10
|
+
import { committedClipPath } from "../../components/editor/cropPresetStore";
|
|
11
|
+
import { isAudibleVideoNode } from "../lib/timelineElementHelpers";
|
|
12
|
+
import type { ClipToolState } from "./clipToolAttrs";
|
|
13
|
+
|
|
14
|
+
const LINK_ATTR = "data-link";
|
|
15
|
+
|
|
16
|
+
const WATCHED_ATTRS = [
|
|
17
|
+
HF_AUDIO_FX_ATTR,
|
|
18
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
19
|
+
HF_AUDIO_CARVE_ATTR,
|
|
20
|
+
HF_COLOR_GRADING_ATTR,
|
|
21
|
+
LINK_ATTR,
|
|
22
|
+
"data-volume",
|
|
23
|
+
"data-has-audio",
|
|
24
|
+
"muted",
|
|
25
|
+
"style",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function readVolume(node: Element): number | null {
|
|
29
|
+
const volume = Number.parseFloat(node.getAttribute("data-volume") ?? "");
|
|
30
|
+
return Number.isFinite(volume) ? volume : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toolStateFromNode(node: Element): ClipToolState {
|
|
34
|
+
const tag = node.tagName.toLowerCase();
|
|
35
|
+
return {
|
|
36
|
+
tag,
|
|
37
|
+
hasSound: tag === "audio" || isAudibleVideoNode(node),
|
|
38
|
+
volume: readVolume(node),
|
|
39
|
+
muted: node.hasAttribute("muted"),
|
|
40
|
+
fxChain: node.getAttribute(HF_AUDIO_FX_ATTR),
|
|
41
|
+
automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR),
|
|
42
|
+
colorGrading: node.getAttribute(HF_COLOR_GRADING_ATTR),
|
|
43
|
+
clipPath: committedClipPath(node),
|
|
44
|
+
carve: node.getAttribute(HF_AUDIO_CARVE_ATTR),
|
|
45
|
+
link: node.getAttribute(LINK_ATTR),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toolStateFromElement(el: TimelineElement): ClipToolState {
|
|
50
|
+
const tag = el.tag.trim().toLowerCase();
|
|
51
|
+
return {
|
|
52
|
+
tag,
|
|
53
|
+
hasSound: tag === "audio" || el.hasAudio === true,
|
|
54
|
+
volume: el.volume ?? null,
|
|
55
|
+
muted: el.muted === true,
|
|
56
|
+
fxChain: el.fxChain ?? null,
|
|
57
|
+
automation: el.automation ?? null,
|
|
58
|
+
colorGrading: null,
|
|
59
|
+
clipPath: null,
|
|
60
|
+
carve: null,
|
|
61
|
+
link: null,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Inline style changes every frame under a tween; only a changed reading re-renders. */
|
|
66
|
+
function keepIfUnchanged(prev: ClipToolState, next: ClipToolState): ClipToolState {
|
|
67
|
+
return JSON.stringify(prev) === JSON.stringify(next) ? prev : next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A clip's tool attributes as the live preview node holds them, kept current as they change. */
|
|
71
|
+
export function useClipToolState(el: TimelineElement): ClipToolState {
|
|
72
|
+
const iframe = useLivePreviewIframe();
|
|
73
|
+
const activeCompPath = useStudioShellContextOptional()?.activeCompPath ?? null;
|
|
74
|
+
const [state, setState] = useState<ClipToolState>(() => toolStateFromElement(el));
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const node = findTimelineElementInIframe(iframe, el, activeCompPath);
|
|
78
|
+
if (!node) {
|
|
79
|
+
setState(toolStateFromElement(el));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const refresh = () => setState((prev) => keepIfUnchanged(prev, toolStateFromNode(node)));
|
|
83
|
+
refresh();
|
|
84
|
+
const view = node.ownerDocument.defaultView;
|
|
85
|
+
if (!view) return;
|
|
86
|
+
const observer = new view.MutationObserver(refresh);
|
|
87
|
+
observer.observe(node, { attributes: true, attributeFilter: WATCHED_ATTRS });
|
|
88
|
+
return () => observer.disconnect();
|
|
89
|
+
}, [iframe, el, activeCompPath]);
|
|
90
|
+
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
@@ -261,3 +261,46 @@ describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
|
|
|
261
261
|
expect(usePlayerStore.getState().loopEnabled).toBe(false);
|
|
262
262
|
});
|
|
263
263
|
});
|
|
264
|
+
|
|
265
|
+
describe("usePlaybackKeyboard — a focused native player owns its keys", () => {
|
|
266
|
+
const KEYS = [
|
|
267
|
+
{ code: "ArrowLeft", key: "ArrowLeft" },
|
|
268
|
+
{ code: "ArrowRight", key: "ArrowRight" },
|
|
269
|
+
{ code: "Space", key: " " },
|
|
270
|
+
{ code: "KeyJ", key: "j" },
|
|
271
|
+
{ code: "KeyK", key: "k" },
|
|
272
|
+
{ code: "KeyL", key: "l" },
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
it.each(["video", "audio"])(
|
|
276
|
+
"leaves the timeline alone while a <%s controls> has focus",
|
|
277
|
+
(tag) => {
|
|
278
|
+
const { dispatch, spies } = setupHook();
|
|
279
|
+
const player = document.createElement(tag);
|
|
280
|
+
player.setAttribute("controls", "");
|
|
281
|
+
document.body.append(player);
|
|
282
|
+
const before = usePlayerStore.getState().currentTime;
|
|
283
|
+
for (const init of KEYS) {
|
|
284
|
+
const event = keydown(init);
|
|
285
|
+
Object.defineProperty(event, "target", { value: player });
|
|
286
|
+
dispatch(event);
|
|
287
|
+
expect(event.defaultPrevented, init.code).toBe(false);
|
|
288
|
+
}
|
|
289
|
+
expect(spies.seek).not.toHaveBeenCalled();
|
|
290
|
+
expect(spies.play).not.toHaveBeenCalled();
|
|
291
|
+
expect(spies.playBackward).not.toHaveBeenCalled();
|
|
292
|
+
expect(spies.pause).not.toHaveBeenCalled();
|
|
293
|
+
expect(usePlayerStore.getState().currentTime).toBe(before);
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
it("still steps the timeline from a player without controls", () => {
|
|
298
|
+
const { dispatch } = setupHook();
|
|
299
|
+
const video = document.createElement("video");
|
|
300
|
+
document.body.append(video);
|
|
301
|
+
const event = keydown({ code: "ArrowRight", key: "ArrowRight" });
|
|
302
|
+
Object.defineProperty(event, "target", { value: video });
|
|
303
|
+
dispatch(event);
|
|
304
|
+
expect(event.defaultPrevented).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* is active and the user is navigating caption segments).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { ownsPlainKeys } from "../../utils/typingTarget";
|
|
10
10
|
|
|
11
11
|
const PLAYBACK_FRAME_STEP_CODES = new Set(["ArrowLeft", "ArrowRight"]);
|
|
12
12
|
|
|
@@ -15,21 +15,17 @@ const PLAYBACK_SHORTCUT_IGNORED_SELECTOR = [
|
|
|
15
15
|
"a[href]",
|
|
16
16
|
"[role='button']",
|
|
17
17
|
"[role='checkbox']",
|
|
18
|
-
"[role='combobox']",
|
|
19
18
|
"[role='menuitem']",
|
|
20
19
|
// Base UI's menu radio item is a `<div>`, so `button` above no longer catches it.
|
|
21
20
|
"[role='menuitemradio']",
|
|
22
21
|
"[role='radio']",
|
|
23
|
-
"[role='slider']",
|
|
24
22
|
"[role='spinbutton']",
|
|
25
|
-
"[role='switch']",
|
|
26
|
-
"[role='textbox']",
|
|
27
23
|
].join(",");
|
|
28
24
|
|
|
29
25
|
export function shouldIgnorePlaybackShortcutTarget(target: EventTarget | null): boolean {
|
|
30
26
|
// Anything the user is typing into owns its keys outright, editable elements
|
|
31
|
-
// included: a letter claimed here never reaches the text.
|
|
32
|
-
if (
|
|
27
|
+
// included: a letter claimed here never reaches the text. So does a native player.
|
|
28
|
+
if (ownsPlainKeys(target)) return true;
|
|
33
29
|
if (!target || typeof target !== "object") return false;
|
|
34
30
|
const candidate = target as { closest?: unknown };
|
|
35
31
|
if (typeof candidate.closest !== "function") return false;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isVideoAudible } from "./timelineElementHelpers";
|
|
3
|
+
|
|
4
|
+
describe("isVideoAudible — the compiler's data-has-audio rule", () => {
|
|
5
|
+
it("explicit data-has-audio wins", () => {
|
|
6
|
+
expect(isVideoAudible({ tag: "video", hasAudioAttr: "true", muted: false })).toBe(true);
|
|
7
|
+
expect(isVideoAudible({ tag: "video", hasAudioAttr: "false", muted: false })).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
it("no attribute: an unmuted video is audible, a muted one is not", () => {
|
|
10
|
+
expect(isVideoAudible({ tag: "video", hasAudioAttr: null, muted: false })).toBe(true);
|
|
11
|
+
expect(isVideoAudible({ tag: "video", hasAudioAttr: undefined, muted: true })).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
it("never true for non-video without the attribute", () => {
|
|
14
|
+
expect(isVideoAudible({ tag: "img", hasAudioAttr: null, muted: false })).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -220,12 +220,35 @@ function readVolume(el: Element, media: Element): number | undefined {
|
|
|
220
220
|
return Number.isFinite(volume) ? volume : undefined;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/** The compiler's rule (timingCompiler): explicit data-has-audio wins; otherwise an unmuted <video> is audible. */
|
|
224
|
+
export function isVideoAudible(opts: {
|
|
225
|
+
tag: string;
|
|
226
|
+
hasAudioAttr: string | null | undefined;
|
|
227
|
+
muted: boolean;
|
|
228
|
+
}): boolean {
|
|
229
|
+
if (opts.hasAudioAttr === "true") return true;
|
|
230
|
+
if (opts.hasAudioAttr === "false" || opts.hasAudioAttr === "") return false;
|
|
231
|
+
return opts.tag.toLowerCase() === "video" && !opts.muted;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function isAudibleVideoNode(el: Element): boolean {
|
|
235
|
+
if (el.tagName.toLowerCase() !== "video" || el.hasAttribute("muted")) return false;
|
|
236
|
+
return isVideoAudible({
|
|
237
|
+
tag: "video",
|
|
238
|
+
hasAudioAttr: el.getAttribute("data-has-audio"),
|
|
239
|
+
muted: false,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
223
243
|
/** What the mixer gets: the compiler's `data-has-audio` rule, muted and volume. */
|
|
224
244
|
function applyAudioMetadataFromElement(entry: TimelineElement, el: Element): void {
|
|
225
245
|
const media = resolveMediaElement(el) ?? el;
|
|
226
246
|
const muted = el.hasAttribute("muted") || media.hasAttribute("muted");
|
|
227
|
-
const
|
|
228
|
-
|
|
247
|
+
const sound = isVideoAudible({
|
|
248
|
+
tag: el.tagName,
|
|
249
|
+
hasAudioAttr: el.getAttribute("data-has-audio"),
|
|
250
|
+
muted,
|
|
251
|
+
});
|
|
229
252
|
setOptional(entry, "hasAudio", sound ? true : undefined);
|
|
230
253
|
setOptional(entry, "muted", muted ? true : undefined);
|
|
231
254
|
setOptional(entry, "volume", readVolume(el, media));
|
|
@@ -19,6 +19,8 @@ describe("timeline viewport budgets", () => {
|
|
|
19
19
|
interactionP95Ms: 50,
|
|
20
20
|
constrainedInteractionP95Ms: 75,
|
|
21
21
|
constrainedFrameIntervalP95Ms: 75,
|
|
22
|
+
ciVirtualizedInteractionP95Ms: 58.3,
|
|
23
|
+
ciVirtualizedFrameIntervalP95Ms: 25,
|
|
22
24
|
longTaskLimitMs: 50,
|
|
23
25
|
constrainedLongTaskLimitMs: 300,
|
|
24
26
|
posterCoverageRatio: 0.9,
|
|
@@ -28,6 +28,8 @@ export interface TimelineViewportBudgets {
|
|
|
28
28
|
frameIntervalP95Ms: number;
|
|
29
29
|
constrainedInteractionP95Ms: number;
|
|
30
30
|
constrainedFrameIntervalP95Ms: number;
|
|
31
|
+
ciVirtualizedInteractionP95Ms: number;
|
|
32
|
+
ciVirtualizedFrameIntervalP95Ms: number;
|
|
31
33
|
longTaskLimitMs: number;
|
|
32
34
|
constrainedLongTaskLimitMs: number;
|
|
33
35
|
memoryReturnToleranceRatio: number;
|
|
@@ -86,6 +88,8 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
|
|
|
86
88
|
frameIntervalP95Ms: 33.3,
|
|
87
89
|
constrainedInteractionP95Ms: 75,
|
|
88
90
|
constrainedFrameIntervalP95Ms: 75,
|
|
91
|
+
ciVirtualizedInteractionP95Ms: 58.3,
|
|
92
|
+
ciVirtualizedFrameIntervalP95Ms: 25,
|
|
89
93
|
longTaskLimitMs: 50,
|
|
90
94
|
constrainedLongTaskLimitMs: 300,
|
|
91
95
|
memoryReturnToleranceRatio: 0.15,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
AudioMeterStrip,
|
|
7
7
|
TimelineHistoryButtons,
|
|
8
8
|
TimelineToolbar,
|
|
9
|
+
useAudioMetersVisible as exportedAudioMetersVisible,
|
|
9
10
|
useClipboard,
|
|
10
11
|
usePlayerStore,
|
|
11
12
|
type UseClipboardOptions,
|
|
@@ -51,6 +52,12 @@ describe("timeline chrome package exports, outside Studio's shell", () => {
|
|
|
51
52
|
await act(async () => root.unmount());
|
|
52
53
|
});
|
|
53
54
|
|
|
55
|
+
it("exports the meter visibility store a host toggles", () => {
|
|
56
|
+
expect(exportedAudioMetersVisible).toBe(useAudioMetersVisible);
|
|
57
|
+
exportedAudioMetersVisible.getState().setVisible(true);
|
|
58
|
+
expect(useAudioMetersVisible.getState().visible).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
54
61
|
it("runs the clipboard with no DOM edit session", async () => {
|
|
55
62
|
const toasts: string[] = [];
|
|
56
63
|
const options: UseClipboardOptions = {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../player";
|
|
2
3
|
import {
|
|
4
|
+
hasProjectAudio,
|
|
3
5
|
levelToFraction,
|
|
4
6
|
markFraction,
|
|
5
7
|
SILENT_CHANNEL,
|
|
@@ -42,3 +44,38 @@ describe("stepPair", () => {
|
|
|
42
44
|
expect(stepPair(rest, { l: 1, r: 0 }, 32, 16)).not.toBe(rest);
|
|
43
45
|
});
|
|
44
46
|
});
|
|
47
|
+
|
|
48
|
+
describe("hasProjectAudio", () => {
|
|
49
|
+
const clip = (overrides: Partial<TimelineElement>): TimelineElement => ({
|
|
50
|
+
id: "c",
|
|
51
|
+
tag: "div",
|
|
52
|
+
start: 0,
|
|
53
|
+
duration: 1,
|
|
54
|
+
track: 0,
|
|
55
|
+
...overrides,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ignores an audible video left on native output at unity gain", () => {
|
|
59
|
+
expect(hasProjectAudio([clip({ tag: "video", hasAudio: true })])).toBe(false);
|
|
60
|
+
expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, volume: 1 })])).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it.each<[string, Partial<TimelineElement>]>([
|
|
64
|
+
["an above-unity gain", { volume: 2 }],
|
|
65
|
+
["an fx chain", { fxChain: "[]" }],
|
|
66
|
+
["automation", { automation: "{}" }],
|
|
67
|
+
["a group", { audioGroup: "music" }],
|
|
68
|
+
])("counts an audible video routed through Web Audio by %s", (_, fields) => {
|
|
69
|
+
expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, ...fields })])).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ignores a muted video even when it carries processing", () => {
|
|
73
|
+
expect(hasProjectAudio([clip({ tag: "video", hasAudio: true, muted: true, volume: 2 })])).toBe(
|
|
74
|
+
false,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("ignores a silent video", () => {
|
|
79
|
+
expect(hasProjectAudio([clip({ tag: "video" })])).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { audioDbToGain, audioGainToDb } from "@hyperframes/core/audio-gain";
|
|
3
|
+
import { audibleVideoNeedsWebAudio } from "@hyperframes/core/audible-video";
|
|
3
4
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
4
5
|
import { isAudioTimelineElement } from "./timelineInspector";
|
|
5
6
|
|
|
@@ -67,9 +68,14 @@ export function stepPair(
|
|
|
67
68
|
return [stepChannel(l, raw?.l ?? 0, now, dtMs), stepChannel(r, raw?.r ?? 0, now, dtMs)];
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
/** The meters appear only for a project with
|
|
71
|
-
function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
|
|
72
|
-
return elements.some(
|
|
71
|
+
/** The meters appear only for a project with an audio clip, a group, or a video Web Audio carries. */
|
|
72
|
+
export function hasProjectAudio(elements: readonly TimelineElement[]): boolean {
|
|
73
|
+
return elements.some(
|
|
74
|
+
(el) =>
|
|
75
|
+
isAudioTimelineElement(el) ||
|
|
76
|
+
el.audioGroup !== undefined ||
|
|
77
|
+
(el.hasAudio === true && !el.muted && audibleVideoNeedsWebAudio(el)),
|
|
78
|
+
);
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
/** Scans once per `elements` change, not on every store update (the playhead ticks each frame). */
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EXPORT_PEAK_CEILING = 10 ** (-1 / 20);
|
|
@@ -195,6 +195,7 @@ export interface SoftReloadOptions {
|
|
|
195
195
|
currentTimeOverride?: number;
|
|
196
196
|
/** After-write file HTML — the primary source for the authored opacity and transform restore. */
|
|
197
197
|
authoredHtml?: string;
|
|
198
|
+
reparse?: Element[];
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
/**
|
|
@@ -251,7 +252,7 @@ export function applySoftReload(
|
|
|
251
252
|
scriptText: string,
|
|
252
253
|
options: SoftReloadOptions = {},
|
|
253
254
|
): SoftReloadResult {
|
|
254
|
-
const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
|
|
255
|
+
const { onAsyncFailure, currentTimeOverride, authoredHtml, reparse = [] } = options;
|
|
255
256
|
if (!iframe || !scriptText) return "cannot-soft-reload";
|
|
256
257
|
|
|
257
258
|
const win = iframe.contentWindow as IframeWindow | null;
|
|
@@ -329,7 +330,7 @@ export function applySoftReload(
|
|
|
329
330
|
// fallow-ignore-next-line complexity
|
|
330
331
|
const doReload = () => {
|
|
331
332
|
const timelines = win.__timelines;
|
|
332
|
-
const allTargets: Element[] = [];
|
|
333
|
+
const allTargets: Element[] = [...reparse];
|
|
333
334
|
|
|
334
335
|
// Kill ONLY the target composition's timeline(s) — leaving every other
|
|
335
336
|
// composition's timeline (and its children on the global timeline) intact.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Real Chrome: the box GSAP and a stylesheet translate produce together is what no DOM emulation computes.
|
|
2
|
+
import { mkdtempSync, readFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import puppeteer, { type Browser } from "puppeteer-core";
|
|
8
|
+
import { build } from "vite";
|
|
9
|
+
import { afterAll, beforeAll, expect, it } from "vitest";
|
|
10
|
+
import { findSystemChrome } from "../../vite.browser";
|
|
11
|
+
import { writeFixture } from "../../tests/e2e/edit-accuracy/grid.mjs";
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const CROP = "inset(0px 40px 0px 0px)";
|
|
15
|
+
let browser: Browser;
|
|
16
|
+
let undoBundle: string;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
const executablePath = findSystemChrome();
|
|
20
|
+
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
21
|
+
browser = await puppeteer.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
|
22
|
+
const out = await build({
|
|
23
|
+
configFile: false,
|
|
24
|
+
logLevel: "silent",
|
|
25
|
+
resolve: {
|
|
26
|
+
alias: { canvas: fileURLToPath(new URL("../shims/canvasBrowserStub.js", import.meta.url)) },
|
|
27
|
+
},
|
|
28
|
+
build: {
|
|
29
|
+
write: false,
|
|
30
|
+
minify: false,
|
|
31
|
+
lib: {
|
|
32
|
+
entry: fileURLToPath(new URL("./gsapUndoRestore.ts", import.meta.url)),
|
|
33
|
+
formats: ["iife"],
|
|
34
|
+
name: "hfUndo",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
undoBundle = (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0]
|
|
39
|
+
.code;
|
|
40
|
+
}, 60_000);
|
|
41
|
+
|
|
42
|
+
afterAll(() => browser?.close());
|
|
43
|
+
|
|
44
|
+
it.each([
|
|
45
|
+
{ gsap: "hold", placement: "px", rotation: 0 },
|
|
46
|
+
{ gsap: "tween", placement: "px", rotation: 0 },
|
|
47
|
+
{ gsap: "tween", placement: "xpercent", rotation: 30 },
|
|
48
|
+
])("an undone crop leaves a $gsap $placement r$rotation layer where it was", async (spec) => {
|
|
49
|
+
const dir = join(mkdtempSync(join(tmpdir(), "undo-gsap-")), "case");
|
|
50
|
+
writeFixture({ gesture: "crop", nesting: "root", zoom: 100, ...spec }, dir);
|
|
51
|
+
const restored = readFileSync(join(dir, "index.html"), "utf8");
|
|
52
|
+
const previous = restored.replace(
|
|
53
|
+
`<div id="target"`,
|
|
54
|
+
`<div id="target" style="clip-path: ${CROP}"`,
|
|
55
|
+
);
|
|
56
|
+
const page = await browser.newPage();
|
|
57
|
+
await page.setViewport({ width: 1920, height: 1080 });
|
|
58
|
+
await page.setRequestInterception(true);
|
|
59
|
+
page.on("request", (request) =>
|
|
60
|
+
request.url().endsWith("/gsap.min.js")
|
|
61
|
+
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
62
|
+
: request.continue(),
|
|
63
|
+
);
|
|
64
|
+
await page.setContent(restored, { waitUntil: "load" });
|
|
65
|
+
await page.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
66
|
+
await page.waitForFunction(() => "__player" in window);
|
|
67
|
+
await page.evaluate(undoBundle);
|
|
68
|
+
|
|
69
|
+
const box = () =>
|
|
70
|
+
page.evaluate(() => {
|
|
71
|
+
const { left, top, width, height } = document
|
|
72
|
+
.getElementById("target")!
|
|
73
|
+
.getBoundingClientRect();
|
|
74
|
+
return [left, top, width, height].map((n) => Math.round(n * 100) / 100);
|
|
75
|
+
});
|
|
76
|
+
await page.evaluate(() =>
|
|
77
|
+
(window as unknown as { __player: { seek(t: number): void } }).__player.seek(1),
|
|
78
|
+
);
|
|
79
|
+
const before = await box();
|
|
80
|
+
const outcome = await page.evaluate(
|
|
81
|
+
(files, crop) => {
|
|
82
|
+
document.getElementById("target")!.style.setProperty("clip-path", crop);
|
|
83
|
+
const undo = (window as unknown as { hfUndo: typeof import("./gsapUndoRestore") }).hfUndo;
|
|
84
|
+
const iframe = { contentDocument: document, contentWindow: window } as HTMLIFrameElement;
|
|
85
|
+
return undo.applyUndoRestoreToPreview(iframe, "index.html", files, 1, () => {});
|
|
86
|
+
},
|
|
87
|
+
{ "index.html": { previous, restored } },
|
|
88
|
+
CROP,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(outcome).toBe("soft");
|
|
92
|
+
expect(await box()).toEqual(before);
|
|
93
|
+
await page.close();
|
|
94
|
+
});
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi } from "vitest";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
applyUndoRestoreToPreview,
|
|
6
|
+
diffSoftReloadableRestore,
|
|
7
|
+
showRestoreInPlace,
|
|
8
|
+
} from "./gsapUndoRestore";
|
|
9
|
+
import { applyPatch } from "./sourcePatcher";
|
|
10
|
+
import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
|
|
11
|
+
import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";
|
|
5
12
|
|
|
6
13
|
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
|
|
7
14
|
|
|
@@ -281,6 +288,25 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
281
288
|
);
|
|
282
289
|
});
|
|
283
290
|
|
|
291
|
+
it("full-reloads a style restore on a GSAP-parsed element when the file has two scripts", () => {
|
|
292
|
+
const scripts = [
|
|
293
|
+
`<script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
|
|
294
|
+
`<script>window.__timelines["captions"]=gsap.timeline().to("#a",{y:1});</script>`,
|
|
295
|
+
].join("");
|
|
296
|
+
const { iframe, doc } = buildLiveIframe(`<div id="a" style="z-index: 8">t</div>${scripts}`);
|
|
297
|
+
Object.assign(doc.getElementById("a")!, { _gsap: {} });
|
|
298
|
+
const reloadPreview = vi.fn();
|
|
299
|
+
const files = {
|
|
300
|
+
[ROOT]: {
|
|
301
|
+
previous: wrap(`<div id="a" style="z-index: 8">t</div>${scripts}`),
|
|
302
|
+
restored: wrap(`<div id="a" style="z-index: 3">t</div>${scripts}`),
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
307
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
284
310
|
it("full-reloads a multi-file restore", () => {
|
|
285
311
|
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
286
312
|
const reloadPreview = vi.fn();
|
|
@@ -420,3 +446,121 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
420
446
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
421
447
|
});
|
|
422
448
|
});
|
|
449
|
+
|
|
450
|
+
describe("an undo that lands while the layer is being dragged", () => {
|
|
451
|
+
const ROOT = "index.html";
|
|
452
|
+
const undone = `<div id="a" style="width: 120px; translate: 90px 60px" data-start="2" data-hf-studio-original-inline-translate="">t</div>`;
|
|
453
|
+
const restored = wrap(`<div id="a" style="width: 100px" data-start="1">t</div>`);
|
|
454
|
+
const files = { [ROOT]: { previous: wrap(undone), restored } };
|
|
455
|
+
const saveDrop = (el: HTMLElement, file: string) =>
|
|
456
|
+
writePlainMove(el, { x: 20, y: 110 }).reduce((html, op) => applyPatch(html, "a", op), file);
|
|
457
|
+
|
|
458
|
+
function dragging(drag: boolean) {
|
|
459
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
460
|
+
const el = doc.getElementById("a")!;
|
|
461
|
+
if (drag) beginStudioManualEditGesture(el, "move");
|
|
462
|
+
if (drag) writeTranslatePx(el, { x: 70, y: 110 });
|
|
463
|
+
else el.style.setProperty("opacity", "0.5");
|
|
464
|
+
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
|
|
465
|
+
return el;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
it("keeps the translate the drag is drawing and reverts the rest", () => {
|
|
469
|
+
const el = dragging(true);
|
|
470
|
+
|
|
471
|
+
expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
|
|
472
|
+
expect(el.style.getPropertyValue("width")).toBe("100px");
|
|
473
|
+
expect(el.getAttribute("data-start")).toBe("1");
|
|
474
|
+
expect(el.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("saves the drop onto the undone file, the same bytes as dragging the undone layer", () => {
|
|
478
|
+
const el = dragging(true);
|
|
479
|
+
const fresh = new DOMParser().parseFromString(restored, "text/html").getElementById("a")!;
|
|
480
|
+
|
|
481
|
+
const saved = saveDrop(el, restored);
|
|
482
|
+
|
|
483
|
+
expect(saved).toBe(saveDrop(fresh, restored));
|
|
484
|
+
expect(saved).toContain("width: 100px; translate: 20px 110px");
|
|
485
|
+
expect(saved).not.toContain("120px");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("keeps the box where it is when the press has not moved it yet", () => {
|
|
489
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
490
|
+
const el = doc.getElementById("a")!;
|
|
491
|
+
beginStudioManualEditGesture(el, "move");
|
|
492
|
+
|
|
493
|
+
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
|
|
494
|
+
|
|
495
|
+
expect(el.style.getPropertyValue("translate")).toBe("90px 60px");
|
|
496
|
+
expect(el.style.getPropertyValue("width")).toBe("100px");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("reverts the translate under a gesture that draws nothing, such as a text edit", () => {
|
|
500
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
501
|
+
const el = doc.getElementById("a")!;
|
|
502
|
+
beginStudioManualEditGesture(el, "edit");
|
|
503
|
+
|
|
504
|
+
applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
|
|
505
|
+
|
|
506
|
+
expect(el.style.getPropertyValue("translate")).toBe("");
|
|
507
|
+
expect(el.hasAttribute("data-hf-studio-manual-edit-gesture")).toBe(true);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("keeps the drag's translate when the undo is shown in place", () => {
|
|
511
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
512
|
+
const el = doc.getElementById("a")!;
|
|
513
|
+
beginStudioManualEditGesture(el, "move");
|
|
514
|
+
writeTranslatePx(el, { x: 70, y: 110 });
|
|
515
|
+
|
|
516
|
+
expect(showRestoreInPlace(iframe, ROOT, files, 3)).not.toBeNull();
|
|
517
|
+
|
|
518
|
+
expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
|
|
519
|
+
expect(el.style.getPropertyValue("width")).toBe("100px");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("keeps a drag that started after the undo was shown when the undo is put back", () => {
|
|
523
|
+
const { iframe, doc } = buildLiveIframe(undone);
|
|
524
|
+
const el = doc.getElementById("a")!;
|
|
525
|
+
const putBack = showRestoreInPlace(iframe, ROOT, files, 3)!;
|
|
526
|
+
beginStudioManualEditGesture(el, "move");
|
|
527
|
+
writeTranslatePx(el, { x: 70, y: 110 });
|
|
528
|
+
|
|
529
|
+
putBack(3);
|
|
530
|
+
|
|
531
|
+
expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
|
|
532
|
+
expect(el.style.getPropertyValue("width")).toBe("120px");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("keeps the drag's translate when the undo re-runs a GSAP script", () => {
|
|
536
|
+
const script = (extra: string) =>
|
|
537
|
+
`<script>window.__timelines["root"]=gsap.timeline();${extra}</script>`;
|
|
538
|
+
const layer = `<div id="a" data-hf-studio-path-offset="true" style="translate: 40px 30px">t</div>`;
|
|
539
|
+
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
540
|
+
`${layer}${script("tl.set('#b',{x:1});")}`,
|
|
541
|
+
);
|
|
542
|
+
Object.assign(contentWindow.gsap, { set: () => {} });
|
|
543
|
+
const el = doc.getElementById("a")!;
|
|
544
|
+
beginStudioManualEditGesture(el, "move");
|
|
545
|
+
writeTranslatePx(el, { x: 70, y: 110 });
|
|
546
|
+
const scripted = {
|
|
547
|
+
[ROOT]: {
|
|
548
|
+
previous: wrap(`<div id="a">t</div>${script("tl.set('#b',{x:1});")}`),
|
|
549
|
+
restored: wrap(`<div id="a">t</div>${script("")}`),
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, scripted, 3, vi.fn())).toBe("soft");
|
|
554
|
+
|
|
555
|
+
expect(el.style.getPropertyValue("translate")).toBe("70px 110px");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("restores a layer no gesture is drawing exactly as before", () => {
|
|
559
|
+
const el = dragging(false);
|
|
560
|
+
const want = new DOMParser().parseFromString(restored, "text/html").getElementById("a")!;
|
|
561
|
+
|
|
562
|
+
expect(el.getAttributeNames().map((n) => [n, el.getAttribute(n)])).toEqual(
|
|
563
|
+
want.getAttributeNames().map((n) => [n, want.getAttribute(n)]),
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
});
|