@hyperframes/studio 0.7.38 → 0.7.39

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.
Files changed (49) hide show
  1. package/dist/assets/index-0P10SwC_.css +1 -0
  2. package/dist/assets/{index-ClUipc8i.js → index-312a3Ceu.js} +1 -1
  3. package/dist/assets/{index-BLRTwY5l.js → index-B9YvRJz1.js} +194 -194
  4. package/dist/assets/{index-Ykq7ihge.js → index-CQHiecE7.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  6. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  8. package/dist/index.d.ts +13 -0
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +2171 -1458
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +7 -6
  14. package/src/components/StudioPreviewArea.tsx +8 -0
  15. package/src/components/StudioRightPanel.tsx +6 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +111 -108
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +48 -0
  20. package/src/components/editor/PropertyPanel.tsx +9 -30
  21. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  22. package/src/components/editor/SnapToolbar.tsx +20 -1
  23. package/src/components/editor/clipPathHelpers.ts +113 -0
  24. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  25. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  27. package/src/components/editor/domEditOverlayShape.ts +39 -0
  28. package/src/components/editor/domEditing.test.ts +2 -0
  29. package/src/components/editor/domEditingTypes.ts +3 -0
  30. package/src/components/editor/marqueeCommit.ts +3 -2
  31. package/src/components/editor/propertyPanelHelpers.ts +10 -34
  32. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  33. package/src/components/editor/propertyPanelTypes.ts +2 -0
  34. package/src/components/editor/snapTargetCollection.ts +2 -3
  35. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  36. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  37. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  38. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  39. package/src/components/sidebar/AssetsTab.tsx +113 -15
  40. package/src/components/sidebar/BlocksTab.tsx +2 -1
  41. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  42. package/src/hooks/domSelectionTestHarness.ts +1 -0
  43. package/src/hooks/useCropMode.ts +91 -0
  44. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  45. package/src/player/components/ShortcutsPanel.tsx +9 -0
  46. package/src/player/store/playerStore.ts +14 -0
  47. package/dist/assets/index-DJaiR8T2.css +0 -1
  48. package/dist/chunk-JND3XUJL.js.map +0 -1
  49. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.38",
3
+ "version": "0.7.39",
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/core": "0.7.38",
50
- "@hyperframes/parsers": "0.7.38",
51
- "@hyperframes/sdk": "0.7.38",
52
- "@hyperframes/studio-server": "0.7.38",
53
- "@hyperframes/player": "0.7.38"
49
+ "@hyperframes/core": "0.7.39",
50
+ "@hyperframes/parsers": "0.7.39",
51
+ "@hyperframes/sdk": "0.7.39",
52
+ "@hyperframes/player": "0.7.39",
53
+ "@hyperframes/studio-server": "0.7.39"
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.38"
68
+ "@hyperframes/producer": "0.7.39"
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;
@@ -369,6 +371,7 @@ export function StudioApp() {
369
371
  isGestureRecordingRef,
370
372
  });
371
373
  handleToggleRecordingRef.current = handleToggleRecording;
374
+ const recordingToggle = STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined;
372
375
  const canvasRectRef = useRef<CanvasRect | null>(null);
373
376
  useLayoutEffect(() => {
374
377
  if (gestureState !== "recording" || !previewIframe) {
@@ -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,12 @@ 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}
574
575
  />
575
576
  )}
576
577
  </div>
@@ -69,6 +69,8 @@ export interface StudioPreviewAreaProps {
69
69
  isGestureRecording?: boolean;
70
70
  recordingState?: GestureRecordingState;
71
71
  onToggleRecording?: () => void;
72
+ cropMode?: boolean;
73
+ onCropModeChange?: (active: boolean) => void;
72
74
  gestureOverlay?: ReactNode;
73
75
  }
74
76
 
@@ -93,6 +95,8 @@ export function StudioPreviewArea({
93
95
  isGestureRecording,
94
96
  recordingState,
95
97
  onToggleRecording,
98
+ cropMode,
99
+ onCropModeChange,
96
100
  blockPreview,
97
101
  gestureOverlay,
98
102
  }: StudioPreviewAreaProps) {
@@ -132,6 +136,7 @@ export function StudioPreviewArea({
132
136
  handleDomGroupPathOffsetCommit,
133
137
  handleDomBoxSizeCommit,
134
138
  handleDomRotationCommit,
139
+ handleDomStyleCommit,
135
140
  handleGsapRemoveKeyframe,
136
141
  handleGsapMoveKeyframeToPlayhead,
137
142
  handleGsapMoveKeyframe,
@@ -375,6 +380,9 @@ export function StudioPreviewArea({
375
380
  onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
376
381
  onBoxSizeCommit={handleDomBoxSizeCommit}
377
382
  onRotationCommit={handleDomRotationCommit}
383
+ onStyleCommit={handleDomStyleCommit}
384
+ cropMode={cropMode}
385
+ onCropModeChange={onCropModeChange}
378
386
  gridVisible={snapPrefs.gridVisible}
379
387
  gridSpacing={snapPrefs.gridSpacing}
380
388
  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;
@@ -70,6 +72,8 @@ export function StudioRightPanel({
70
72
  recordingState,
71
73
  recordingDuration,
72
74
  onToggleRecording,
75
+ cropMode,
76
+ onCropModeChange,
73
77
  sdkSession,
74
78
  reloadPreview,
75
79
  domEditSaveTimestampRef,
@@ -393,6 +397,8 @@ export function StudioRightPanel({
393
397
  recordingState={recordingState}
394
398
  recordingDuration={recordingDuration}
395
399
  onToggleRecording={onToggleRecording}
400
+ cropMode={cropMode}
401
+ onCropModeChange={onCropModeChange}
396
402
  />
397
403
  );
398
404
 
@@ -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
+ }