@hyperframes/studio 0.8.55 → 0.8.56
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-BjAGc2ID.js → hyperframes-player-k8LzMtXX.js} +1 -1
- package/dist/assets/{index-TcBZyzNA.js → index-BlkJ_Axe.js} +191 -191
- package/dist/assets/{index-DaNpNFJC.js → index-CxR7F2YQ.js} +1 -1
- package/dist/assets/{index-BEIRytvV.js → index-DFlILHJe.js} +1 -1
- package/dist/assets/{index-DrHQAuGO.css → index-ZmBtVrEm.css} +1 -1
- package/dist/index.d.ts +16 -8
- package/dist/index.html +2 -2
- package/dist/index.js +3111 -2998
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +14 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +59 -50
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -40
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- 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 +1 -0
- 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/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/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +15 -8
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +128 -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/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/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/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/player/components/Timeline.tsx +22 -23
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- 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/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -3
- 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/player/hooks/useTimelineRowElements.test.ts +0 -28
- package/src/player/hooks/useTimelineRowElements.ts +0 -17
|
@@ -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 ──
|
|
@@ -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(
|
|
@@ -12,10 +12,30 @@ import {
|
|
|
12
12
|
readStudioPathOffset,
|
|
13
13
|
readStudioRotation,
|
|
14
14
|
} from "../components/editor/manualEdits";
|
|
15
|
-
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
15
|
+
import { useDomGeometryCommits, type UseDomGeometryCommitsParams } from "./useDomGeometryCommits";
|
|
16
16
|
|
|
17
17
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
18
18
|
|
|
19
|
+
function mountCommits(
|
|
20
|
+
commitPositionPatchToHtml: UseDomGeometryCommitsParams["commitPositionPatchToHtml"],
|
|
21
|
+
readOnlyPreview = false,
|
|
22
|
+
) {
|
|
23
|
+
let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
|
|
24
|
+
function Probe() {
|
|
25
|
+
commits = useDomGeometryCommits({
|
|
26
|
+
previewIframeRef: { current: null },
|
|
27
|
+
showToast: vi.fn(),
|
|
28
|
+
commitPositionPatchToHtml,
|
|
29
|
+
readOnlyPreview,
|
|
30
|
+
});
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const host = document.createElement("div");
|
|
34
|
+
const root = createRoot(host);
|
|
35
|
+
act(() => root.render(<Probe />));
|
|
36
|
+
return { commits: () => commits!, unmount: () => act(() => root.unmount()) };
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
describe("useDomGeometryCommits rollback", () => {
|
|
20
40
|
it("restores every optimistic geometry mutation when persistence rejects", async () => {
|
|
21
41
|
const element = document.createElement("div");
|
|
@@ -30,7 +50,9 @@ describe("useDomGeometryCommits rollback", () => {
|
|
|
30
50
|
element,
|
|
31
51
|
} as unknown as DomEditSelection;
|
|
32
52
|
const failure = new Error("save failed");
|
|
33
|
-
const commitPositionPatchToHtml = vi
|
|
53
|
+
const commitPositionPatchToHtml = vi
|
|
54
|
+
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
|
|
55
|
+
.mockRejectedValue(failure);
|
|
34
56
|
let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
|
|
35
57
|
const host = document.createElement("div");
|
|
36
58
|
const root = createRoot(host);
|
|
@@ -40,6 +62,7 @@ describe("useDomGeometryCommits rollback", () => {
|
|
|
40
62
|
previewIframeRef: { current: null },
|
|
41
63
|
showToast: vi.fn(),
|
|
42
64
|
commitPositionPatchToHtml,
|
|
65
|
+
readOnlyPreview: false,
|
|
43
66
|
});
|
|
44
67
|
return null;
|
|
45
68
|
}
|
|
@@ -60,3 +83,63 @@ describe("useDomGeometryCommits rollback", () => {
|
|
|
60
83
|
act(() => root.unmount());
|
|
61
84
|
});
|
|
62
85
|
});
|
|
86
|
+
|
|
87
|
+
describe("useDomGeometryCommits read-only preview", () => {
|
|
88
|
+
const selectionOn = (element: HTMLElement) =>
|
|
89
|
+
({ id: element.id, selector: `#${element.id}`, element }) as unknown as DomEditSelection;
|
|
90
|
+
|
|
91
|
+
it("refuses a manual offset commit: no write, no history entry", async () => {
|
|
92
|
+
const element = document.createElement("div");
|
|
93
|
+
element.id = "ro-offset";
|
|
94
|
+
document.body.append(element);
|
|
95
|
+
applyStudioPathOffset(element, { x: 1, y: 2 });
|
|
96
|
+
const commitPositionPatchToHtml =
|
|
97
|
+
vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
|
|
98
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
|
|
99
|
+
await commits().handleDomPathOffsetCommit(selectionOn(element), { x: 99, y: 99 });
|
|
100
|
+
expect(readStudioPathOffset(element)).toEqual({ x: 1, y: 2 });
|
|
101
|
+
expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
|
|
102
|
+
unmount();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("refuses a manual box-size commit: no write, no history entry", async () => {
|
|
106
|
+
const element = document.createElement("div");
|
|
107
|
+
element.id = "ro-size";
|
|
108
|
+
document.body.append(element);
|
|
109
|
+
applyStudioBoxSize(element, { width: 10, height: 20 });
|
|
110
|
+
const commitPositionPatchToHtml =
|
|
111
|
+
vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
|
|
112
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
|
|
113
|
+
await commits().handleDomBoxSizeCommit(selectionOn(element), { width: 999, height: 999 });
|
|
114
|
+
expect(readStudioBoxSize(element)).toEqual({ width: 10, height: 20 });
|
|
115
|
+
expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
|
|
116
|
+
unmount();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("refuses a manual rotation commit: no write, no history entry", async () => {
|
|
120
|
+
const element = document.createElement("div");
|
|
121
|
+
element.id = "ro-rotate";
|
|
122
|
+
document.body.append(element);
|
|
123
|
+
applyStudioRotation(element, { angle: 5 });
|
|
124
|
+
const commitPositionPatchToHtml =
|
|
125
|
+
vi.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>();
|
|
126
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml, true);
|
|
127
|
+
await commits().handleDomRotationCommit(selectionOn(element), { angle: 350 });
|
|
128
|
+
expect(readStudioRotation(element)).toEqual({ angle: 5 });
|
|
129
|
+
expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
|
|
130
|
+
unmount();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("still commits an offset with the flag off", async () => {
|
|
134
|
+
const element = document.createElement("div");
|
|
135
|
+
element.id = "rw-offset";
|
|
136
|
+
document.body.append(element);
|
|
137
|
+
const commitPositionPatchToHtml = vi
|
|
138
|
+
.fn<UseDomGeometryCommitsParams["commitPositionPatchToHtml"]>()
|
|
139
|
+
.mockResolvedValue(undefined);
|
|
140
|
+
const { commits, unmount } = mountCommits(commitPositionPatchToHtml);
|
|
141
|
+
await commits().handleDomPathOffsetCommit(selectionOn(element), { x: 5, y: 6 });
|
|
142
|
+
expect(commitPositionPatchToHtml).toHaveBeenCalledTimes(1);
|
|
143
|
+
unmount();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -28,9 +28,20 @@ import { isElementGsapTargeted } from "./gsapTargetCache";
|
|
|
28
28
|
const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
|
|
29
29
|
"This element is GSAP-animated — dragging via CSS would corrupt keyframes";
|
|
30
30
|
|
|
31
|
+
function rejectGsapCssFallback(
|
|
32
|
+
selection: DomEditSelection,
|
|
33
|
+
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
|
|
34
|
+
showToast: (message: string, tone?: "error" | "info") => void,
|
|
35
|
+
): Promise<never> | null {
|
|
36
|
+
if (!isElementGsapTargeted(previewIframeRef.current, selection.element)) return null;
|
|
37
|
+
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
38
|
+
showToast(error.message, "error");
|
|
39
|
+
return Promise.reject(error);
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
// ── Hook ──
|
|
32
43
|
|
|
33
|
-
interface UseDomGeometryCommitsParams {
|
|
44
|
+
export interface UseDomGeometryCommitsParams {
|
|
34
45
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
35
46
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
36
47
|
commitPositionPatchToHtml: (
|
|
@@ -38,25 +49,24 @@ interface UseDomGeometryCommitsParams {
|
|
|
38
49
|
patches: PatchOperation[],
|
|
39
50
|
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
|
40
51
|
) => Promise<void>;
|
|
52
|
+
readOnlyPreview: boolean;
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
export function useDomGeometryCommits({
|
|
44
56
|
previewIframeRef,
|
|
45
57
|
showToast,
|
|
46
58
|
commitPositionPatchToHtml,
|
|
59
|
+
readOnlyPreview,
|
|
47
60
|
}: UseDomGeometryCommitsParams) {
|
|
48
61
|
const handleDomPathOffsetCommit = useCallback(
|
|
49
62
|
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
63
|
+
if (readOnlyPreview) return Promise.resolve();
|
|
50
64
|
// ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path
|
|
51
65
|
// elements fall through to commitPositionPatchToHtml → persistDomEditOperations →
|
|
52
66
|
// onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done).
|
|
53
67
|
// Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR.
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
57
|
-
showToast(error.message, "error");
|
|
58
|
-
return Promise.reject(error);
|
|
59
|
-
}
|
|
68
|
+
const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
|
|
69
|
+
if (gsapFallback) return gsapFallback;
|
|
60
70
|
const before = captureStudioPathOffset(selection.element);
|
|
61
71
|
applyStudioPathOffset(selection.element, next);
|
|
62
72
|
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
@@ -67,7 +77,7 @@ export function useDomGeometryCommits({
|
|
|
67
77
|
throw error;
|
|
68
78
|
});
|
|
69
79
|
},
|
|
70
|
-
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
80
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
|
|
71
81
|
);
|
|
72
82
|
|
|
73
83
|
const handleDomBoxSizeCommit = useCallback(
|
|
@@ -76,11 +86,9 @@ export function useDomGeometryCommits({
|
|
|
76
86
|
next: { width: number; height: number },
|
|
77
87
|
offset?: { x: number; y: number },
|
|
78
88
|
) => {
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return Promise.reject(error);
|
|
83
|
-
}
|
|
89
|
+
if (readOnlyPreview) return Promise.resolve();
|
|
90
|
+
const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
|
|
91
|
+
if (gsapFallback) return gsapFallback;
|
|
84
92
|
const beforeSize = captureStudioBoxSize(selection.element);
|
|
85
93
|
const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
|
|
86
94
|
applyStudioBoxSize(selection.element, next);
|
|
@@ -104,16 +112,14 @@ export function useDomGeometryCommits({
|
|
|
104
112
|
throw error;
|
|
105
113
|
});
|
|
106
114
|
},
|
|
107
|
-
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
115
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
|
|
108
116
|
);
|
|
109
117
|
|
|
110
118
|
const handleDomRotationCommit = useCallback(
|
|
111
119
|
(selection: DomEditSelection, next: { angle: number }) => {
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return Promise.reject(error);
|
|
116
|
-
}
|
|
120
|
+
if (readOnlyPreview) return Promise.resolve();
|
|
121
|
+
const gsapFallback = rejectGsapCssFallback(selection, previewIframeRef, showToast);
|
|
122
|
+
if (gsapFallback) return gsapFallback;
|
|
117
123
|
const before = captureStudioRotation(selection.element);
|
|
118
124
|
applyStudioRotation(selection.element, next);
|
|
119
125
|
return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
|
|
@@ -124,7 +130,7 @@ export function useDomGeometryCommits({
|
|
|
124
130
|
throw error;
|
|
125
131
|
});
|
|
126
132
|
},
|
|
127
|
-
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
133
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast, readOnlyPreview],
|
|
128
134
|
);
|
|
129
135
|
|
|
130
136
|
const handleDomManualEditsReset = useCallback(
|
|
@@ -107,6 +107,7 @@ describe("useGestureCommit", () => {
|
|
|
107
107
|
previewIframeRef: { current: iframe },
|
|
108
108
|
showToast: vi.fn(),
|
|
109
109
|
isGestureRecordingRef: { current: false },
|
|
110
|
+
readOnlyPreview: false,
|
|
110
111
|
});
|
|
111
112
|
return null;
|
|
112
113
|
}
|
|
@@ -127,4 +128,32 @@ describe("useGestureCommit", () => {
|
|
|
127
128
|
expect(options[1]).toEqual(expect.objectContaining({ coalesceMs: Infinity, softReload: true }));
|
|
128
129
|
expect(options[1]).not.toHaveProperty("skipReload");
|
|
129
130
|
});
|
|
131
|
+
|
|
132
|
+
it("does not start a recording while the preview is read-only", () => {
|
|
133
|
+
const element = document.createElement("div");
|
|
134
|
+
const commitMutation = vi.fn(async () => {});
|
|
135
|
+
const captured: { hook: ReturnType<typeof useGestureCommit> | null } = { hook: null };
|
|
136
|
+
function Probe() {
|
|
137
|
+
captured.hook = useGestureCommit({
|
|
138
|
+
domEditSessionRef: {
|
|
139
|
+
current: {
|
|
140
|
+
domEditSelection: makeSelection(element),
|
|
141
|
+
selectedGsapAnimations: [],
|
|
142
|
+
commitMutation,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
previewIframeRef: { current: document.createElement("iframe") },
|
|
146
|
+
showToast: vi.fn(),
|
|
147
|
+
isGestureRecordingRef: { current: false },
|
|
148
|
+
readOnlyPreview: true,
|
|
149
|
+
});
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const root = mountReactHarness(<Probe />);
|
|
153
|
+
cleanup = () => act(() => root.unmount());
|
|
154
|
+
|
|
155
|
+
act(() => captured.hook?.handleToggleRecording());
|
|
156
|
+
expect(gestureRecording.startRecording).not.toHaveBeenCalled();
|
|
157
|
+
expect(commitMutation).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
130
159
|
});
|
|
@@ -87,6 +87,7 @@ interface UseGestureCommitParams {
|
|
|
87
87
|
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
88
88
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
89
89
|
isGestureRecordingRef: React.MutableRefObject<boolean>;
|
|
90
|
+
readOnlyPreview: boolean;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
export interface UseGestureCommitResult {
|
|
@@ -101,6 +102,7 @@ export function useGestureCommit({
|
|
|
101
102
|
previewIframeRef,
|
|
102
103
|
showToast,
|
|
103
104
|
isGestureRecordingRef,
|
|
105
|
+
readOnlyPreview,
|
|
104
106
|
}: UseGestureCommitParams): UseGestureCommitResult {
|
|
105
107
|
const gestureRecording = useGestureRecording();
|
|
106
108
|
const [gestureState, setGestureState] = useState<"idle" | "recording">("idle");
|
|
@@ -115,6 +117,20 @@ export function useGestureCommit({
|
|
|
115
117
|
// Unmount: clear auto-stop interval
|
|
116
118
|
useEffect(() => () => clearInterval(recordingAutoStopRef.current), []);
|
|
117
119
|
|
|
120
|
+
const cancelRecording = useCallback(() => {
|
|
121
|
+
clearInterval(recordingAutoStopRef.current);
|
|
122
|
+
gestureRecording.stopRecording();
|
|
123
|
+
gestureRecording.clearSamples();
|
|
124
|
+
gestureStateRef.current = "idle";
|
|
125
|
+
isGestureRecordingRef.current = false;
|
|
126
|
+
capturedSelectionRef.current = null;
|
|
127
|
+
setGestureState("idle");
|
|
128
|
+
}, [gestureRecording, isGestureRecordingRef]);
|
|
129
|
+
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (readOnlyPreview && gestureStateRef.current === "recording") cancelRecording();
|
|
132
|
+
}, [cancelRecording, readOnlyPreview]);
|
|
133
|
+
|
|
118
134
|
// fallow-ignore-next-line complexity
|
|
119
135
|
const stopAndCommitRecording = useCallback(async () => {
|
|
120
136
|
clearInterval(recordingAutoStopRef.current);
|
|
@@ -335,9 +351,14 @@ export function useGestureCommit({
|
|
|
335
351
|
// fallow-ignore-next-line complexity
|
|
336
352
|
const handleToggleRecording = useCallback(() => {
|
|
337
353
|
if (gestureStateRef.current === "recording") {
|
|
354
|
+
if (readOnlyPreview) {
|
|
355
|
+
cancelRecording();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
338
358
|
void stopAndCommitRecording();
|
|
339
359
|
return;
|
|
340
360
|
}
|
|
361
|
+
if (readOnlyPreview) return;
|
|
341
362
|
const sel = domEditSessionRef.current.domEditSelection;
|
|
342
363
|
if (!sel) {
|
|
343
364
|
showToast("Select an element first", "error");
|
|
@@ -373,9 +394,11 @@ export function useGestureCommit({
|
|
|
373
394
|
gestureRecording,
|
|
374
395
|
showToast,
|
|
375
396
|
stopAndCommitRecording,
|
|
397
|
+
cancelRecording,
|
|
376
398
|
previewIframeRef,
|
|
377
399
|
domEditSessionRef,
|
|
378
400
|
isGestureRecordingRef,
|
|
401
|
+
readOnlyPreview,
|
|
379
402
|
]);
|
|
380
403
|
|
|
381
404
|
return { gestureState, gestureRecording, handleToggleRecording };
|