@hyperframes/studio 0.7.38 → 0.7.40
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-ClUipc8i.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
- package/dist/chunk-BA66NM4L.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +47 -29
- package/dist/index.html +2 -2
- package/dist/index.js +4432 -3045
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +9 -7
- package/src/components/StudioPreviewArea.tsx +14 -0
- package/src/components/StudioRightPanel.tsx +9 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +140 -151
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +73 -0
- package/src/components/editor/PropertyPanel.tsx +42 -37
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +21 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +3 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +19 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.40",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
"gsap": "^3.13.0",
|
|
47
47
|
"marked": "^14.1.4",
|
|
48
48
|
"mediabunny": "^1.45.3",
|
|
49
|
-
"@hyperframes/
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/sdk": "0.7.
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/player": "0.7.
|
|
49
|
+
"@hyperframes/parsers": "0.7.40",
|
|
50
|
+
"@hyperframes/studio-server": "0.7.40",
|
|
51
|
+
"@hyperframes/sdk": "0.7.40",
|
|
52
|
+
"@hyperframes/core": "0.7.40",
|
|
53
|
+
"@hyperframes/player": "0.7.40"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/react": "19",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"vite": "^6.4.2",
|
|
66
66
|
"vitest": "^3.2.4",
|
|
67
67
|
"zustand": "^5.0.0",
|
|
68
|
-
"@hyperframes/producer": "0.7.
|
|
68
|
+
"@hyperframes/producer": "0.7.40"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
import type { DomEditSelection } from "./components/editor/domEditing";
|
|
38
38
|
import { StudioHeader } from "./components/StudioHeader";
|
|
39
39
|
import { useGestureCommit } from "./hooks/useGestureCommit";
|
|
40
|
+
import { useCropModeProps } from "./hooks/useCropMode";
|
|
40
41
|
import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability";
|
|
41
42
|
import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay";
|
|
42
43
|
import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
|
|
@@ -82,6 +83,7 @@ export function StudioApp() {
|
|
|
82
83
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
83
84
|
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
|
|
84
85
|
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
|
|
86
|
+
const cropModeProps = useCropModeProps();
|
|
85
87
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
86
88
|
const activeCompPathRef = useRef(activeCompPath);
|
|
87
89
|
activeCompPathRef.current = activeCompPath;
|
|
@@ -358,7 +360,6 @@ export function StudioApp() {
|
|
|
358
360
|
resetErrors: resetConsoleErrors,
|
|
359
361
|
} = useConsoleErrorCapture(previewIframe);
|
|
360
362
|
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
|
|
361
|
-
// Gesture recording
|
|
362
363
|
const handleToggleRecordingRef = useRef<() => void>(() => {});
|
|
363
364
|
const domEditSessionRef = useRef(domEditSession);
|
|
364
365
|
domEditSessionRef.current = domEditSession;
|
|
@@ -369,6 +370,7 @@ export function StudioApp() {
|
|
|
369
370
|
isGestureRecordingRef,
|
|
370
371
|
});
|
|
371
372
|
handleToggleRecordingRef.current = handleToggleRecording;
|
|
373
|
+
const recordingToggle = STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined;
|
|
372
374
|
const canvasRectRef = useRef<CanvasRect | null>(null);
|
|
373
375
|
useLayoutEffect(() => {
|
|
374
376
|
if (gestureState !== "recording" || !previewIframe) {
|
|
@@ -528,6 +530,7 @@ export function StudioApp() {
|
|
|
528
530
|
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
|
|
529
531
|
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
|
|
530
532
|
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
|
533
|
+
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
|
|
531
534
|
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
|
532
535
|
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
|
533
536
|
handleRazorSplit={timelineEditing.handleRazorSplit}
|
|
@@ -537,10 +540,9 @@ export function StudioApp() {
|
|
|
537
540
|
shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
|
|
538
541
|
isGestureRecording={gestureState === "recording"}
|
|
539
542
|
recordingState={gestureState}
|
|
540
|
-
onToggleRecording={
|
|
541
|
-
STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined
|
|
542
|
-
}
|
|
543
|
+
onToggleRecording={recordingToggle}
|
|
543
544
|
blockPreview={blockPreview}
|
|
545
|
+
{...cropModeProps}
|
|
544
546
|
gestureOverlay={
|
|
545
547
|
gestureState === "recording" && previewIframe ? (
|
|
546
548
|
<GestureTrailOverlay
|
|
@@ -564,13 +566,13 @@ export function StudioApp() {
|
|
|
564
566
|
}}
|
|
565
567
|
recordingState={gestureState}
|
|
566
568
|
recordingDuration={gestureRecording.recordingDuration}
|
|
567
|
-
onToggleRecording={
|
|
568
|
-
STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined
|
|
569
|
-
}
|
|
569
|
+
onToggleRecording={recordingToggle}
|
|
570
570
|
sdkSession={sdkHandle.session}
|
|
571
571
|
reloadPreview={reloadPreview}
|
|
572
572
|
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
|
573
573
|
recordEdit={editHistory.recordEdit}
|
|
574
|
+
{...cropModeProps}
|
|
575
|
+
onToggleElementHidden={timelineEditing.handleToggleElementHidden}
|
|
574
576
|
/>
|
|
575
577
|
)}
|
|
576
578
|
</div>
|
|
@@ -58,6 +58,7 @@ export interface StudioPreviewAreaProps {
|
|
|
58
58
|
element: TimelineElement,
|
|
59
59
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
60
60
|
) => Promise<void> | void;
|
|
61
|
+
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
|
|
61
62
|
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
62
63
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
63
64
|
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
@@ -69,6 +70,8 @@ export interface StudioPreviewAreaProps {
|
|
|
69
70
|
isGestureRecording?: boolean;
|
|
70
71
|
recordingState?: GestureRecordingState;
|
|
71
72
|
onToggleRecording?: () => void;
|
|
73
|
+
cropMode?: boolean;
|
|
74
|
+
onCropModeChange?: (active: boolean) => void;
|
|
72
75
|
gestureOverlay?: ReactNode;
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -83,6 +86,7 @@ export function StudioPreviewArea({
|
|
|
83
86
|
handleTimelineFileDrop,
|
|
84
87
|
handleTimelineElementMove,
|
|
85
88
|
handleTimelineElementResize,
|
|
89
|
+
handleToggleTrackHidden,
|
|
86
90
|
handleBlockedTimelineEdit,
|
|
87
91
|
handleTimelineElementSplit,
|
|
88
92
|
handleRazorSplit,
|
|
@@ -93,6 +97,8 @@ export function StudioPreviewArea({
|
|
|
93
97
|
isGestureRecording,
|
|
94
98
|
recordingState,
|
|
95
99
|
onToggleRecording,
|
|
100
|
+
cropMode,
|
|
101
|
+
onCropModeChange,
|
|
96
102
|
blockPreview,
|
|
97
103
|
gestureOverlay,
|
|
98
104
|
}: StudioPreviewAreaProps) {
|
|
@@ -132,6 +138,7 @@ export function StudioPreviewArea({
|
|
|
132
138
|
handleDomGroupPathOffsetCommit,
|
|
133
139
|
handleDomBoxSizeCommit,
|
|
134
140
|
handleDomRotationCommit,
|
|
141
|
+
handleDomStyleCommit,
|
|
135
142
|
handleGsapRemoveKeyframe,
|
|
136
143
|
handleGsapMoveKeyframeToPlayhead,
|
|
137
144
|
handleGsapMoveKeyframe,
|
|
@@ -160,6 +167,7 @@ export function StudioPreviewArea({
|
|
|
160
167
|
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
|
|
161
168
|
// anim in the keyframe's property group, falling back to the first keyframed one.
|
|
162
169
|
const resolveKeyframeTarget = useCallback(
|
|
170
|
+
// fallow-ignore-next-line complexity
|
|
163
171
|
(pct: number): { animId: string; tweenPct: number } | null => {
|
|
164
172
|
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
|
|
165
173
|
const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
|
@@ -177,6 +185,7 @@ export function StudioPreviewArea({
|
|
|
177
185
|
() => ({
|
|
178
186
|
onMoveElement: handleTimelineElementMove,
|
|
179
187
|
onResizeElement: handleTimelineElementResize,
|
|
188
|
+
onToggleTrackHidden: handleToggleTrackHidden,
|
|
180
189
|
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
|
181
190
|
onSplitElement: handleTimelineElementSplit,
|
|
182
191
|
onRazorSplit: handleRazorSplit,
|
|
@@ -205,6 +214,7 @@ export function StudioPreviewArea({
|
|
|
205
214
|
// drop past the boundary (last keyframe past the end, first before the start)
|
|
206
215
|
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
|
207
216
|
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
|
217
|
+
// fallow-ignore-next-line complexity
|
|
208
218
|
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
|
|
209
219
|
const target = resolveKeyframeTarget(fromClipPct);
|
|
210
220
|
const sel = domEditSelection;
|
|
@@ -275,6 +285,7 @@ export function StudioPreviewArea({
|
|
|
275
285
|
[
|
|
276
286
|
handleTimelineElementMove,
|
|
277
287
|
handleTimelineElementResize,
|
|
288
|
+
handleToggleTrackHidden,
|
|
278
289
|
handleBlockedTimelineEdit,
|
|
279
290
|
handleTimelineElementSplit,
|
|
280
291
|
handleRazorSplit,
|
|
@@ -375,6 +386,9 @@ export function StudioPreviewArea({
|
|
|
375
386
|
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
376
387
|
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
377
388
|
onRotationCommit={handleDomRotationCommit}
|
|
389
|
+
onStyleCommit={handleDomStyleCommit}
|
|
390
|
+
cropMode={cropMode}
|
|
391
|
+
onCropModeChange={onCropModeChange}
|
|
378
392
|
gridVisible={snapPrefs.gridVisible}
|
|
379
393
|
gridSpacing={snapPrefs.gridSpacing}
|
|
380
394
|
recordingState={recordingState}
|
|
@@ -51,6 +51,8 @@ export interface StudioRightPanelProps {
|
|
|
51
51
|
recordingState?: "idle" | "recording" | "preview";
|
|
52
52
|
recordingDuration?: number;
|
|
53
53
|
onToggleRecording?: () => void;
|
|
54
|
+
cropMode?: boolean;
|
|
55
|
+
onCropModeChange?: (active: boolean) => void;
|
|
54
56
|
/** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
|
|
55
57
|
sdkSession: Composition | null;
|
|
56
58
|
reloadPreview: () => void;
|
|
@@ -60,6 +62,7 @@ export interface StudioRightPanelProps {
|
|
|
60
62
|
kind: EditHistoryKind;
|
|
61
63
|
files: Record<string, { before: string; after: string }>;
|
|
62
64
|
}) => Promise<void>;
|
|
65
|
+
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
// fallow-ignore-next-line complexity
|
|
@@ -70,10 +73,13 @@ export function StudioRightPanel({
|
|
|
70
73
|
recordingState,
|
|
71
74
|
recordingDuration,
|
|
72
75
|
onToggleRecording,
|
|
76
|
+
cropMode,
|
|
77
|
+
onCropModeChange,
|
|
73
78
|
sdkSession,
|
|
74
79
|
reloadPreview,
|
|
75
80
|
domEditSaveTimestampRef,
|
|
76
81
|
recordEdit,
|
|
82
|
+
onToggleElementHidden,
|
|
77
83
|
}: StudioRightPanelProps) {
|
|
78
84
|
const {
|
|
79
85
|
rightWidth,
|
|
@@ -346,6 +352,7 @@ export function StudioRightPanel({
|
|
|
346
352
|
multiSelectCount={domEditGroupSelections.length}
|
|
347
353
|
copiedAgentPrompt={copiedAgentPrompt}
|
|
348
354
|
onClearSelection={clearDomSelection}
|
|
355
|
+
onToggleElementHidden={onToggleElementHidden}
|
|
349
356
|
onUngroup={handleUngroupSelection}
|
|
350
357
|
onSetStyle={handleDomStyleCommit}
|
|
351
358
|
onSetAttribute={handleDomAttributeCommit}
|
|
@@ -393,6 +400,8 @@ export function StudioRightPanel({
|
|
|
393
400
|
recordingState={recordingState}
|
|
394
401
|
recordingDuration={recordingDuration}
|
|
395
402
|
onToggleRecording={onToggleRecording}
|
|
403
|
+
cropMode={cropMode}
|
|
404
|
+
onCropModeChange={onCropModeChange}
|
|
396
405
|
/>
|
|
397
406
|
);
|
|
398
407
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
2
|
+
import type { DomEditSelection } from "./domEditing";
|
|
3
|
+
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
4
|
+
import {
|
|
5
|
+
type CropEdge,
|
|
6
|
+
cropRectFromInsets,
|
|
7
|
+
readElementCropInsets,
|
|
8
|
+
resolveCropInsetFromEdgeDrag,
|
|
9
|
+
resolveCropInsetFromMoveDrag,
|
|
10
|
+
} from "./domEditOverlayCrop";
|
|
11
|
+
import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
|
|
12
|
+
|
|
13
|
+
interface CropGestureState {
|
|
14
|
+
edge: CropEdge | "move";
|
|
15
|
+
pointerId: number;
|
|
16
|
+
startX: number;
|
|
17
|
+
startY: number;
|
|
18
|
+
startInsets: ClipPathInsetSides;
|
|
19
|
+
didMove: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface DomEditCropHandlesProps {
|
|
23
|
+
selection: DomEditSelection;
|
|
24
|
+
overlayRect: OverlayRect;
|
|
25
|
+
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function handleCenter(
|
|
29
|
+
edge: CropEdge,
|
|
30
|
+
rect: { left: number; top: number; width: number; height: number },
|
|
31
|
+
) {
|
|
32
|
+
if (edge === "top") return { left: rect.left + rect.width / 2, top: rect.top };
|
|
33
|
+
if (edge === "right") return { left: rect.left + rect.width, top: rect.top + rect.height / 2 };
|
|
34
|
+
if (edge === "bottom") return { left: rect.left + rect.width / 2, top: rect.top + rect.height };
|
|
35
|
+
return { left: rect.left, top: rect.top + rect.height / 2 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pro-editor crop: while crop mode is active the element's clip is lifted so
|
|
42
|
+
* the FULL content stays visible; the cropped-out region is dimmed and the
|
|
43
|
+
* edge handles sit on the crop lines. Dragging updates the crop live; release
|
|
44
|
+
* commits `clip-path: inset(...)` through the normal style-commit path (one
|
|
45
|
+
* undo step per drag). Leaving crop mode re-applies the committed crop.
|
|
46
|
+
*/
|
|
47
|
+
export function DomEditCropHandles({
|
|
48
|
+
selection,
|
|
49
|
+
overlayRect,
|
|
50
|
+
onStyleCommit,
|
|
51
|
+
}: DomEditCropHandlesProps) {
|
|
52
|
+
const gestureRef = useRef<CropGestureState | null>(null);
|
|
53
|
+
const [state, setState] = useState(() => {
|
|
54
|
+
const parsed = readElementCropInsets(selection.element);
|
|
55
|
+
return {
|
|
56
|
+
element: selection.element,
|
|
57
|
+
insets: {
|
|
58
|
+
top: parsed.top,
|
|
59
|
+
right: parsed.right,
|
|
60
|
+
bottom: parsed.bottom,
|
|
61
|
+
left: parsed.left,
|
|
62
|
+
} as ClipPathInsetSides,
|
|
63
|
+
radius: parsed.radius,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Re-sync when the selection element changes (reselect, undo/redo reload).
|
|
68
|
+
if (state.element !== selection.element) {
|
|
69
|
+
const parsed = readElementCropInsets(selection.element);
|
|
70
|
+
setState({
|
|
71
|
+
element: selection.element,
|
|
72
|
+
insets: { top: parsed.top, right: parsed.right, bottom: parsed.bottom, left: parsed.left },
|
|
73
|
+
radius: parsed.radius,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The value to re-apply when crop mode ends (latest committed crop).
|
|
78
|
+
const committedRef = useRef<string | null>(null);
|
|
79
|
+
{
|
|
80
|
+
const hasCrop =
|
|
81
|
+
state.insets.top > 0 ||
|
|
82
|
+
state.insets.right > 0 ||
|
|
83
|
+
state.insets.bottom > 0 ||
|
|
84
|
+
state.insets.left > 0;
|
|
85
|
+
committedRef.current = hasCrop ? buildInsetClipPathSides(state.insets, state.radius) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Lift the clip while crop mode is active so the full content shows through
|
|
89
|
+
// the dim; restore the committed crop on exit/unmount.
|
|
90
|
+
const liftedRef = useRef(false);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const el = selection.element;
|
|
93
|
+
el.style.setProperty("clip-path", "none");
|
|
94
|
+
liftedRef.current = true;
|
|
95
|
+
return () => {
|
|
96
|
+
liftedRef.current = false;
|
|
97
|
+
if (committedRef.current) el.style.setProperty("clip-path", committedRef.current);
|
|
98
|
+
else el.style.removeProperty("clip-path");
|
|
99
|
+
};
|
|
100
|
+
}, [selection.element]);
|
|
101
|
+
|
|
102
|
+
const scaleX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
|
|
103
|
+
const scaleY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
|
|
104
|
+
const width = overlayRect.width / scaleX;
|
|
105
|
+
const height = overlayRect.height / scaleY;
|
|
106
|
+
const cropRect = cropRectFromInsets(overlayRect, state.insets, scaleX, scaleY);
|
|
107
|
+
|
|
108
|
+
const startCropGesture = (edge: CropEdge | "move", event: ReactPointerEvent<HTMLElement>) => {
|
|
109
|
+
if (!onStyleCommit) return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
event.stopPropagation();
|
|
112
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
113
|
+
gestureRef.current = {
|
|
114
|
+
edge,
|
|
115
|
+
pointerId: event.pointerId,
|
|
116
|
+
startX: event.clientX,
|
|
117
|
+
startY: event.clientY,
|
|
118
|
+
startInsets: state.insets,
|
|
119
|
+
didMove: false,
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const updateCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
124
|
+
const gesture = gestureRef.current;
|
|
125
|
+
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
event.stopPropagation();
|
|
128
|
+
const drag = {
|
|
129
|
+
startInsets: gesture.startInsets,
|
|
130
|
+
deltaX: event.clientX - gesture.startX,
|
|
131
|
+
deltaY: event.clientY - gesture.startY,
|
|
132
|
+
scaleX,
|
|
133
|
+
scaleY,
|
|
134
|
+
};
|
|
135
|
+
const nextInsets =
|
|
136
|
+
gesture.edge === "move"
|
|
137
|
+
? resolveCropInsetFromMoveDrag(drag)
|
|
138
|
+
: resolveCropInsetFromEdgeDrag({ ...drag, edge: gesture.edge, width, height });
|
|
139
|
+
gesture.didMove = true;
|
|
140
|
+
setState((prev) => ({ ...prev, insets: nextInsets }));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const finishCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
144
|
+
const gesture = gestureRef.current;
|
|
145
|
+
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
event.stopPropagation();
|
|
148
|
+
gestureRef.current = null;
|
|
149
|
+
if (!gesture.didMove) return;
|
|
150
|
+
// Commit to the file; the commit path re-applies the value to the live
|
|
151
|
+
// element, so lift it back to "none" afterwards — full content + dim is
|
|
152
|
+
// the crop-mode presentation.
|
|
153
|
+
const el = selection.element;
|
|
154
|
+
void Promise.resolve(
|
|
155
|
+
onStyleCommit?.("clip-path", buildInsetClipPathSides(state.insets, state.radius)),
|
|
156
|
+
).then(() => {
|
|
157
|
+
if (liftedRef.current) el.style.setProperty("clip-path", "none");
|
|
158
|
+
});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
162
|
+
const gesture = gestureRef.current;
|
|
163
|
+
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
event.stopPropagation();
|
|
166
|
+
setState((prev) => ({ ...prev, insets: gesture.startInsets }));
|
|
167
|
+
gestureRef.current = null;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<>
|
|
172
|
+
{/* Dim everything of the element outside the crop region. */}
|
|
173
|
+
<div
|
|
174
|
+
className="pointer-events-none absolute overflow-hidden"
|
|
175
|
+
style={{
|
|
176
|
+
left: overlayRect.left,
|
|
177
|
+
top: overlayRect.top,
|
|
178
|
+
width: overlayRect.width,
|
|
179
|
+
height: overlayRect.height,
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
<div
|
|
183
|
+
className="absolute"
|
|
184
|
+
style={{
|
|
185
|
+
left: cropRect.left - overlayRect.left,
|
|
186
|
+
top: cropRect.top - overlayRect.top,
|
|
187
|
+
width: cropRect.width,
|
|
188
|
+
height: cropRect.height,
|
|
189
|
+
boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
{/* Crop frame — drag it to move the whole crop window. */}
|
|
194
|
+
<div
|
|
195
|
+
data-dom-edit-crop-frame="true"
|
|
196
|
+
className="pointer-events-auto absolute border-2 border-studio-accent shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
|
|
197
|
+
style={{
|
|
198
|
+
left: cropRect.left,
|
|
199
|
+
top: cropRect.top,
|
|
200
|
+
width: cropRect.width,
|
|
201
|
+
height: cropRect.height,
|
|
202
|
+
cursor: "move",
|
|
203
|
+
touchAction: "none",
|
|
204
|
+
}}
|
|
205
|
+
onPointerDown={(event) => startCropGesture("move", event)}
|
|
206
|
+
onPointerMove={updateCropGesture}
|
|
207
|
+
onPointerUp={finishCropGesture}
|
|
208
|
+
onPointerCancel={cancelCropGesture}
|
|
209
|
+
/>
|
|
210
|
+
{EDGES.map((edge) => {
|
|
211
|
+
const center = handleCenter(edge, cropRect);
|
|
212
|
+
const vertical = edge === "left" || edge === "right";
|
|
213
|
+
return (
|
|
214
|
+
<button
|
|
215
|
+
key={edge}
|
|
216
|
+
type="button"
|
|
217
|
+
aria-label={`Crop ${edge}`}
|
|
218
|
+
data-dom-edit-crop-handle="true"
|
|
219
|
+
className="pointer-events-auto absolute rounded-sm border border-studio-accent bg-studio-accent shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
|
|
220
|
+
style={{
|
|
221
|
+
left: center.left,
|
|
222
|
+
top: center.top,
|
|
223
|
+
width: vertical ? 10 : 28,
|
|
224
|
+
height: vertical ? 28 : 10,
|
|
225
|
+
transform: "translate(-50%, -50%)",
|
|
226
|
+
cursor: vertical ? "ew-resize" : "ns-resize",
|
|
227
|
+
touchAction: "none",
|
|
228
|
+
}}
|
|
229
|
+
onPointerDown={(event) => startCropGesture(edge, event)}
|
|
230
|
+
onPointerMove={updateCropGesture}
|
|
231
|
+
onPointerUp={finishCropGesture}
|
|
232
|
+
onPointerCancel={cancelCropGesture}
|
|
233
|
+
/>
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
236
|
+
</>
|
|
237
|
+
);
|
|
238
|
+
}
|