@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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
6
|
+
import {
|
|
7
|
+
makePreview,
|
|
8
|
+
mountPlayerWithPreview,
|
|
9
|
+
paintShadow,
|
|
10
|
+
resetPlayerStore,
|
|
11
|
+
} from "../player/hooks/timelinePlayerTestHarness";
|
|
12
|
+
import { useDomEditCommits } from "./useDomEditCommits";
|
|
13
|
+
|
|
14
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
15
|
+
vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
16
|
+
vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
|
|
18
|
+
ensureMotionPathPluginLoaded: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
/** The project file; the patch route applies a colour to it, and each preview load serves it. */
|
|
22
|
+
let color = "red";
|
|
23
|
+
const card = () => `<div data-hf-id="hf-card" style="color: ${color}">Card</div>`;
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
document.body.innerHTML = "";
|
|
28
|
+
resetPlayerStore();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("an inspector save made while a reload loads is not undone when that reload goes on screen", async () => {
|
|
32
|
+
color = "red";
|
|
33
|
+
let land = () => {};
|
|
34
|
+
const landed = new Promise<void>((resolve) => (land = resolve));
|
|
35
|
+
vi.stubGlobal(
|
|
36
|
+
"fetch",
|
|
37
|
+
vi.fn(async (input: string, init?: RequestInit) => {
|
|
38
|
+
if (!input.includes("/file-mutations/patch-element/"))
|
|
39
|
+
return Response.json({ content: card() });
|
|
40
|
+
await landed;
|
|
41
|
+
const { operations } = JSON.parse(String(init?.body)) as { operations: { value: string }[] };
|
|
42
|
+
color = operations[0]!.value;
|
|
43
|
+
return Response.json({ ok: true, changed: true, matched: true, content: card() });
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
const live = makePreview(card());
|
|
47
|
+
const player = mountPlayerWithPreview(live);
|
|
48
|
+
const element = live.contentDocument!.querySelector<HTMLElement>("[data-hf-id]")!;
|
|
49
|
+
const selection = {
|
|
50
|
+
element,
|
|
51
|
+
label: "Card",
|
|
52
|
+
tagName: "div",
|
|
53
|
+
sourceFile: "index.html",
|
|
54
|
+
compositionPath: "index.html",
|
|
55
|
+
textFields: [],
|
|
56
|
+
inlineStyles: { color: "red" },
|
|
57
|
+
computedStyles: {},
|
|
58
|
+
dataAttributes: {},
|
|
59
|
+
capabilities: { canSelect: true, canEditStyles: true },
|
|
60
|
+
hfId: "hf-card",
|
|
61
|
+
selector: '[data-hf-id="hf-card"]',
|
|
62
|
+
selectorIndex: 0,
|
|
63
|
+
} as unknown as DomEditSelection;
|
|
64
|
+
let commits: ReturnType<typeof useDomEditCommits> | null = null;
|
|
65
|
+
function Inspector() {
|
|
66
|
+
commits = useDomEditCommits({
|
|
67
|
+
activeCompPath: "index.html",
|
|
68
|
+
previewIframeRef: player.getApi().iframeRef,
|
|
69
|
+
showToast: () => {},
|
|
70
|
+
queueDomEditSave: (save) => save(),
|
|
71
|
+
writeProjectFile: async () => {},
|
|
72
|
+
editHistory: { recordEdit: async () => {} },
|
|
73
|
+
fileTree: [],
|
|
74
|
+
importedFontAssetsRef: { current: [] },
|
|
75
|
+
projectId: "p1",
|
|
76
|
+
projectIdRef: { current: "p1" },
|
|
77
|
+
reloadPreview: () => {},
|
|
78
|
+
domEditSelection: selection,
|
|
79
|
+
applyDomSelection: () => {},
|
|
80
|
+
clearDomSelection: () => {},
|
|
81
|
+
refreshDomEditSelectionFromPreview: () => {},
|
|
82
|
+
buildDomSelectionFromTarget: async () => null,
|
|
83
|
+
readOnlyPreview: false,
|
|
84
|
+
});
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const root = createRoot(document.createElement("div"));
|
|
88
|
+
act(() => root.render(createElement(Inspector)));
|
|
89
|
+
|
|
90
|
+
act(() => player.getApi().refreshPlayer());
|
|
91
|
+
const beforeSave = makePreview(card(), "?_t=1");
|
|
92
|
+
let saved: Promise<unknown> = Promise.resolve();
|
|
93
|
+
act(() => void (saved = commits!.handleDomStyleCommit("color", "green")));
|
|
94
|
+
await act(async () => {
|
|
95
|
+
land();
|
|
96
|
+
await saved;
|
|
97
|
+
});
|
|
98
|
+
expect(color).toBe("green");
|
|
99
|
+
|
|
100
|
+
const requested = await paintShadow(player.getApi, beforeSave);
|
|
101
|
+
expect(player.getApi().iframeRef.current, "the file before the save").toBe(live);
|
|
102
|
+
const fresh = makePreview(card(), "?_t=2");
|
|
103
|
+
expect(await paintShadow(player.getApi, fresh)).toBeGreaterThan(requested);
|
|
104
|
+
expect(player.getApi().iframeRef.current).toBe(fresh);
|
|
105
|
+
act(() => root.unmount());
|
|
106
|
+
act(() => player.root.unmount());
|
|
107
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
6
|
+
import { resolveElementForOverlay } from "../components/editor/domEditOverlayGeometry";
|
|
7
|
+
import {
|
|
8
|
+
makePreview,
|
|
9
|
+
mountPlayerWithPreview,
|
|
10
|
+
paintShadow,
|
|
11
|
+
resetPlayerStore,
|
|
12
|
+
type TimelinePlayerApi,
|
|
13
|
+
} from "../player/hooks/timelinePlayerTestHarness";
|
|
14
|
+
import { announcePreviewPromoted } from "../player/sceneSwap";
|
|
15
|
+
import { stageElementOffset } from "./elementOffsetStager";
|
|
16
|
+
import { savePlainRotation } from "./plainRotation";
|
|
17
|
+
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
|
|
18
|
+
import { useLivePreviewIframe } from "./useLivePreviewIframe";
|
|
19
|
+
|
|
20
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
|
+
|
|
22
|
+
vi.mock("../utils/gsapSoftReload", async (importOriginal) => ({
|
|
23
|
+
...(await importOriginal<typeof import("../utils/gsapSoftReload")>()),
|
|
24
|
+
ensureMotionPathPluginLoaded: vi.fn(),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
document.body.innerHTML = "";
|
|
29
|
+
resetPlayerStore();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const previewWithTitle = (query = "") => makePreview('<h1 id="title">Title</h1>', query);
|
|
33
|
+
|
|
34
|
+
const titleOf = (iframe: HTMLIFrameElement | null) =>
|
|
35
|
+
iframe!.contentDocument!.getElementById("title") as HTMLElement;
|
|
36
|
+
|
|
37
|
+
const selectionOf = (element: HTMLElement) =>
|
|
38
|
+
({ element, id: "title", selector: "#title", label: "title" }) as unknown as DomEditSelection;
|
|
39
|
+
|
|
40
|
+
/** A host shaped like Desktop's: it reads its iframe once and never re-renders for a reload. */
|
|
41
|
+
function mountHost(followPromotions: boolean) {
|
|
42
|
+
const selectionRef = { current: null as DomEditSelection | null };
|
|
43
|
+
function Session({ host }: { host: HTMLIFrameElement }) {
|
|
44
|
+
const live = useLivePreviewIframe(host);
|
|
45
|
+
useDomEditPreviewSync({
|
|
46
|
+
previewIframe: followPromotions ? live : host,
|
|
47
|
+
activeCompPath: null,
|
|
48
|
+
captionEditMode: false,
|
|
49
|
+
domEditSelectionRef: selectionRef,
|
|
50
|
+
domEditGroupSelectionsRef: { current: [] },
|
|
51
|
+
domEditSelection: selectionRef.current,
|
|
52
|
+
refreshDomEditGroupSelectionsFromPreview: async () => {},
|
|
53
|
+
applyDomSelection: (selection) => void (selectionRef.current = selection),
|
|
54
|
+
buildDomSelectionFromTarget: async (element) => selectionOf(element),
|
|
55
|
+
refreshPreviewDocumentVersion: () => {},
|
|
56
|
+
syncPreviewHotkeys: () => {},
|
|
57
|
+
applyStudioManualEditsToPreviewRef: { current: async () => {} },
|
|
58
|
+
});
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const live = previewWithTitle();
|
|
62
|
+
const player = mountPlayerWithPreview(live);
|
|
63
|
+
selectionRef.current = selectionOf(titleOf(live));
|
|
64
|
+
const sessionRoot = createRoot(document.body.appendChild(document.createElement("div")));
|
|
65
|
+
act(() => sessionRoot.render(React.createElement(Session, { host: live })));
|
|
66
|
+
const unmount = () => {
|
|
67
|
+
act(() => sessionRoot.unmount());
|
|
68
|
+
act(() => player.root.unmount());
|
|
69
|
+
};
|
|
70
|
+
return { player: player.getApi, selectionRef, live, unmount };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The real shadow reload: load a shadow, report it painted, and let the player promote it. */
|
|
74
|
+
async function promoteShadow(player: () => TimelinePlayerApi): Promise<HTMLIFrameElement> {
|
|
75
|
+
act(() => player().refreshPlayer());
|
|
76
|
+
const shadow = previewWithTitle("?_t=1");
|
|
77
|
+
await paintShadow(player, shadow);
|
|
78
|
+
expect(player().iframeRef.current).toBe(shadow);
|
|
79
|
+
return shadow;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("a shadow reload promoted without a host re-render", () => {
|
|
83
|
+
// A host stage holds both players, each with the same #title, in either order.
|
|
84
|
+
it.each([
|
|
85
|
+
["second", true],
|
|
86
|
+
["first", false],
|
|
87
|
+
])(
|
|
88
|
+
"with the live preview %s on the stage, the selection, overlay, rotate and move land on it",
|
|
89
|
+
async (_, liveSecond) => {
|
|
90
|
+
const host = mountHost(true);
|
|
91
|
+
const shadow = await promoteShadow(host.player);
|
|
92
|
+
// The stage only orders the two players; nothing here needs their pages to load.
|
|
93
|
+
for (const frame of [host.live, shadow]) frame.removeAttribute("src");
|
|
94
|
+
const stage = document.body.appendChild(document.createElement("div"));
|
|
95
|
+
stage.append(...(liveSecond ? [host.live, shadow] : [shadow, host.live]));
|
|
96
|
+
|
|
97
|
+
const selection = host.selectionRef.current!;
|
|
98
|
+
expect(selection.element).toBe(titleOf(shadow));
|
|
99
|
+
const overlayNode = resolveElementForOverlay(shadow.contentDocument!, selection, null, {
|
|
100
|
+
current: null,
|
|
101
|
+
});
|
|
102
|
+
expect(overlayNode).toBe(selection.element);
|
|
103
|
+
|
|
104
|
+
const commitPositionPatchToHtml = vi.fn(async () => {});
|
|
105
|
+
await savePlainRotation({ commitPositionPatchToHtml }, selection, { angle: 30 });
|
|
106
|
+
await stageElementOffset(
|
|
107
|
+
{ commitPositionPatchToHtml, showToast: vi.fn() },
|
|
108
|
+
selection,
|
|
109
|
+
{ x: 10, y: 5 },
|
|
110
|
+
true,
|
|
111
|
+
).save();
|
|
112
|
+
expect(titleOf(shadow).style.getPropertyValue("rotate")).toBe("30deg");
|
|
113
|
+
expect(titleOf(shadow).style.getPropertyValue("translate")).toBe("10px 5px");
|
|
114
|
+
expect(titleOf(host.live).getAttribute("style")).toBeNull();
|
|
115
|
+
host.unmount();
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
it("left the selection on the retired node when the session kept the host's first iframe", async () => {
|
|
120
|
+
const host = mountHost(false);
|
|
121
|
+
await promoteShadow(host.player);
|
|
122
|
+
expect(host.selectionRef.current!.element).toBe(titleOf(host.live));
|
|
123
|
+
host.unmount();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("useLivePreviewIframe", () => {
|
|
128
|
+
function track(host: HTMLIFrameElement) {
|
|
129
|
+
const seen: Array<HTMLIFrameElement | null> = [];
|
|
130
|
+
function Probe({ iframe }: { iframe: HTMLIFrameElement }) {
|
|
131
|
+
seen.push(useLivePreviewIframe(iframe));
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const root = createRoot(document.createElement("div"));
|
|
135
|
+
act(() => root.render(React.createElement(Probe, { iframe: host })));
|
|
136
|
+
const rerender = (iframe: HTMLIFrameElement) =>
|
|
137
|
+
act(() => root.render(React.createElement(Probe, { iframe })));
|
|
138
|
+
return { live: () => seen.at(-1), rerender, root };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
it("follows each promotion of its own preview and ignores another preview's", () => {
|
|
142
|
+
const [a, b, c, other] = [0, 1, 2, 3].map(() => document.createElement("iframe"));
|
|
143
|
+
const probe = track(a!);
|
|
144
|
+
act(() => announcePreviewPromoted({ retired: other!, live: c! }));
|
|
145
|
+
expect(probe.live()).toBe(a);
|
|
146
|
+
act(() => announcePreviewPromoted({ retired: a!, live: b! }));
|
|
147
|
+
expect(probe.live()).toBe(b);
|
|
148
|
+
act(() => announcePreviewPromoted({ retired: b!, live: c! }));
|
|
149
|
+
expect(probe.live()).toBe(c);
|
|
150
|
+
act(() => probe.root.unmount());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("lands on the same iframe wrapped twice, as when a host wraps what it hands the session", () => {
|
|
154
|
+
const [a, b, c] = [0, 1, 2].map(() => document.createElement("iframe"));
|
|
155
|
+
let seen: { outer: HTMLIFrameElement | null; inner: HTMLIFrameElement | null } | null = null;
|
|
156
|
+
function Probe({ host }: { host: HTMLIFrameElement }) {
|
|
157
|
+
const outer = useLivePreviewIframe(host);
|
|
158
|
+
seen = { outer, inner: useLivePreviewIframe(outer) };
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const root = createRoot(document.createElement("div"));
|
|
162
|
+
act(() => root.render(React.createElement(Probe, { host: a! })));
|
|
163
|
+
act(() => announcePreviewPromoted({ retired: a!, live: b! }));
|
|
164
|
+
expect(seen).toEqual({ outer: b, inner: b });
|
|
165
|
+
act(() => announcePreviewPromoted({ retired: b!, live: c! }));
|
|
166
|
+
expect(seen).toEqual({ outer: c, inner: c });
|
|
167
|
+
act(() => root.unmount());
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("takes a new host iframe as given, and stops listening on unmount", () => {
|
|
171
|
+
const [a, b, d] = [0, 1, 2].map(() => document.createElement("iframe"));
|
|
172
|
+
const probe = track(a!);
|
|
173
|
+
act(() => announcePreviewPromoted({ retired: a!, live: b! }));
|
|
174
|
+
probe.rerender(d!);
|
|
175
|
+
expect(probe.live()).toBe(d);
|
|
176
|
+
const remove = vi.spyOn(document, "removeEventListener");
|
|
177
|
+
act(() => probe.root.unmount());
|
|
178
|
+
expect(remove).toHaveBeenCalledWith("hf-preview-promoted", expect.any(Function));
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -16,6 +16,7 @@ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
|
16
16
|
const domDelete = vi.fn(async () => undefined);
|
|
17
17
|
const historyUndo = vi.fn(async () => ({ ok: false }));
|
|
18
18
|
const historyRedo = vi.fn(async () => ({ ok: false }));
|
|
19
|
+
const settle = vi.fn(async () => undefined);
|
|
19
20
|
let root: Root | null = null;
|
|
20
21
|
let sync: ((iframe: HTMLIFrameElement | null) => void) | null = null;
|
|
21
22
|
|
|
@@ -49,7 +50,7 @@ function Harness() {
|
|
|
49
50
|
writeProjectFile: vi.fn(async () => undefined),
|
|
50
51
|
showToast: vi.fn(),
|
|
51
52
|
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
52
|
-
|
|
53
|
+
settlePendingEdits: settle,
|
|
53
54
|
handleCopy: vi.fn(() => false),
|
|
54
55
|
handlePaste: vi.fn(() => false),
|
|
55
56
|
handleCut: vi.fn(() => false),
|
|
@@ -69,6 +70,8 @@ afterEach(() => {
|
|
|
69
70
|
domDelete.mockClear();
|
|
70
71
|
historyUndo.mockClear();
|
|
71
72
|
historyRedo.mockClear();
|
|
73
|
+
settle.mockReset();
|
|
74
|
+
settle.mockImplementation(async () => undefined);
|
|
72
75
|
vi.mocked(trackStudioEvent).mockClear();
|
|
73
76
|
});
|
|
74
77
|
|
|
@@ -122,6 +125,18 @@ describe("preview iframe hotkey forwarding", () => {
|
|
|
122
125
|
expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "undo" });
|
|
123
126
|
});
|
|
124
127
|
|
|
128
|
+
it("settles pending edits, failed ones included, before Cmd+Z steps the history", async () => {
|
|
129
|
+
let settled!: () => void;
|
|
130
|
+
settle.mockImplementation(
|
|
131
|
+
() => new Promise<undefined>((resolve) => (settled = () => resolve(undefined))),
|
|
132
|
+
);
|
|
133
|
+
await press(mountWithPreview(), { key: "z", ctrlKey: true });
|
|
134
|
+
expect(settle).toHaveBeenCalledTimes(1);
|
|
135
|
+
expect(historyUndo).not.toHaveBeenCalled();
|
|
136
|
+
await act(async () => settled());
|
|
137
|
+
expect(historyUndo).toHaveBeenCalledTimes(1);
|
|
138
|
+
});
|
|
139
|
+
|
|
125
140
|
it.each([
|
|
126
141
|
["Shift+Cmd+Z", { key: "Z", metaKey: true, shiftKey: true }],
|
|
127
142
|
["Ctrl+Y", { key: "y", ctrlKey: true }],
|
|
@@ -60,6 +60,55 @@ afterEach(() => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe("keys on a focused slider", () => {
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
document.body.innerHTML = "";
|
|
66
|
+
});
|
|
67
|
+
const slider = () => {
|
|
68
|
+
const el = document.createElement("div");
|
|
69
|
+
el.setAttribute("role", "slider");
|
|
70
|
+
document.body.append(el);
|
|
71
|
+
return el;
|
|
72
|
+
};
|
|
73
|
+
const from = (target: HTMLElement, init: KeyboardEventInit) => {
|
|
74
|
+
const event = new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true });
|
|
75
|
+
Object.defineProperty(event, "target", { value: target });
|
|
76
|
+
return event;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
it("leaves Delete to the slider instead of deleting the selected clip", () => {
|
|
80
|
+
usePlayerStore.setState({
|
|
81
|
+
elements: [bgmElement],
|
|
82
|
+
selectedElementId: "bgm",
|
|
83
|
+
selectedElementIds: new Set(["bgm"]),
|
|
84
|
+
});
|
|
85
|
+
const cb = callbacks();
|
|
86
|
+
dispatchPlainKey(from(slider(), { key: "Delete" }), "delete", cb);
|
|
87
|
+
expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
|
|
88
|
+
expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it.each(["video", "audio"])("leaves Delete to a focused <%s controls>", (tag) => {
|
|
92
|
+
usePlayerStore.setState({
|
|
93
|
+
elements: [bgmElement],
|
|
94
|
+
selectedElementId: "bgm",
|
|
95
|
+
selectedElementIds: new Set(["bgm"]),
|
|
96
|
+
});
|
|
97
|
+
const player = document.body.appendChild(document.createElement(tag));
|
|
98
|
+
player.setAttribute("controls", "");
|
|
99
|
+
const cb = callbacks();
|
|
100
|
+
dispatchPlainKey(from(player, { key: "Delete" }), "delete", cb);
|
|
101
|
+
expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
|
|
102
|
+
expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("still undoes with Cmd+Z while a slider has focus", () => {
|
|
106
|
+
const cb = callbacks();
|
|
107
|
+
dispatchModifierKey(from(slider(), { key: "z", metaKey: true }), "z", cb);
|
|
108
|
+
expect(cb.handleUndo).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
63
112
|
describe("dispatchPlainKey — select leftward / rightward", () => {
|
|
64
113
|
const clips = [
|
|
65
114
|
{ ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
|
|
@@ -86,7 +86,7 @@ function Harness() {
|
|
|
86
86
|
writeProjectFile: vi.fn(async () => undefined),
|
|
87
87
|
showToast: vi.fn(),
|
|
88
88
|
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
89
|
-
|
|
89
|
+
settlePendingEdits: vi.fn(async () => undefined),
|
|
90
90
|
handleCopy: vi.fn(() => false),
|
|
91
91
|
handlePaste: vi.fn(async () => undefined),
|
|
92
92
|
handleCut: vi.fn(async () => false),
|
|
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react";
|
|
|
2
2
|
import { usePlayerStore } from "../player";
|
|
3
3
|
import type { TimelineElement } from "../player";
|
|
4
4
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
|
-
import { isTypingTarget } from "../utils/typingTarget";
|
|
6
5
|
import { useCaptionStore } from "../captions/store";
|
|
7
6
|
import {
|
|
8
7
|
applyCaptionModelToIframe,
|
|
@@ -86,7 +85,7 @@ interface UseAppHotkeysParams {
|
|
|
86
85
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
87
86
|
syncHistoryPreviewAfterApply: UseEditHistoryActionsOptions["syncHistoryPreviewAfterApply"];
|
|
88
87
|
showHistoryRestoreNow?: UseEditHistoryActionsOptions["showHistoryRestoreNow"];
|
|
89
|
-
|
|
88
|
+
settlePendingEdits: () => Promise<void>;
|
|
90
89
|
handleCopy: () => boolean;
|
|
91
90
|
handlePaste: () => Promise<void>;
|
|
92
91
|
handleCut: () => Promise<boolean>;
|
|
@@ -125,7 +124,7 @@ export function useAppHotkeys({
|
|
|
125
124
|
showToast,
|
|
126
125
|
syncHistoryPreviewAfterApply,
|
|
127
126
|
showHistoryRestoreNow,
|
|
128
|
-
|
|
127
|
+
settlePendingEdits,
|
|
129
128
|
handleCopy,
|
|
130
129
|
handlePaste,
|
|
131
130
|
handleCut,
|
|
@@ -152,7 +151,7 @@ export function useAppHotkeys({
|
|
|
152
151
|
showToast,
|
|
153
152
|
syncHistoryPreviewAfterApply,
|
|
154
153
|
showHistoryRestoreNow,
|
|
155
|
-
waitForPendingDomEditSaves,
|
|
154
|
+
waitForPendingDomEditSaves: settlePendingEdits,
|
|
156
155
|
onAfterUndoRedo,
|
|
157
156
|
activeCompPath,
|
|
158
157
|
forceReloadSdkSession,
|
|
@@ -220,7 +219,7 @@ export function useAppHotkeys({
|
|
|
220
219
|
dispatchModifierKey(event, key, cb);
|
|
221
220
|
return;
|
|
222
221
|
}
|
|
223
|
-
|
|
222
|
+
dispatchPlainKey(event, key, cb);
|
|
224
223
|
}, []);
|
|
225
224
|
|
|
226
225
|
// eslint-disable-next-line no-restricted-syntax
|
|
@@ -216,6 +216,23 @@ describe("useAutomationSelectionKeyboard", () => {
|
|
|
216
216
|
input.remove();
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
+
describe("with a focused picker", () => {
|
|
220
|
+
const picker = document.createElement("button");
|
|
221
|
+
picker.setAttribute("role", "combobox");
|
|
222
|
+
afterEach(() => picker.remove());
|
|
223
|
+
|
|
224
|
+
it("is inert while a picker that owns typing has focus", () => {
|
|
225
|
+
usePlayerStore
|
|
226
|
+
.getState()
|
|
227
|
+
.setAutomationSelection(wholeAxis({ elementKey: "bgm", target: "volume", t0: 1, t1: 3 }));
|
|
228
|
+
const { onCommit } = setup({});
|
|
229
|
+
document.body.append(picker);
|
|
230
|
+
picker.focus();
|
|
231
|
+
key("Delete");
|
|
232
|
+
expect(onCommit).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
219
236
|
it("Cmd+C copies the active selection", () => {
|
|
220
237
|
clearAutomationClipboard();
|
|
221
238
|
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: "bgm" });
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type HfAutomationLane,
|
|
32
32
|
} from "@hyperframes/core/audio-automation";
|
|
33
33
|
import { clampNumber } from "../utils/studioHelpers";
|
|
34
|
+
import { isTypingTarget } from "../utils/typingTarget";
|
|
34
35
|
import type { AutomationSelection } from "../player/store/automationSelectionSlice";
|
|
35
36
|
import type {
|
|
36
37
|
AutomationLaneBinding,
|
|
@@ -39,13 +40,6 @@ import type {
|
|
|
39
40
|
|
|
40
41
|
type PlayerState = ReturnType<typeof usePlayerStore.getState>;
|
|
41
42
|
|
|
42
|
-
function isTextInput(el: Element | null): boolean {
|
|
43
|
-
if (!el) return false;
|
|
44
|
-
const tag = el.tagName;
|
|
45
|
-
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
46
|
-
return el instanceof HTMLElement && el.isContentEditable;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
43
|
/**
|
|
50
44
|
* A Cmd/Ctrl+<letter> chord. `e.key` is normalised because CapsLock makes it
|
|
51
45
|
* "V"/"C", and useAppHotkeys already lowercases — a raw `e.key === "v"` test
|
|
@@ -302,7 +296,7 @@ function handleCopy(
|
|
|
302
296
|
* dead key beats today's double write.
|
|
303
297
|
*/
|
|
304
298
|
export function automationOwnsKey(e: KeyboardEvent): boolean {
|
|
305
|
-
if (
|
|
299
|
+
if (isTypingTarget(document.activeElement)) return false;
|
|
306
300
|
const state = usePlayerStore.getState();
|
|
307
301
|
if (isChord(e, "c")) return state.automationSelection !== null;
|
|
308
302
|
if (!isChord(e, "v")) return false;
|
|
@@ -340,7 +334,7 @@ export function useAutomationSelectionKeyboard({
|
|
|
340
334
|
}): void {
|
|
341
335
|
useEffect(() => {
|
|
342
336
|
const handler = (e: KeyboardEvent): void => {
|
|
343
|
-
if (
|
|
337
|
+
if (isTypingTarget(document.activeElement)) return;
|
|
344
338
|
// Somebody upstream already claimed this key. useAppHotkeys is on
|
|
345
339
|
// window/capture so it always runs first, and it deliberately lets a
|
|
346
340
|
// keyframe selection outrank an automation range on Delete — without
|
|
@@ -98,6 +98,7 @@ function mountClipboard(
|
|
|
98
98
|
sub = SUB,
|
|
99
99
|
bundle: (host: Element) => void = () => {},
|
|
100
100
|
saved = SAVED,
|
|
101
|
+
view = { path: "index.html" },
|
|
101
102
|
) {
|
|
102
103
|
const files: Record<string, string> = { "index.html": saved, "compositions/sub.html": sub };
|
|
103
104
|
const fail = { on: false };
|
|
@@ -115,7 +116,7 @@ function mountClipboard(
|
|
|
115
116
|
function Harness() {
|
|
116
117
|
api = useClipboard({
|
|
117
118
|
projectId: "p",
|
|
118
|
-
activeCompPath:
|
|
119
|
+
activeCompPath: view.path,
|
|
119
120
|
domEditSelectionRef: domSelectionRef,
|
|
120
121
|
showToast: () => {},
|
|
121
122
|
writeProjectFile,
|
|
@@ -134,7 +135,9 @@ function mountClipboard(
|
|
|
134
135
|
root = createRoot(document.createElement("div"));
|
|
135
136
|
act(() => root?.render(React.createElement(Harness)));
|
|
136
137
|
const clipboard = () => api as ReturnType<typeof useClipboard>;
|
|
138
|
+
const rerender = () => act(() => root?.render(React.createElement(Harness)));
|
|
137
139
|
return {
|
|
140
|
+
rerender,
|
|
138
141
|
clipboard,
|
|
139
142
|
writes,
|
|
140
143
|
deleted,
|
|
@@ -253,6 +256,17 @@ const SUB_SELECTION = {
|
|
|
253
256
|
sourceFile: "compositions/sub.html",
|
|
254
257
|
} as DomEditSelection;
|
|
255
258
|
|
|
259
|
+
const copySubNext = ({ clipboard, domSelectionRef }: ReturnType<typeof mountClipboard>) => {
|
|
260
|
+
clearSelection();
|
|
261
|
+
domSelectionRef.current = SUB_SELECTION;
|
|
262
|
+
clipboard().handleCopy();
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const expectSubPastedAlone = (writes: string[]) => {
|
|
266
|
+
expect(writes[0]).toContain(">Sub</h2>");
|
|
267
|
+
expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
|
|
268
|
+
};
|
|
269
|
+
|
|
256
270
|
describe("paste of an element styled by its id", () => {
|
|
257
271
|
it("gives the copy the look its original has", async () => {
|
|
258
272
|
clearSelection();
|
|
@@ -339,30 +353,24 @@ describe("copy order", () => {
|
|
|
339
353
|
|
|
340
354
|
it("pastes the second of two copies", async () => {
|
|
341
355
|
selectTitle();
|
|
342
|
-
const
|
|
343
|
-
clipboard().handleCopy();
|
|
356
|
+
const view = mountClipboard();
|
|
357
|
+
view.clipboard().handleCopy();
|
|
344
358
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
await clipboard().handlePaste();
|
|
349
|
-
expect(writes[0]).toContain(">Sub</h2>");
|
|
350
|
-
expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
|
|
359
|
+
copySubNext(view);
|
|
360
|
+
await view.clipboard().handlePaste();
|
|
361
|
+
expectSubPastedAlone(view.writes);
|
|
351
362
|
});
|
|
352
363
|
|
|
353
364
|
it("pastes the later copy even when the earlier copy's read lands last", async () => {
|
|
354
365
|
selectTitle();
|
|
355
|
-
const
|
|
356
|
-
delayMs["index.html"] = 20;
|
|
357
|
-
clipboard().handleCopy();
|
|
358
|
-
|
|
359
|
-
domSelectionRef.current = SUB_SELECTION;
|
|
360
|
-
clipboard().handleCopy();
|
|
366
|
+
const view = mountClipboard();
|
|
367
|
+
view.delayMs["index.html"] = 20;
|
|
368
|
+
view.clipboard().handleCopy();
|
|
369
|
+
copySubNext(view);
|
|
361
370
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
362
|
-
delayMs["index.html"] = 0;
|
|
363
|
-
await clipboard().handlePaste();
|
|
364
|
-
|
|
365
|
-
expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
|
|
371
|
+
view.delayMs["index.html"] = 0;
|
|
372
|
+
await view.clipboard().handlePaste();
|
|
373
|
+
expectSubPastedAlone(view.writes);
|
|
366
374
|
});
|
|
367
375
|
|
|
368
376
|
it("duplicates a hidden clip with its saved markup", async () => {
|
|
@@ -374,6 +382,78 @@ describe("copy order", () => {
|
|
|
374
382
|
});
|
|
375
383
|
});
|
|
376
384
|
|
|
385
|
+
describe("a copy in place, as the canvas makes one", () => {
|
|
386
|
+
// The copy's start and track: the saved original has no data-hf-id, its copy is minted one.
|
|
387
|
+
const copyAt = (html: string | undefined) => {
|
|
388
|
+
const copy = html?.match(/<h1[^>]*data-hf-id[^>]*>Title<\/h1>/)?.[0] ?? "";
|
|
389
|
+
return [copy.match(/data-start="([^"]*)"/)?.[1], copy.match(/data-track-index="([^"]*)"/)?.[1]];
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
it("duplicates at the clip's own time, on the next free track", async () => {
|
|
393
|
+
selectTitle();
|
|
394
|
+
const { clipboard, writes } = mountClipboard();
|
|
395
|
+
await clipboard().handleDuplicate({ inPlace: true });
|
|
396
|
+
expect(copyAt(writes[0])).toEqual(["2", "1"]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("pastes at the copied clip's time, not the playhead's", async () => {
|
|
400
|
+
selectTitle();
|
|
401
|
+
const { clipboard, writes } = mountClipboard();
|
|
402
|
+
clipboard().handleCopy();
|
|
403
|
+
await clipboard().handlePaste({ inPlace: true });
|
|
404
|
+
expect(copyAt(writes[0])).toEqual(["2", "1"]);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Host at 2 s: the clip's local 1-3 s shows as a 3-5 s master row, the clock the film's own timeline has.
|
|
408
|
+
const SUB_CLIP: TimelineElement = {
|
|
409
|
+
id: SUB_HF_ID,
|
|
410
|
+
hfId: SUB_HF_ID,
|
|
411
|
+
tag: "h2",
|
|
412
|
+
start: 3,
|
|
413
|
+
duration: 2,
|
|
414
|
+
track: 0,
|
|
415
|
+
authoredTrack: 0,
|
|
416
|
+
sourceFile: "compositions/sub.html",
|
|
417
|
+
parentCompositionStart: 2,
|
|
418
|
+
};
|
|
419
|
+
const pastedSubStarts = (html: string | undefined) =>
|
|
420
|
+
[...(html ?? "").matchAll(/<h2[^>]*data-hf-id[^>]*data-start="([^"]+)"/g)].map((m) => m[1]);
|
|
421
|
+
const mountSubClip = (currentTime = 0, view = { path: "index.html" }) => {
|
|
422
|
+
usePlayerStore.setState({
|
|
423
|
+
elements: [TITLE, SUB_CLIP],
|
|
424
|
+
selectedElementId: SUB_CLIP.id,
|
|
425
|
+
selectedElementIds: new Set([SUB_CLIP.id]),
|
|
426
|
+
currentTime,
|
|
427
|
+
});
|
|
428
|
+
return mountClipboard(null, SUB, (host) => host.setAttribute("data-start", "2"), SAVED, view);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
it("pastes a sub-composition's clip into the film at the moment the film shows it", async () => {
|
|
432
|
+
const { clipboard, writes } = mountSubClip(7);
|
|
433
|
+
clipboard().handleCopy();
|
|
434
|
+
await clipboard().handlePaste({ inPlace: true });
|
|
435
|
+
expect(pastedSubStarts(writes[0])).toEqual(["3"]);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("duplicates a sub-composition's clip at its own local time, on another track", async () => {
|
|
439
|
+
const { clipboard, writes } = mountSubClip();
|
|
440
|
+
await clipboard().handleDuplicate({ inPlace: true });
|
|
441
|
+
const copy = /<h2[^>]*data-hf-id[^>]*>/.exec(writes[0] ?? "")?.[0] ?? "";
|
|
442
|
+
expect(copy).toContain('data-start="1"');
|
|
443
|
+
expect(copy).toContain('data-track-index="1"');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("pastes at the playhead once the edited composition is another, whose clock differs", async () => {
|
|
447
|
+
const view = { path: "index.html" };
|
|
448
|
+
const { clipboard, writes, rerender } = mountSubClip(0.5, view);
|
|
449
|
+
clipboard().handleCopy();
|
|
450
|
+
view.path = "compositions/sub.html";
|
|
451
|
+
rerender();
|
|
452
|
+
await clipboard().handlePaste({ inPlace: true });
|
|
453
|
+
expect(pastedSubStarts(writes[0])).toEqual(["0.5"]);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
377
457
|
describe("duplicate of a clip inside a sub-composition", () => {
|
|
378
458
|
it("writes the copy at local time right after the original", async () => {
|
|
379
459
|
// Host at 2 s, so the clip's local 1-3 s shows as a 3-5 s master row.
|