@hyperframes/studio 0.8.55 → 0.8.57
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-c23wnIfc.js +464 -0
- package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
- package/dist/index.d.ts +111 -17
- package/dist/index.html +2 -2
- package/dist/index.js +21534 -20683
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +18 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +62 -51
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -55
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
- package/src/components/editor/PropertyPanel.tsx +4 -3
- package/src/components/editor/SnapToolbar.test.tsx +26 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
- package/src/components/editor/previewReadOnlyContext.tsx +59 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
- package/src/components/editor/useDomEditNudge.ts +9 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
- package/src/components/editor/useOffCanvasIndicators.ts +50 -0
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +40 -4
- package/src/components/nle/NLEPreview.test.ts +66 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +19 -24
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +129 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
- package/src/components/nle/usePreviewBlockDrop.ts +3 -1
- package/src/hooks/appHotkeysDispatch.ts +16 -1
- package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
- package/src/hooks/timelineAudioGroupCreate.ts +3 -4
- package/src/hooks/timelineTrackVisibility.ts +7 -8
- package/src/hooks/useAppHotkeys.test.ts +71 -3
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -0
- package/src/hooks/useCompositionDimensions.test.tsx +38 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useDomEditCommits.test.tsx +1 -0
- package/src/hooks/useDomEditCommits.ts +3 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useDomEditWiring.ts +40 -40
- package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
- package/src/hooks/useDomGeometryCommits.ts +26 -20
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useGestureCommit.test.tsx +29 -0
- package/src/hooks/useGestureCommit.ts +23 -0
- package/src/hooks/useInlineTextEdit.test.tsx +23 -2
- package/src/hooks/useInlineTextEdit.ts +11 -2
- package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
- package/src/hooks/useSdkSession.ts +46 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
- package/src/hooks/useTimelineAssetDropOps.ts +1 -6
- package/src/hooks/useTimelineEditing.test.tsx +10 -7
- package/src/hooks/useTimelineEditing.ts +2 -7
- package/src/index.ts +12 -0
- package/src/player/components/Player.test.ts +141 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -573
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +34 -30
- package/src/player/components/timelineTrackDisplay.ts +1 -4
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
- package/src/player/store/playerStore.ts +0 -4
- package/src/player/store/timelineElement.ts +2 -4
- package/src/player/store/timelineResetState.ts +0 -1
- package/src/styles/hex-baseline.json +3 -4
- package/src/styles/studio.css +7 -7
- package/src/styles/theme.css +29 -0
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- package/src/player/hooks/useTimelineRowElements.ts +0 -17
|
@@ -54,6 +54,11 @@ export interface HotkeyCallbacks {
|
|
|
54
54
|
onUngroupSelection?: () => void;
|
|
55
55
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
56
56
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
57
|
+
readOnlyPreview: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function timelineOwnsKey(event: KeyboardEvent): boolean {
|
|
61
|
+
return event.target instanceof Element && event.target.closest("[data-studio-timeline]") !== null;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/** Exported for tests, like dispatchPlainKey below: lets the Cmd+C/Cmd+V
|
|
@@ -95,6 +100,7 @@ export function dispatchModifierKey(
|
|
|
95
100
|
|
|
96
101
|
if (key === "g" && !event.altKey && !isTypingTarget(event.target)) {
|
|
97
102
|
event.preventDefault();
|
|
103
|
+
if (cb.readOnlyPreview) return true;
|
|
98
104
|
if (event.shiftKey) cb.onUngroupSelection?.();
|
|
99
105
|
else cb.onGroupSelection?.();
|
|
100
106
|
return true;
|
|
@@ -113,6 +119,12 @@ export function dispatchModifierKey(
|
|
|
113
119
|
}
|
|
114
120
|
return true;
|
|
115
121
|
}
|
|
122
|
+
const previewOwnsMutation =
|
|
123
|
+
cb.readOnlyPreview && cb.domEditSelectionRef.current !== null && !timelineOwnsKey(event);
|
|
124
|
+
if (previewOwnsMutation && ["v", "x", "d"].includes(key)) {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
116
128
|
if (key === "v") {
|
|
117
129
|
event.preventDefault();
|
|
118
130
|
trackStudioEvent("keyboard_shortcut", { action: "paste" });
|
|
@@ -158,6 +170,7 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
|
|
158
170
|
// Reserve bare `s` for Split even when the current selection cannot split,
|
|
159
171
|
// so secondary listeners do not reinterpret the same key as Snap toggle.
|
|
160
172
|
event.preventDefault();
|
|
173
|
+
if (cb.readOnlyPreview) return;
|
|
161
174
|
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
|
162
175
|
if (selectedElementId) {
|
|
163
176
|
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
|
|
@@ -240,8 +253,10 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
|
|
|
240
253
|
// the timeline left other selected elements behind. Timeline stays as the
|
|
241
254
|
// fallback for rows with no canvas node (audio, an inactive comp).
|
|
242
255
|
const domSel = cb.domEditSelectionRef.current;
|
|
243
|
-
|
|
256
|
+
const timelineOwnsDelete = timelineOwnsKey(event);
|
|
257
|
+
if (domSel && !timelineOwnsDelete) {
|
|
244
258
|
event.preventDefault();
|
|
259
|
+
if (cb.readOnlyPreview) return;
|
|
245
260
|
// The whole marquee group, not just the primary the ref holds.
|
|
246
261
|
void cb.handleDomEditElementDelete(domSel, { expandGroup: true });
|
|
247
262
|
return;
|
|
@@ -196,6 +196,7 @@ function renderGestureCommit(
|
|
|
196
196
|
previewIframeRef: { current: document.createElement("iframe") },
|
|
197
197
|
showToast: vi.fn(),
|
|
198
198
|
isGestureRecordingRef: { current: false },
|
|
199
|
+
readOnlyPreview: false,
|
|
199
200
|
});
|
|
200
201
|
toggle = handleToggleRecording;
|
|
201
202
|
return null;
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
|
|
9
9
|
import { useCallback } from "react";
|
|
10
10
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
11
|
-
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
12
11
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
13
12
|
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
|
|
14
13
|
import { runtimeAudioId } from "../player/lib/timelineElementHelpers";
|
|
@@ -286,7 +285,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
286
285
|
groupId: string,
|
|
287
286
|
groupLabel?: string,
|
|
288
287
|
) => Promise<void> {
|
|
289
|
-
const
|
|
288
|
+
const timelineElements = usePlayerStore((state) => state.elements);
|
|
290
289
|
return useCallback(
|
|
291
290
|
async (clipIds: readonly string[], groupId: string, groupLabel?: string) => {
|
|
292
291
|
if (isRecordingRef?.current) {
|
|
@@ -299,7 +298,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
299
298
|
// timeline's group-pointer button) name clips the way the document does,
|
|
300
299
|
// because that is the only space `resolveAudioGroups` reads back.
|
|
301
300
|
const wanted = new Set(clipIds);
|
|
302
|
-
const elements =
|
|
301
|
+
const elements = timelineElements.filter((item) => {
|
|
303
302
|
const domId = runtimeAudioId(item);
|
|
304
303
|
return domId !== null && wanted.has(domId);
|
|
305
304
|
});
|
|
@@ -340,7 +339,7 @@ export function useAudioGroupCarveAssignment({
|
|
|
340
339
|
},
|
|
341
340
|
[
|
|
342
341
|
activeCompPath,
|
|
343
|
-
|
|
342
|
+
timelineElements,
|
|
344
343
|
previewIframeRef,
|
|
345
344
|
writeProjectFile,
|
|
346
345
|
recordEdit,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../player";
|
|
3
3
|
import { reseekPreviewAtTime } from "../player/hooks/timelineSyncHydration";
|
|
4
|
-
import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
|
|
5
4
|
import { applySoftReloadFinalization } from "../utils/gsapSoftReload";
|
|
6
5
|
import {
|
|
7
6
|
timelineTrackOrder,
|
|
@@ -295,7 +294,7 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
295
294
|
// virtual sub-comp children carry their own (display.track + idx) track numbers,
|
|
296
295
|
// so filtering the raw store list by a virtual track number would hide the wrong
|
|
297
296
|
// outer-scene sibling sharing that index.
|
|
298
|
-
const
|
|
297
|
+
const timelineElements = usePlayerStore((state) => state.elements);
|
|
299
298
|
return useCallback(
|
|
300
299
|
async (track: number, hidden: boolean, displayNumber?: number | null) => {
|
|
301
300
|
if (isRecordingRef?.current) {
|
|
@@ -308,7 +307,7 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
308
307
|
await toggleTimelineTrackHidden({
|
|
309
308
|
projectId: pid,
|
|
310
309
|
activeCompPath,
|
|
311
|
-
timelineElements
|
|
310
|
+
timelineElements,
|
|
312
311
|
track,
|
|
313
312
|
hidden,
|
|
314
313
|
displayNumber,
|
|
@@ -327,7 +326,7 @@ export function useTimelineTrackVisibilityEditing({
|
|
|
327
326
|
},
|
|
328
327
|
[
|
|
329
328
|
activeCompPath,
|
|
330
|
-
|
|
329
|
+
timelineElements,
|
|
331
330
|
previewIframeRef,
|
|
332
331
|
writeProjectFile,
|
|
333
332
|
recordEdit,
|
|
@@ -354,7 +353,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
354
353
|
elementKey: string | readonly string[],
|
|
355
354
|
hidden: boolean,
|
|
356
355
|
) => Promise<void> {
|
|
357
|
-
const
|
|
356
|
+
const timelineElements = usePlayerStore((state) => state.elements);
|
|
358
357
|
return useCallback(
|
|
359
358
|
async (elementKey: string | readonly string[], hidden: boolean) => {
|
|
360
359
|
if (isRecordingRef?.current) {
|
|
@@ -364,7 +363,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
364
363
|
const pid = projectIdRef.current;
|
|
365
364
|
if (!pid) return;
|
|
366
365
|
const keys = typeof elementKey === "string" ? [elementKey] : elementKey;
|
|
367
|
-
if (!
|
|
366
|
+
if (!timelineElements.some((item) => keys.includes(item.key ?? item.id))) {
|
|
368
367
|
showToast("This element is inside a sub-composition and has no timeline row to hide.");
|
|
369
368
|
return;
|
|
370
369
|
}
|
|
@@ -372,7 +371,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
372
371
|
await toggleTimelineElementHidden({
|
|
373
372
|
projectId: pid,
|
|
374
373
|
activeCompPath,
|
|
375
|
-
timelineElements
|
|
374
|
+
timelineElements,
|
|
376
375
|
elementKey,
|
|
377
376
|
hidden,
|
|
378
377
|
previewIframe: previewIframeRef.current,
|
|
@@ -390,7 +389,7 @@ export function useTimelineElementVisibilityEditing({
|
|
|
390
389
|
},
|
|
391
390
|
[
|
|
392
391
|
activeCompPath,
|
|
393
|
-
|
|
392
|
+
timelineElements,
|
|
394
393
|
previewIframeRef,
|
|
395
394
|
writeProjectFile,
|
|
396
395
|
recordEdit,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { dispatchModifierKey, dispatchPlainKey } from "./appHotkeysDispatch";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { dispatchModifierKey, dispatchPlainKey, type HotkeyCallbacks } from "./appHotkeysDispatch";
|
|
4
4
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
5
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
5
6
|
import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
|
|
6
7
|
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
|
|
7
8
|
import type { TimelineElement } from "../player/store/timelineElement";
|
|
@@ -19,7 +20,7 @@ const bgmElement: TimelineElement = {
|
|
|
19
20
|
/** Every callback dispatchPlainKey can reach, so a test can assert which one
|
|
20
21
|
* a key resolved to. Unannotated on purpose: the parameter type is not
|
|
21
22
|
* exported, and structural inference checks it at the call site. */
|
|
22
|
-
function callbacks() {
|
|
23
|
+
function callbacks(overrides: Partial<HotkeyCallbacks> = {}) {
|
|
23
24
|
return {
|
|
24
25
|
handleTimelineElementDelete: vi.fn(async () => {}),
|
|
25
26
|
handleTimelineElementsDelete: vi.fn(async () => {}),
|
|
@@ -30,10 +31,15 @@ function callbacks() {
|
|
|
30
31
|
handleCopy: vi.fn(() => false),
|
|
31
32
|
handlePaste: vi.fn(async () => {}),
|
|
32
33
|
handleCut: vi.fn(async () => false),
|
|
34
|
+
handleDuplicate: vi.fn(async () => false),
|
|
35
|
+
onGroupSelection: vi.fn(),
|
|
36
|
+
onUngroupSelection: vi.fn(),
|
|
33
37
|
onResetKeyframes: vi.fn(() => true),
|
|
34
38
|
onDeleteSelectedKeyframes: vi.fn(),
|
|
35
39
|
showToast: vi.fn(),
|
|
36
40
|
domEditSelectionRef: { current: null },
|
|
41
|
+
readOnlyPreview: false,
|
|
42
|
+
...overrides,
|
|
37
43
|
};
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -298,3 +304,65 @@ describe('dispatchPlainKey — "A" returns to select while the razor is armed',
|
|
|
298
304
|
expect(e.defaultPrevented).toBe(false);
|
|
299
305
|
});
|
|
300
306
|
});
|
|
307
|
+
|
|
308
|
+
describe("hotkeys with the preview read-only", () => {
|
|
309
|
+
beforeEach(() => {
|
|
310
|
+
usePlayerStore.setState({ elements: [bgmElement], selectedElementId: null });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("does not delete the selected element on Delete", () => {
|
|
314
|
+
const cb = callbacks({ readOnlyPreview: true });
|
|
315
|
+
cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
|
|
316
|
+
dispatchPlainKey(press("Delete"), "delete", cb);
|
|
317
|
+
expect(cb.handleDomEditElementDelete).not.toHaveBeenCalled();
|
|
318
|
+
expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("does not split on s", () => {
|
|
322
|
+
usePlayerStore.setState({
|
|
323
|
+
currentTime: 3,
|
|
324
|
+
elements: [{ ...bgmElement, hfId: "hf-bgm" }],
|
|
325
|
+
selectedElementId: "bgm",
|
|
326
|
+
});
|
|
327
|
+
const cb = callbacks({ readOnlyPreview: true });
|
|
328
|
+
dispatchPlainKey(press("s"), "s", cb);
|
|
329
|
+
expect(cb.handleTimelineElementSplit).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it.each([
|
|
333
|
+
["x", "handleCut"],
|
|
334
|
+
["d", "handleDuplicate"],
|
|
335
|
+
["v", "handlePaste"],
|
|
336
|
+
["g", "onGroupSelection"],
|
|
337
|
+
] as const)("does not run %s", (key, callback) => {
|
|
338
|
+
const cb = callbacks({ readOnlyPreview: true });
|
|
339
|
+
cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
|
|
340
|
+
dispatchModifierKey(chord(key), key, cb);
|
|
341
|
+
expect(cb[callback]).not.toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("still undoes, because history covers timeline edits", () => {
|
|
345
|
+
const cb = callbacks({ readOnlyPreview: true });
|
|
346
|
+
dispatchModifierKey(chord("z"), "z", cb);
|
|
347
|
+
expect(cb.handleUndo).toHaveBeenCalledTimes(1);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("keeps timeline paste when the mirrored preview selection is not the owner", () => {
|
|
351
|
+
usePlayerStore.setState({ selectedElementId: "bgm" });
|
|
352
|
+
const cb = callbacks({ readOnlyPreview: true });
|
|
353
|
+
cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
|
|
354
|
+
const event = chord("v");
|
|
355
|
+
const timeline = document.createElement("div");
|
|
356
|
+
timeline.dataset.studioTimeline = "true";
|
|
357
|
+
Object.defineProperty(event, "target", { value: timeline });
|
|
358
|
+
dispatchModifierKey(event, "v", cb);
|
|
359
|
+
expect(cb.handlePaste).toHaveBeenCalledTimes(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("control: with the flag off Delete removes the selected element", () => {
|
|
363
|
+
const cb = callbacks();
|
|
364
|
+
cb.domEditSelectionRef.current = { id: "card" } as DomEditSelection;
|
|
365
|
+
dispatchPlainKey(press("Delete"), "delete", cb);
|
|
366
|
+
expect(cb.handleDomEditElementDelete).toHaveBeenCalledTimes(1);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -101,6 +101,8 @@ interface UseAppHotkeysParams {
|
|
|
101
101
|
onUngroupSelection?: () => void;
|
|
102
102
|
/** Active composition path — used to decide whether undo/redo must resync the SDK session. */
|
|
103
103
|
activeCompPath?: string | null;
|
|
104
|
+
/** Clicks still select and report; the preview cannot move, edit or delete anything. */
|
|
105
|
+
readOnlyPreview: boolean;
|
|
104
106
|
/**
|
|
105
107
|
* Force-reload the SDK session after undo/redo reverts the active comp file,
|
|
106
108
|
* bypassing the self-write suppress window. Without this, the suppress window
|
|
@@ -135,6 +137,7 @@ export function useAppHotkeys({
|
|
|
135
137
|
onUngroupSelection,
|
|
136
138
|
activeCompPath,
|
|
137
139
|
forceReloadSdkSession,
|
|
140
|
+
readOnlyPreview,
|
|
138
141
|
}: UseAppHotkeysParams) {
|
|
139
142
|
const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
|
|
140
143
|
|
|
@@ -203,6 +206,7 @@ export function useAppHotkeys({
|
|
|
203
206
|
onUngroupSelection,
|
|
204
207
|
domEditSelectionRef,
|
|
205
208
|
showToast,
|
|
209
|
+
readOnlyPreview,
|
|
206
210
|
};
|
|
207
211
|
|
|
208
212
|
// ── Keydown dispatch ──
|
|
@@ -3,6 +3,7 @@ import { act } from "react";
|
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { expect, it } from "vitest";
|
|
5
5
|
import { useCompositionDimensions } from "./useCompositionDimensions";
|
|
6
|
+
import { usePreviewIframeStore } from "../player/store/previewIframeStore";
|
|
6
7
|
|
|
7
8
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
8
9
|
|
|
@@ -45,3 +46,40 @@ it("accepts only the current preview frame and rejects foreign or missing sender
|
|
|
45
46
|
foreign.remove();
|
|
46
47
|
}
|
|
47
48
|
});
|
|
49
|
+
|
|
50
|
+
it("re-reads the size from the promoted iframe's document", () => {
|
|
51
|
+
const host = document.createElement("div");
|
|
52
|
+
const a = document.createElement("iframe");
|
|
53
|
+
const b = document.createElement("iframe");
|
|
54
|
+
document.body.append(host, a, b);
|
|
55
|
+
const ref = { current: a };
|
|
56
|
+
const root = createRoot(host);
|
|
57
|
+
const setRoot = (frame: HTMLIFrameElement, width: number, height: number) => {
|
|
58
|
+
const el = frame.contentDocument!.createElement("div");
|
|
59
|
+
el.setAttribute("data-composition-id", "main");
|
|
60
|
+
el.setAttribute("data-width", String(width));
|
|
61
|
+
el.setAttribute("data-height", String(height));
|
|
62
|
+
frame.contentDocument!.body.append(el);
|
|
63
|
+
};
|
|
64
|
+
setRoot(a, 1920, 1080);
|
|
65
|
+
setRoot(b, 1080, 1920);
|
|
66
|
+
function Harness() {
|
|
67
|
+
return JSON.stringify(useCompositionDimensions(ref));
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
act(() => usePreviewIframeStore.getState().setIframe(a));
|
|
71
|
+
act(() => root.render(<Harness />));
|
|
72
|
+
expect(host.textContent).toBe('{"width":1920,"height":1080}');
|
|
73
|
+
act(() => {
|
|
74
|
+
ref.current = b;
|
|
75
|
+
usePreviewIframeStore.getState().setIframe(b);
|
|
76
|
+
});
|
|
77
|
+
expect(host.textContent).toBe('{"width":1080,"height":1920}');
|
|
78
|
+
} finally {
|
|
79
|
+
act(() => root.unmount());
|
|
80
|
+
usePreviewIframeStore.setState({ iframe: null });
|
|
81
|
+
host.remove();
|
|
82
|
+
a.remove();
|
|
83
|
+
b.remove();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { useState, type RefObject } from "react";
|
|
1
|
+
import { useEffect, useState, type RefObject } from "react";
|
|
2
|
+
import { useLivePreviewIframe } from "../player/store/previewIframeStore";
|
|
3
|
+
import { readPreviewCompositionSize } from "../utils/previewCompositionSize";
|
|
2
4
|
import { useMountEffect } from "./useMountEffect";
|
|
3
5
|
import type { CompositionDimensions } from "../components/renders/RenderQueue";
|
|
4
6
|
import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
|
|
@@ -34,6 +36,20 @@ export function useCompositionDimensions(iframeRef: RefObject<HTMLIFrameElement
|
|
|
34
36
|
null,
|
|
35
37
|
);
|
|
36
38
|
|
|
39
|
+
const livePreviewIframe = useLivePreviewIframe();
|
|
40
|
+
|
|
41
|
+
// The runtime posts stage-size once per document, so a promoted reload's message was sent
|
|
42
|
+
// before this listener could accept it. The size is authored on the document: read it there.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const dimensions = readPreviewCompositionSize(livePreviewIframe);
|
|
45
|
+
if (!dimensions) return;
|
|
46
|
+
setCompositionDimensions((prev) =>
|
|
47
|
+
prev && prev.width === dimensions.width && prev.height === dimensions.height
|
|
48
|
+
? prev
|
|
49
|
+
: dimensions,
|
|
50
|
+
);
|
|
51
|
+
}, [livePreviewIframe]);
|
|
52
|
+
|
|
37
53
|
useMountEffect(() => {
|
|
38
54
|
const handleMessage = (e: MessageEvent) => {
|
|
39
55
|
if (!e.source || e.source !== iframeRef.current?.contentWindow) return;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveOptimisticAttributeValue } from "./useDomEditAttributeCommits";
|
|
3
|
+
|
|
4
|
+
describe("resolveOptimisticAttributeValue", () => {
|
|
5
|
+
it("removes an HTML boolean attribute when the value is the literal string false", () => {
|
|
6
|
+
expect(resolveOptimisticAttributeValue("loop", "false")).toBeNull();
|
|
7
|
+
expect(resolveOptimisticAttributeValue("muted", "false")).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("keeps the literal string false for a non-boolean attribute, matching what persist() writes", () => {
|
|
11
|
+
expect(resolveOptimisticAttributeValue("data-example", "false")).toBe("false");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("always removes on null regardless of attribute", () => {
|
|
15
|
+
expect(resolveOptimisticAttributeValue("loop", null)).toBeNull();
|
|
16
|
+
expect(resolveOptimisticAttributeValue("data-example", null)).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("passes any other value through unchanged", () => {
|
|
20
|
+
expect(resolveOptimisticAttributeValue("data-example", "true")).toBe("true");
|
|
21
|
+
expect(resolveOptimisticAttributeValue("loop", "true")).toBe("true");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { HTML_BOOLEAN_ATTRIBUTES, type PatchOperation } from "../utils/sourcePatcher";
|
|
3
3
|
import {
|
|
4
4
|
findElementForSelection,
|
|
5
5
|
getDomEditTargetKey,
|
|
@@ -55,6 +55,14 @@ function resolveFullAttrName(attr: string, prefixData: boolean | undefined): str
|
|
|
55
55
|
return prefixData && !attr.startsWith("data-") ? `data-${attr}` : attr;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// Matches sourcePatcher's own boolean handling: "false" means "remove" only
|
|
59
|
+
// for HTML_BOOLEAN_ATTRIBUTES (loop, muted, ...), so the live preview node
|
|
60
|
+
// ends up holding what persist() actually writes to disk.
|
|
61
|
+
export function resolveOptimisticAttributeValue(attr: string, value: string | null): string | null {
|
|
62
|
+
if (value === null) return null;
|
|
63
|
+
return value === "false" && HTML_BOOLEAN_ATTRIBUTES.has(attr) ? null : value;
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
function setOrRemovePreviewAttribute(
|
|
59
67
|
el: HTMLElement,
|
|
60
68
|
fullAttr: string,
|
|
@@ -393,8 +401,11 @@ export function useDomEditAttributeCommits({
|
|
|
393
401
|
},
|
|
394
402
|
apply: () => {
|
|
395
403
|
if (!editedElement) return;
|
|
396
|
-
|
|
397
|
-
|
|
404
|
+
setOrRemovePreviewAttribute(
|
|
405
|
+
editedElement,
|
|
406
|
+
attr,
|
|
407
|
+
resolveOptimisticAttributeValue(attr, value),
|
|
408
|
+
);
|
|
398
409
|
},
|
|
399
410
|
persist: () =>
|
|
400
411
|
persistDomEditOperations(domEditSelection, [op], {
|
|
@@ -89,6 +89,7 @@ export interface UseDomEditCommitsParams {
|
|
|
89
89
|
) => Promise<CutoverResult>;
|
|
90
90
|
/** Resolver-shadow tripwire for z-index reorder targets (telemetry-only, decoupled from cutover). */
|
|
91
91
|
onReorderShadow?: (targets: string[]) => void;
|
|
92
|
+
readOnlyPreview: boolean;
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
export function useDomEditCommits({
|
|
@@ -112,6 +113,7 @@ export function useDomEditCommits({
|
|
|
112
113
|
onTrySdkPersist,
|
|
113
114
|
onTrySdkDelete,
|
|
114
115
|
onReorderShadow,
|
|
116
|
+
readOnlyPreview,
|
|
115
117
|
}: UseDomEditCommitsParams) {
|
|
116
118
|
const resolveImportedFontAsset = useCallback(
|
|
117
119
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
@@ -452,6 +454,7 @@ export function useDomEditCommits({
|
|
|
452
454
|
previewIframeRef,
|
|
453
455
|
showToast,
|
|
454
456
|
commitPositionPatchToHtml,
|
|
457
|
+
readOnlyPreview,
|
|
455
458
|
});
|
|
456
459
|
|
|
457
460
|
// ── Element lifecycle (delete, z-index reorder) ──
|
|
@@ -58,6 +58,7 @@ export interface UseDomEditSessionParams extends DomEditTimelineParams {
|
|
|
58
58
|
/** The timeline context menu's delete op — a canvas selection that IS a
|
|
59
59
|
* timeline row hands off here instead of the REST remove-elements path. */
|
|
60
60
|
handleTimelineElementsDelete: (elements: TimelineElement[]) => Promise<void>;
|
|
61
|
+
readOnlyPreview: boolean;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export function useDomEditSession({
|
|
@@ -98,8 +99,10 @@ export function useDomEditSession({
|
|
|
98
99
|
publishSdkSession,
|
|
99
100
|
forceReloadSdkSession,
|
|
100
101
|
handleTimelineElementsDelete,
|
|
102
|
+
readOnlyPreview,
|
|
101
103
|
}: UseDomEditSessionParams) {
|
|
102
104
|
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
105
|
+
const previewCaptionEditMode = captionEditMode && !readOnlyPreview;
|
|
103
106
|
void _setRefreshKey;
|
|
104
107
|
const {
|
|
105
108
|
domEditSelection,
|
|
@@ -125,7 +128,7 @@ export function useDomEditSession({
|
|
|
125
128
|
activeCompPath,
|
|
126
129
|
isMasterView,
|
|
127
130
|
compIdToSrc,
|
|
128
|
-
captionEditMode,
|
|
131
|
+
captionEditMode: previewCaptionEditMode,
|
|
129
132
|
previewIframeRef,
|
|
130
133
|
timelineElements,
|
|
131
134
|
getTimelineSelectionSet,
|
|
@@ -247,6 +250,7 @@ export function useDomEditSession({
|
|
|
247
250
|
refreshDomEditSelectionFromPreview,
|
|
248
251
|
buildDomSelectionFromTarget,
|
|
249
252
|
forceReloadSdkSession,
|
|
253
|
+
readOnlyPreview,
|
|
250
254
|
onTrySdkPersist: sdkSession
|
|
251
255
|
? (selection, operations, originalContent, targetPath, options) => {
|
|
252
256
|
// Decoupled tripwire, runs regardless of the cutover flag. originalContent lets
|
|
@@ -405,7 +409,6 @@ export function useDomEditSession({
|
|
|
405
409
|
handleGsapRemoveAllKeyframes,
|
|
406
410
|
handleResetSelectedElementKeyframes,
|
|
407
411
|
} = useDomEditWiring({
|
|
408
|
-
// fallow-ignore-next-line code-duplication
|
|
409
412
|
projectId,
|
|
410
413
|
activeCompPath,
|
|
411
414
|
domEditSelection,
|
|
@@ -414,7 +417,7 @@ export function useDomEditSession({
|
|
|
414
417
|
refreshDomEditGroupSelectionsFromPreview,
|
|
415
418
|
previewIframeRef,
|
|
416
419
|
previewIframe,
|
|
417
|
-
captionEditMode,
|
|
420
|
+
captionEditMode: previewCaptionEditMode,
|
|
418
421
|
refreshKey,
|
|
419
422
|
gsapCacheVersion,
|
|
420
423
|
bumpGsapCache,
|
|
@@ -451,7 +454,7 @@ export function useDomEditSession({
|
|
|
451
454
|
handleBlockedDomMove,
|
|
452
455
|
handleDomManualDragStart,
|
|
453
456
|
} = usePreviewInteraction({
|
|
454
|
-
captionEditMode,
|
|
457
|
+
captionEditMode: previewCaptionEditMode,
|
|
455
458
|
compositionLoading,
|
|
456
459
|
previewIframeRef,
|
|
457
460
|
showToast,
|
|
@@ -109,46 +109,46 @@ export interface UseDomEditWiringParams {
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
// fallow-ignore-next-line complexity
|
|
112
|
-
export function useDomEditWiring({
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
112
|
+
export function useDomEditWiring(params: UseDomEditWiringParams) {
|
|
113
|
+
const {
|
|
114
|
+
projectId,
|
|
115
|
+
activeCompPath,
|
|
116
|
+
domEditSelection,
|
|
117
|
+
domEditSelectionRef,
|
|
118
|
+
domEditGroupSelectionsRef,
|
|
119
|
+
refreshDomEditGroupSelectionsFromPreview,
|
|
120
|
+
previewIframeRef,
|
|
121
|
+
previewIframe,
|
|
122
|
+
captionEditMode,
|
|
123
|
+
refreshKey,
|
|
124
|
+
gsapCacheVersion,
|
|
125
|
+
bumpGsapCache,
|
|
126
|
+
showToast,
|
|
127
|
+
refreshPreviewDocumentVersion,
|
|
128
|
+
syncPreviewHotkeys,
|
|
129
|
+
applyStudioManualEditsToPreviewRef,
|
|
130
|
+
applyDomSelection,
|
|
131
|
+
buildDomSelectionFromTarget,
|
|
132
|
+
openSourceForSelection,
|
|
133
|
+
updateGsapProperty,
|
|
134
|
+
updateGsapMeta,
|
|
135
|
+
deleteGsapAnimation,
|
|
136
|
+
deleteAllForSelector,
|
|
137
|
+
addGsapAnimation,
|
|
138
|
+
addGsapProperty,
|
|
139
|
+
removeGsapProperty,
|
|
140
|
+
updateGsapFromProperty,
|
|
141
|
+
addGsapFromProperty,
|
|
142
|
+
removeGsapFromProperty,
|
|
143
|
+
addKeyframe,
|
|
144
|
+
addKeyframeBatch,
|
|
145
|
+
removeKeyframe,
|
|
146
|
+
moveKeyframe,
|
|
147
|
+
resizeKeyframedTween,
|
|
148
|
+
convertToKeyframes,
|
|
149
|
+
removeAllKeyframes,
|
|
150
|
+
handleDomManualEditsReset,
|
|
151
|
+
} = params;
|
|
152
152
|
// ── Click-to-source navigation ──
|
|
153
153
|
|
|
154
154
|
const onClickToSource = useCallback(
|