@hyperframes/studio 0.6.95 → 0.6.96
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/index-B0twsRu0.css +1 -0
- package/dist/assets/index-BA979yF1.js +251 -0
- package/dist/assets/{index-CAANLw9Q.js → index-BWFaypdT.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +10 -5
- package/src/components/SaveQueuePausedBanner.tsx +23 -0
- package/src/components/StudioPreviewArea.tsx +7 -0
- package/src/components/StudioRightPanel.tsx +1 -38
- package/src/components/editor/DomEditOverlay.test.ts +169 -29
- package/src/components/editor/DomEditOverlay.tsx +13 -23
- package/src/components/editor/GestureRecordControl.tsx +98 -0
- package/src/components/editor/PropertyPanel.tsx +22 -38
- package/src/components/editor/domEditing.test.ts +84 -0
- package/src/components/editor/domEditingLayers.ts +19 -0
- package/src/components/editor/domEditingRootLayer.ts +64 -0
- package/src/components/editor/manualEditingAvailability.test.ts +1 -2
- package/src/components/editor/manualEditingAvailability.ts +0 -7
- package/src/contexts/DomEditContext.tsx +1 -6
- package/src/hooks/gsapScriptCommitHelpers.ts +128 -0
- package/src/hooks/useDomEditCommits.ts +97 -123
- package/src/hooks/useDomEditPositionPatchCommit.ts +53 -0
- package/src/hooks/useDomEditSession.ts +59 -65
- package/src/hooks/useFileManager.ts +19 -5
- package/src/hooks/useGsapAnimationFetchFallback.ts +19 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +25 -0
- package/src/hooks/useGsapScriptCommits.ts +152 -140
- package/src/hooks/useGsapSelectionHandlers.ts +38 -8
- package/src/hooks/usePreviewPersistence.ts +90 -51
- package/src/hooks/useSafeGsapCommitMutation.ts +66 -0
- package/src/hooks/useStudioContextValue.ts +3 -19
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/lib/playbackAdapter.test.ts +86 -1
- package/src/player/lib/playbackAdapter.ts +62 -0
- package/src/utils/domEditSaveQueue.test.ts +117 -0
- package/src/utils/domEditSaveQueue.ts +87 -0
- package/src/utils/studioHelpers.ts +1 -1
- package/src/utils/studioSaveDiagnostics.test.ts +127 -0
- package/src/utils/studioSaveDiagnostics.ts +200 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/studioUrlState.ts +2 -8
- package/dist/assets/index-DujOjou6.js +0 -251
- package/dist/assets/index-rm9tn9nH.css +0 -1
- package/src/components/editor/EaseCurveEditor.tsx +0 -221
- package/src/components/editor/MotionPanel.tsx +0 -277
- package/src/components/editor/MotionPanelFields.tsx +0 -185
- package/src/components/editor/MotionPathOverlay.tsx +0 -146
- package/src/components/editor/SpringEaseEditor.tsx +0 -256
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-BA979yF1.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B0twsRu0.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.96",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@codemirror/view": "6.40.0",
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"mediabunny": "^1.45.3",
|
|
34
|
-
"@hyperframes/
|
|
35
|
-
"@hyperframes/
|
|
34
|
+
"@hyperframes/player": "0.6.96",
|
|
35
|
+
"@hyperframes/core": "0.6.96"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/react": "19",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"vite": "^6.4.2",
|
|
47
47
|
"vitest": "^3.2.4",
|
|
48
48
|
"zustand": "^5.0.0",
|
|
49
|
-
"@hyperframes/producer": "0.6.
|
|
49
|
+
"@hyperframes/producer": "0.6.96"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSid
|
|
|
3
3
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
4
4
|
import { usePlayerStore } from "./player";
|
|
5
5
|
import { LintModal } from "./components/LintModal";
|
|
6
|
+
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
|
|
6
7
|
import { useCaptionStore } from "./captions/store";
|
|
7
8
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
8
9
|
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
@@ -389,9 +390,7 @@ export function StudioApp() {
|
|
|
389
390
|
);
|
|
390
391
|
|
|
391
392
|
const {
|
|
392
|
-
selectedStudioMotion,
|
|
393
393
|
designPanelActive,
|
|
394
|
-
motionPanelActive,
|
|
395
394
|
inspectorPanelActive,
|
|
396
395
|
inspectorButtonActive,
|
|
397
396
|
shouldShowSelectedDomBounds,
|
|
@@ -399,7 +398,6 @@ export function StudioApp() {
|
|
|
399
398
|
panelLayout.rightPanelTab,
|
|
400
399
|
panelLayout.rightCollapsed,
|
|
401
400
|
isPlaying,
|
|
402
|
-
domEditSession.domEditSelection,
|
|
403
401
|
gestureState === "recording",
|
|
404
402
|
);
|
|
405
403
|
|
|
@@ -481,6 +479,13 @@ export function StudioApp() {
|
|
|
481
479
|
onExport={() => void renderQueue.startRender()}
|
|
482
480
|
/>
|
|
483
481
|
|
|
482
|
+
{previewPersistence.domEditSaveQueuePaused && (
|
|
483
|
+
<SaveQueuePausedBanner
|
|
484
|
+
message={previewPersistence.domEditSaveQueuePaused}
|
|
485
|
+
onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
|
|
486
|
+
/>
|
|
487
|
+
)}
|
|
488
|
+
|
|
484
489
|
<div className="flex flex-1 min-h-0">
|
|
485
490
|
<StudioLeftSidebar
|
|
486
491
|
leftSidebarRef={leftSidebarRef}
|
|
@@ -510,6 +515,8 @@ export function StudioApp() {
|
|
|
510
515
|
setCompositionLoading={setCompositionLoading}
|
|
511
516
|
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
|
512
517
|
isGestureRecording={gestureState === "recording"}
|
|
518
|
+
recordingState={gestureState}
|
|
519
|
+
onToggleRecording={STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined}
|
|
513
520
|
blockPreview={blockPreview}
|
|
514
521
|
gestureOverlay={
|
|
515
522
|
gestureState === "recording" && previewIframe ? (
|
|
@@ -530,9 +537,7 @@ export function StudioApp() {
|
|
|
530
537
|
|
|
531
538
|
{!panelLayout.rightCollapsed && (
|
|
532
539
|
<StudioRightPanel
|
|
533
|
-
selectedStudioMotion={selectedStudioMotion}
|
|
534
540
|
designPanelActive={designPanelActive}
|
|
535
|
-
motionPanelActive={motionPanelActive}
|
|
536
541
|
activeBlockParams={activeBlockParams}
|
|
537
542
|
onCloseBlockParams={() => {
|
|
538
543
|
setActiveBlockParams(null);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface SaveQueuePausedBannerProps {
|
|
2
|
+
message: string;
|
|
3
|
+
onDismiss: () => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */
|
|
7
|
+
export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
className="absolute left-1/2 top-14 z-[92] flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-red-500/30 bg-red-950/85 px-4 py-2 text-[12px] font-medium text-red-100 shadow-lg shadow-black/30"
|
|
11
|
+
role="alert"
|
|
12
|
+
>
|
|
13
|
+
<span>{message}</span>
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
onClick={onDismiss}
|
|
17
|
+
className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10"
|
|
18
|
+
>
|
|
19
|
+
Dismiss
|
|
20
|
+
</button>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -17,6 +17,7 @@ import { useStudioContext } from "../contexts/StudioContext";
|
|
|
17
17
|
import { useDomEditContext } from "../contexts/DomEditContext";
|
|
18
18
|
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
|
|
19
19
|
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
20
|
+
import type { GestureRecordingState } from "./editor/GestureRecordControl";
|
|
20
21
|
|
|
21
22
|
export interface StudioPreviewAreaProps {
|
|
22
23
|
timelineToolbar: ReactNode;
|
|
@@ -59,6 +60,8 @@ export interface StudioPreviewAreaProps {
|
|
|
59
60
|
shouldShowSelectedDomBounds: boolean;
|
|
60
61
|
blockPreview?: BlockPreviewInfo | null;
|
|
61
62
|
isGestureRecording?: boolean;
|
|
63
|
+
recordingState?: GestureRecordingState;
|
|
64
|
+
onToggleRecording?: () => void;
|
|
62
65
|
gestureOverlay?: ReactNode;
|
|
63
66
|
}
|
|
64
67
|
|
|
@@ -81,6 +84,8 @@ export function StudioPreviewArea({
|
|
|
81
84
|
setCompositionLoading,
|
|
82
85
|
shouldShowSelectedDomBounds,
|
|
83
86
|
isGestureRecording,
|
|
87
|
+
recordingState,
|
|
88
|
+
onToggleRecording,
|
|
84
89
|
blockPreview,
|
|
85
90
|
gestureOverlay,
|
|
86
91
|
}: StudioPreviewAreaProps) {
|
|
@@ -290,6 +295,8 @@ export function StudioPreviewArea({
|
|
|
290
295
|
onRotationCommit={handleDomRotationCommit}
|
|
291
296
|
gridVisible={snapPrefs.gridVisible}
|
|
292
297
|
gridSpacing={snapPrefs.gridSpacing}
|
|
298
|
+
recordingState={recordingState}
|
|
299
|
+
onToggleRecording={onToggleRecording}
|
|
293
300
|
/>
|
|
294
301
|
<SnapToolbar onSnapChange={setSnapPrefs} />
|
|
295
302
|
{gestureOverlay}
|
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import { Tooltip } from "./ui";
|
|
2
2
|
import { PropertyPanel } from "./editor/PropertyPanel";
|
|
3
|
-
import { MotionPanel } from "./editor/MotionPanel";
|
|
4
3
|
import { LayersPanel } from "./editor/LayersPanel";
|
|
5
4
|
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
|
|
6
5
|
import { BlockParamsPanel } from "./editor/BlockParamsPanel";
|
|
7
6
|
import { RenderQueue } from "./renders/RenderQueue";
|
|
8
7
|
import type { RenderJob } from "./renders/useRenderQueue";
|
|
9
|
-
import type { StudioGsapMotion } from "./editor/studioMotion";
|
|
10
8
|
import type { BlockParam } from "@hyperframes/core/registry";
|
|
11
|
-
import {
|
|
12
|
-
STUDIO_INSPECTOR_PANELS_ENABLED,
|
|
13
|
-
STUDIO_MOTION_PANEL_ENABLED,
|
|
14
|
-
} from "./editor/manualEditingAvailability";
|
|
15
|
-
|
|
16
|
-
/** Motion data without targeting metadata. */
|
|
17
|
-
type StudioMotionData = Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">;
|
|
9
|
+
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability";
|
|
18
10
|
|
|
19
11
|
import { useStudioContext } from "../contexts/StudioContext";
|
|
20
12
|
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
@@ -23,9 +15,7 @@ import { useDomEditContext } from "../contexts/DomEditContext";
|
|
|
23
15
|
import { usePlayerStore } from "../player";
|
|
24
16
|
|
|
25
17
|
export interface StudioRightPanelProps {
|
|
26
|
-
selectedStudioMotion: StudioMotionData | null;
|
|
27
18
|
designPanelActive: boolean;
|
|
28
|
-
motionPanelActive: boolean;
|
|
29
19
|
activeBlockParams?: {
|
|
30
20
|
blockName: string;
|
|
31
21
|
blockTitle: string;
|
|
@@ -40,9 +30,7 @@ export interface StudioRightPanelProps {
|
|
|
40
30
|
|
|
41
31
|
// fallow-ignore-next-line complexity
|
|
42
32
|
export function StudioRightPanel({
|
|
43
|
-
selectedStudioMotion,
|
|
44
33
|
designPanelActive,
|
|
45
|
-
motionPanelActive,
|
|
46
34
|
activeBlockParams,
|
|
47
35
|
onCloseBlockParams,
|
|
48
36
|
recordingState,
|
|
@@ -84,8 +72,6 @@ export function StudioRightPanel({
|
|
|
84
72
|
handleDomAddTextField,
|
|
85
73
|
handleDomRemoveTextField,
|
|
86
74
|
handleAskAgent,
|
|
87
|
-
handleDomMotionCommit,
|
|
88
|
-
handleDomMotionClear,
|
|
89
75
|
selectedGsapAnimations,
|
|
90
76
|
gsapMultipleTimelines,
|
|
91
77
|
gsapUnsupportedTimelinePattern,
|
|
@@ -159,21 +145,6 @@ export function StudioRightPanel({
|
|
|
159
145
|
Layers
|
|
160
146
|
</button>
|
|
161
147
|
</Tooltip>
|
|
162
|
-
{STUDIO_MOTION_PANEL_ENABLED && (
|
|
163
|
-
<Tooltip label="Animation and motion" side="bottom">
|
|
164
|
-
<button
|
|
165
|
-
type="button"
|
|
166
|
-
onClick={() => setRightPanelTab("motion")}
|
|
167
|
-
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
168
|
-
rightPanelTab === "motion"
|
|
169
|
-
? "bg-neutral-800 text-white"
|
|
170
|
-
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
171
|
-
}`}
|
|
172
|
-
>
|
|
173
|
-
Motion
|
|
174
|
-
</button>
|
|
175
|
-
</Tooltip>
|
|
176
|
-
)}
|
|
177
148
|
</>
|
|
178
149
|
)}
|
|
179
150
|
<Tooltip label="Render queue and exports" side="bottom">
|
|
@@ -248,14 +219,6 @@ export function StudioRightPanel({
|
|
|
248
219
|
recordingDuration={recordingDuration}
|
|
249
220
|
onToggleRecording={onToggleRecording}
|
|
250
221
|
/>
|
|
251
|
-
) : motionPanelActive ? (
|
|
252
|
-
<MotionPanel
|
|
253
|
-
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
|
254
|
-
motion={selectedStudioMotion}
|
|
255
|
-
onClearSelection={clearDomSelection}
|
|
256
|
-
onSetMotion={handleDomMotionCommit}
|
|
257
|
-
onClearMotion={handleDomMotionClear}
|
|
258
|
-
/>
|
|
259
222
|
) : (
|
|
260
223
|
<RenderQueue
|
|
261
224
|
jobs={renderJobs}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { Window } from "happy-dom";
|
|
7
7
|
import {
|
|
8
8
|
DomEditOverlay,
|
|
@@ -19,13 +19,21 @@ import type { DomEditSelection } from "./domEditing";
|
|
|
19
19
|
// React 19 warns unless the test environment opts into act().
|
|
20
20
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
21
|
|
|
22
|
+
const gestureSpies = vi.hoisted(() => ({
|
|
23
|
+
startGesture: vi.fn(() => true),
|
|
24
|
+
startGroupDrag: vi.fn(),
|
|
25
|
+
onPointerMove: vi.fn(),
|
|
26
|
+
onPointerUp: vi.fn(),
|
|
27
|
+
clearPointerState: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
22
30
|
vi.mock("./useDomEditOverlayGestures", () => ({
|
|
23
31
|
createDomEditOverlayGestureHandlers: () => ({
|
|
24
|
-
startGesture:
|
|
25
|
-
startGroupDrag:
|
|
26
|
-
onPointerMove:
|
|
27
|
-
onPointerUp:
|
|
28
|
-
clearPointerState:
|
|
32
|
+
startGesture: gestureSpies.startGesture,
|
|
33
|
+
startGroupDrag: gestureSpies.startGroupDrag,
|
|
34
|
+
onPointerMove: gestureSpies.onPointerMove,
|
|
35
|
+
onPointerUp: gestureSpies.onPointerUp,
|
|
36
|
+
clearPointerState: gestureSpies.clearPointerState,
|
|
29
37
|
}),
|
|
30
38
|
}));
|
|
31
39
|
|
|
@@ -34,9 +42,18 @@ vi.mock("./useDomEditOverlayRects", async () => {
|
|
|
34
42
|
const { rectsEqual } = await import("./domEditOverlayGeometry");
|
|
35
43
|
|
|
36
44
|
return {
|
|
37
|
-
useDomEditOverlayRects: () => {
|
|
38
|
-
const
|
|
39
|
-
|
|
45
|
+
useDomEditOverlayRects: (options: { selectionRef: { current: unknown } }) => {
|
|
46
|
+
const defaultSelectionRect = {
|
|
47
|
+
left: 24,
|
|
48
|
+
top: 36,
|
|
49
|
+
width: 180,
|
|
50
|
+
height: 72,
|
|
51
|
+
editScaleX: 1,
|
|
52
|
+
editScaleY: 1,
|
|
53
|
+
};
|
|
54
|
+
const initialOverlayRect = options.selectionRef.current ? defaultSelectionRect : null;
|
|
55
|
+
const [overlayRect, setOverlayRectState] = React.useState(initialOverlayRect);
|
|
56
|
+
const overlayRectRef = React.useRef(initialOverlayRect);
|
|
40
57
|
const [groupOverlayItems, setGroupOverlayItemsState] = React.useState([]);
|
|
41
58
|
const groupOverlayItemsRef = React.useRef([]);
|
|
42
59
|
|
|
@@ -85,6 +102,30 @@ vi.mock("./domEditOverlayGeometry", async () => {
|
|
|
85
102
|
};
|
|
86
103
|
});
|
|
87
104
|
|
|
105
|
+
function createOverlayProps(args: {
|
|
106
|
+
iframeRef: { current: HTMLIFrameElement | null };
|
|
107
|
+
selection: DomEditSelection | null;
|
|
108
|
+
hoverSelection: DomEditSelection | null;
|
|
109
|
+
onSelectionChange: (next: DomEditSelection) => void;
|
|
110
|
+
}) {
|
|
111
|
+
return {
|
|
112
|
+
iframeRef: args.iframeRef,
|
|
113
|
+
activeCompositionPath: null,
|
|
114
|
+
selection: args.selection,
|
|
115
|
+
hoverSelection: args.hoverSelection,
|
|
116
|
+
groupSelections: [],
|
|
117
|
+
onCanvasMouseDown: () => {},
|
|
118
|
+
onCanvasPointerMove: () => Promise.resolve(args.hoverSelection ?? args.selection),
|
|
119
|
+
onCanvasPointerLeave: () => {},
|
|
120
|
+
onSelectionChange: args.onSelectionChange,
|
|
121
|
+
onBlockedMove: () => {},
|
|
122
|
+
onPathOffsetCommit: () => {},
|
|
123
|
+
onGroupPathOffsetCommit: () => {},
|
|
124
|
+
onBoxSizeCommit: () => {},
|
|
125
|
+
onRotationCommit: () => {},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
88
129
|
describe("focusDomEditOverlayElement", () => {
|
|
89
130
|
it("focuses the canvas overlay without scrolling", () => {
|
|
90
131
|
const calls: Array<FocusOptions | undefined> = [];
|
|
@@ -97,7 +138,94 @@ describe("focusDomEditOverlayElement", () => {
|
|
|
97
138
|
});
|
|
98
139
|
|
|
99
140
|
describe("DomEditOverlay", () => {
|
|
100
|
-
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
gestureSpies.startGesture.mockClear();
|
|
143
|
+
gestureSpies.startGroupDrag.mockClear();
|
|
144
|
+
gestureSpies.onPointerMove.mockClear();
|
|
145
|
+
gestureSpies.onPointerUp.mockClear();
|
|
146
|
+
gestureSpies.clearPointerState.mockClear();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("does not start a drag from a stale hover target on canvas pointer-down", () => {
|
|
150
|
+
const host = document.createElement("div");
|
|
151
|
+
document.body.append(host);
|
|
152
|
+
const root = createRoot(host);
|
|
153
|
+
const selection: DomEditSelection = {
|
|
154
|
+
element: document.createElement("div"),
|
|
155
|
+
id: "cta-label",
|
|
156
|
+
selector: ".cta-label",
|
|
157
|
+
selectorIndex: 0,
|
|
158
|
+
sourceFile: "index.html",
|
|
159
|
+
tagName: "span",
|
|
160
|
+
label: "CTA Label",
|
|
161
|
+
textContent: "Add to basket",
|
|
162
|
+
textFields: [],
|
|
163
|
+
capabilities: {
|
|
164
|
+
canEditText: true,
|
|
165
|
+
canEditLayout: true,
|
|
166
|
+
canMove: true,
|
|
167
|
+
canApplyManualOffset: true,
|
|
168
|
+
canApplyManualSize: false,
|
|
169
|
+
canApplyManualRotation: false,
|
|
170
|
+
canAdjustOpacity: true,
|
|
171
|
+
canAdjustFill: true,
|
|
172
|
+
canAdjustBorderRadius: true,
|
|
173
|
+
canAdjustStroke: true,
|
|
174
|
+
canAdjustShadow: true,
|
|
175
|
+
canAdjustZIndex: true,
|
|
176
|
+
},
|
|
177
|
+
computedStyle: {
|
|
178
|
+
display: "inline",
|
|
179
|
+
position: "static",
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
let currentSelection: DomEditSelection | null = null;
|
|
184
|
+
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
|
|
185
|
+
|
|
186
|
+
function Harness() {
|
|
187
|
+
const [selected, setSelected] = React.useState<DomEditSelection | null>(null);
|
|
188
|
+
currentSelection = selected;
|
|
189
|
+
|
|
190
|
+
return React.createElement(
|
|
191
|
+
DomEditOverlay,
|
|
192
|
+
createOverlayProps({
|
|
193
|
+
iframeRef,
|
|
194
|
+
selection: selected,
|
|
195
|
+
hoverSelection: selection,
|
|
196
|
+
onSelectionChange: (next: DomEditSelection) => setSelected(next),
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
act(() => {
|
|
202
|
+
root.render(React.createElement(Harness));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
|
|
206
|
+
expect(overlay).toBeTruthy();
|
|
207
|
+
|
|
208
|
+
act(() => {
|
|
209
|
+
overlay.dispatchEvent(
|
|
210
|
+
new PointerEvent("pointerdown", {
|
|
211
|
+
bubbles: true,
|
|
212
|
+
button: 0,
|
|
213
|
+
clientX: 120,
|
|
214
|
+
clientY: 80,
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(gestureSpies.startGesture).not.toHaveBeenCalled();
|
|
220
|
+
expect(currentSelection).toBe(null);
|
|
221
|
+
|
|
222
|
+
act(() => {
|
|
223
|
+
root.unmount();
|
|
224
|
+
});
|
|
225
|
+
host.remove();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("starts movement from the selected bounds", async () => {
|
|
101
229
|
// The overlay's compRect updates via a RAF loop reading iframe + overlay
|
|
102
230
|
// getBoundingClientRect. happy-dom returns all zeros for newly-created
|
|
103
231
|
// elements with no layout, so without stubs the RAF early-returns
|
|
@@ -153,31 +281,25 @@ describe("DomEditOverlay", () => {
|
|
|
153
281
|
},
|
|
154
282
|
};
|
|
155
283
|
|
|
156
|
-
let currentSelection: DomEditSelection | null =
|
|
284
|
+
let currentSelection: DomEditSelection | null = selection;
|
|
285
|
+
const onToggleRecording = vi.fn();
|
|
157
286
|
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
|
|
158
287
|
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
|
|
159
288
|
HTMLDivElement.prototype.setPointerCapture = () => {};
|
|
160
289
|
|
|
161
290
|
function Harness() {
|
|
162
|
-
const [selected, setSelected] = React.useState<DomEditSelection | null>(
|
|
291
|
+
const [selected, setSelected] = React.useState<DomEditSelection | null>(selection);
|
|
163
292
|
currentSelection = selected;
|
|
164
293
|
|
|
165
294
|
return React.createElement(DomEditOverlay, {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
onCanvasPointerLeave: () => {},
|
|
175
|
-
onSelectionChange: (next: DomEditSelection) => setSelected(next),
|
|
176
|
-
onBlockedMove: () => {},
|
|
177
|
-
onPathOffsetCommit: () => {},
|
|
178
|
-
onGroupPathOffsetCommit: () => {},
|
|
179
|
-
onBoxSizeCommit: () => {},
|
|
180
|
-
onRotationCommit: () => {},
|
|
295
|
+
...createOverlayProps({
|
|
296
|
+
iframeRef,
|
|
297
|
+
selection: selected,
|
|
298
|
+
hoverSelection: null,
|
|
299
|
+
onSelectionChange: (next: DomEditSelection) => setSelected(next),
|
|
300
|
+
}),
|
|
301
|
+
recordingState: "idle",
|
|
302
|
+
onToggleRecording,
|
|
181
303
|
});
|
|
182
304
|
}
|
|
183
305
|
|
|
@@ -197,8 +319,13 @@ describe("DomEditOverlay", () => {
|
|
|
197
319
|
const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
|
|
198
320
|
expect(overlay).toBeTruthy();
|
|
199
321
|
|
|
322
|
+
const selectionBox = host.querySelector(
|
|
323
|
+
'[data-dom-edit-selection-box="true"]',
|
|
324
|
+
) as HTMLDivElement;
|
|
325
|
+
expect(selectionBox).toBeTruthy();
|
|
326
|
+
|
|
200
327
|
act(() => {
|
|
201
|
-
|
|
328
|
+
selectionBox.dispatchEvent(
|
|
202
329
|
new PointerEvent("pointerdown", {
|
|
203
330
|
bubbles: true,
|
|
204
331
|
button: 0,
|
|
@@ -209,7 +336,20 @@ describe("DomEditOverlay", () => {
|
|
|
209
336
|
});
|
|
210
337
|
|
|
211
338
|
expect(currentSelection).toBe(selection);
|
|
212
|
-
expect(
|
|
339
|
+
expect(gestureSpies.startGesture).toHaveBeenCalledWith(
|
|
340
|
+
"drag",
|
|
341
|
+
expect.objectContaining({ button: 0 }),
|
|
342
|
+
);
|
|
343
|
+
const recordButton = host.querySelector(
|
|
344
|
+
'[aria-label="Record gesture (R)"]',
|
|
345
|
+
) as HTMLButtonElement;
|
|
346
|
+
expect(recordButton).toBeTruthy();
|
|
347
|
+
|
|
348
|
+
act(() => {
|
|
349
|
+
recordButton.click();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(onToggleRecording).toHaveBeenCalledTimes(1);
|
|
213
353
|
|
|
214
354
|
act(() => {
|
|
215
355
|
root.unmount();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { memo, useMemo, useRef, useState, type RefObject } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { type DomEditSelection } from "./domEditing";
|
|
4
|
-
import { resolveDomEditGroupOverlayRect
|
|
4
|
+
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
|
|
5
5
|
import {
|
|
6
6
|
type BlockedMoveState,
|
|
7
7
|
type FocusableDomEditOverlay,
|
|
@@ -13,6 +13,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
|
|
|
13
13
|
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
14
14
|
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
|
|
15
15
|
import { GridOverlay } from "./GridOverlay";
|
|
16
|
+
import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl";
|
|
16
17
|
|
|
17
18
|
// Re-exports for external consumers — preserving existing import paths.
|
|
18
19
|
export {
|
|
@@ -66,6 +67,8 @@ interface DomEditOverlayProps {
|
|
|
66
67
|
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
|
|
67
68
|
gridVisible?: boolean;
|
|
68
69
|
gridSpacing?: number;
|
|
70
|
+
recordingState?: GestureRecordingState;
|
|
71
|
+
onToggleRecording?: () => void;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
export const DomEditOverlay = memo(function DomEditOverlay({
|
|
@@ -87,6 +90,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
87
90
|
onGroupPathOffsetCommit,
|
|
88
91
|
onBoxSizeCommit,
|
|
89
92
|
onRotationCommit,
|
|
93
|
+
recordingState,
|
|
94
|
+
onToggleRecording,
|
|
90
95
|
}: DomEditOverlayProps) {
|
|
91
96
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
92
97
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -304,28 +309,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
304
309
|
|
|
305
310
|
const target = event.target as HTMLElement | null;
|
|
306
311
|
if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
|
|
307
|
-
|
|
308
|
-
const candidate = hoverSelectionRef.current;
|
|
309
|
-
if (!candidate?.capabilities.canApplyManualOffset) return;
|
|
310
|
-
|
|
311
|
-
const overlayEl = overlayRef.current;
|
|
312
|
-
const iframe = iframeRef.current;
|
|
313
|
-
const candidateRect =
|
|
314
|
-
overlayEl && iframe ? toOverlayRect(overlayEl, iframe, candidate.element) : null;
|
|
315
|
-
if (!candidateRect) return;
|
|
316
|
-
|
|
317
|
-
suppressNextOverlayMouseDownRef.current = true;
|
|
318
|
-
selectionRef.current = candidate;
|
|
319
|
-
setOverlayRect(candidateRect);
|
|
320
|
-
const didStartGesture = gestures.startGesture("drag", event, {
|
|
321
|
-
selection: candidate,
|
|
322
|
-
rect: candidateRect,
|
|
323
|
-
});
|
|
324
|
-
if (!didStartGesture) {
|
|
325
|
-
suppressNextOverlayMouseDownRef.current = false;
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
onSelectionChangeRef.current(candidate);
|
|
329
312
|
};
|
|
330
313
|
|
|
331
314
|
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
@@ -453,6 +436,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
453
436
|
/>
|
|
454
437
|
</div>
|
|
455
438
|
)}
|
|
439
|
+
{onToggleRecording && (
|
|
440
|
+
<GestureRecordBadge
|
|
441
|
+
rect={overlayRect}
|
|
442
|
+
recordingState={recordingState}
|
|
443
|
+
onToggleRecording={onToggleRecording}
|
|
444
|
+
/>
|
|
445
|
+
)}
|
|
456
446
|
<div
|
|
457
447
|
key={selectionKey}
|
|
458
448
|
ref={boxRef}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export type GestureRecordingState = "idle" | "recording" | "preview";
|
|
2
|
+
|
|
3
|
+
interface GestureRecordIconProps {
|
|
4
|
+
recording: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function GestureRecordIcon({ recording }: GestureRecordIconProps) {
|
|
8
|
+
return (
|
|
9
|
+
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
|
|
10
|
+
{recording ? (
|
|
11
|
+
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
|
|
12
|
+
) : (
|
|
13
|
+
<circle cx="5" cy="5" r="4.5" fill="currentColor" />
|
|
14
|
+
)}
|
|
15
|
+
</svg>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface GestureRecordPanelButtonProps {
|
|
20
|
+
recordingState?: GestureRecordingState;
|
|
21
|
+
recordingDuration?: number;
|
|
22
|
+
onToggleRecording: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function GestureRecordPanelButton({
|
|
26
|
+
recordingState,
|
|
27
|
+
recordingDuration,
|
|
28
|
+
onToggleRecording,
|
|
29
|
+
}: GestureRecordPanelButtonProps) {
|
|
30
|
+
const recording = recordingState === "recording";
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="px-4 pb-3">
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
37
|
+
onClick={onToggleRecording}
|
|
38
|
+
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
|
|
39
|
+
recording
|
|
40
|
+
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
|
|
41
|
+
: "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
|
|
42
|
+
}`}
|
|
43
|
+
>
|
|
44
|
+
<GestureRecordIcon recording={recording} />
|
|
45
|
+
{recording
|
|
46
|
+
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
|
|
47
|
+
: "Record gesture (R) — move pointer to capture motion"}
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface GestureRecordBadgeProps {
|
|
54
|
+
rect: { left: number; top: number; width: number; height: number };
|
|
55
|
+
recordingState?: GestureRecordingState;
|
|
56
|
+
onToggleRecording: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function GestureRecordBadge({
|
|
60
|
+
rect,
|
|
61
|
+
recordingState,
|
|
62
|
+
onToggleRecording,
|
|
63
|
+
}: GestureRecordBadgeProps) {
|
|
64
|
+
const recording = recordingState === "recording";
|
|
65
|
+
const label = recording ? "Stop gesture recording (R)" : "Record gesture (R)";
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
aria-label={label}
|
|
71
|
+
title={label}
|
|
72
|
+
className={`pointer-events-auto absolute z-20 flex h-7 w-7 items-center justify-center rounded-full border shadow-lg transition-colors ${
|
|
73
|
+
recording
|
|
74
|
+
? "border-red-400/60 bg-red-500 text-white animate-pulse"
|
|
75
|
+
: "border-studio-accent/60 bg-neutral-950 text-studio-accent hover:bg-neutral-900"
|
|
76
|
+
}`}
|
|
77
|
+
style={{
|
|
78
|
+
left: Math.max(0, rect.left + rect.width + 8),
|
|
79
|
+
top: Math.max(0, rect.top - 4),
|
|
80
|
+
}}
|
|
81
|
+
onPointerDown={(event) => {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
event.stopPropagation();
|
|
84
|
+
}}
|
|
85
|
+
onMouseDown={(event) => {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
}}
|
|
89
|
+
onClick={(event) => {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
event.stopPropagation();
|
|
92
|
+
onToggleRecording();
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<GestureRecordIcon recording={recording} />
|
|
96
|
+
</button>
|
|
97
|
+
);
|
|
98
|
+
}
|