@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.
Files changed (102) hide show
  1. package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/chunk-BA66NM4L.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +47 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +4432 -3045
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +9 -7
  14. package/src/components/StudioPreviewArea.tsx +14 -0
  15. package/src/components/StudioRightPanel.tsx +9 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +140 -151
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +73 -0
  20. package/src/components/editor/PropertyPanel.tsx +42 -37
  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/domEditingElement.ts +8 -1
  30. package/src/components/editor/domEditingTypes.ts +3 -0
  31. package/src/components/editor/manualEditsDom.ts +1 -18
  32. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  33. package/src/components/editor/marqueeCommit.ts +3 -2
  34. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  35. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  36. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  37. package/src/components/editor/propertyPanelHelpers.ts +21 -34
  38. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  39. package/src/components/editor/propertyPanelTypes.ts +3 -0
  40. package/src/components/editor/snapTargetCollection.ts +2 -3
  41. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  43. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  44. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  45. package/src/components/sidebar/AssetsTab.tsx +113 -15
  46. package/src/components/sidebar/BlocksTab.tsx +2 -1
  47. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  48. package/src/contexts/TimelineEditContext.tsx +1 -0
  49. package/src/hooks/domSelectionTestHarness.ts +1 -0
  50. package/src/hooks/gsapDragCommit.test.ts +56 -0
  51. package/src/hooks/gsapDragCommit.ts +41 -109
  52. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  53. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  54. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  55. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  56. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  58. package/src/hooks/timelineEditingHelpers.ts +17 -5
  59. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  60. package/src/hooks/timelineTrackVisibility.ts +371 -0
  61. package/src/hooks/useCropMode.ts +91 -0
  62. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  63. package/src/hooks/useRenderClipContent.test.ts +68 -2
  64. package/src/hooks/useRenderClipContent.ts +5 -6
  65. package/src/hooks/useTimelineEditing.ts +35 -30
  66. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  67. package/src/icons/SystemIcons.tsx +12 -4
  68. package/src/player/components/AudioWaveform.tsx +11 -8
  69. package/src/player/components/CompositionThumbnail.tsx +14 -11
  70. package/src/player/components/PlayheadIndicator.tsx +21 -11
  71. package/src/player/components/ShortcutsPanel.tsx +9 -0
  72. package/src/player/components/Timeline.test.ts +105 -5
  73. package/src/player/components/Timeline.tsx +37 -47
  74. package/src/player/components/TimelineCanvas.tsx +275 -256
  75. package/src/player/components/TimelineClip.test.tsx +105 -0
  76. package/src/player/components/TimelineClip.tsx +44 -57
  77. package/src/player/components/TimelineRuler.tsx +17 -23
  78. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  79. package/src/player/components/TimelineTypes.ts +18 -0
  80. package/src/player/components/VideoThumbnail.tsx +16 -13
  81. package/src/player/components/timelineCallbacks.ts +1 -0
  82. package/src/player/components/timelineIcons.tsx +2 -44
  83. package/src/player/components/timelineLayout.ts +18 -22
  84. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  85. package/src/player/components/timelineTheme.test.ts +21 -4
  86. package/src/player/components/timelineTheme.ts +15 -15
  87. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  88. package/src/player/components/useTimelineActiveClips.ts +125 -0
  89. package/src/player/components/useTimelinePlayhead.ts +6 -0
  90. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  91. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  92. package/src/player/lib/time.test.ts +1 -0
  93. package/src/player/lib/timelineDOM.test.ts +48 -1
  94. package/src/player/lib/timelineDOM.ts +7 -0
  95. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  96. package/src/player/store/playerStore.ts +19 -1
  97. package/src/styles/studio.css +108 -0
  98. package/src/utils/sourcePatcher.test.ts +11 -0
  99. package/src/utils/sourcePatcher.ts +6 -4
  100. package/dist/assets/index-DJaiR8T2.css +0 -1
  101. package/dist/chunk-JND3XUJL.js.map +0 -1
  102. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { memo, useEffect, useMemo, useRef, useState } from "react";
2
- import { Eye, Layers, Move } from "../../icons/SystemIcons";
2
+ import { Move } from "../../icons/SystemIcons";
3
+ import { Eye, EyeSlash } from "@phosphor-icons/react";
3
4
  import { InspectorHeaderActions } from "./InspectorHeaderActions";
4
5
  import { useStudioShellContext } from "../../contexts/StudioContext";
5
6
  import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
@@ -10,6 +11,7 @@ import {
10
11
  RESPONSIVE_GRID,
11
12
  readGsapRuntimeValuesForPanel,
12
13
  readGsapBorderRadiusForPanel,
14
+ isSelectedElementHidden,
13
15
  } from "./propertyPanelHelpers";
14
16
  import { MetricField, Section } from "./propertyPanelPrimitives";
15
17
  import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
@@ -27,9 +29,11 @@ import { usePlayerStore, liveTime } from "../../player";
27
29
  import { TimingSection } from "./propertyPanelTimingSection";
28
30
  import { type PropertyPanelProps } from "./propertyPanelHelpers";
29
31
  import { GestureRecordPanelButton } from "./GestureRecordControl";
32
+ import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
30
33
 
31
34
  // Re-export helpers that external consumers import from this module
32
35
  export {
36
+ buildInsetClipPathSides,
33
37
  buildStrokeStyleUpdates,
34
38
  buildStrokeWidthStyleUpdates,
35
39
  getCssFilterFunctionPx,
@@ -37,6 +41,7 @@ export {
37
41
  inferBoxShadowPreset,
38
42
  inferClipPathPreset,
39
43
  normalizePanelPxValue,
44
+ parseInsetClipPathSides,
40
45
  setCssFilterFunctionPx,
41
46
  } from "./propertyPanelHelpers";
42
47
 
@@ -64,6 +69,7 @@ export const PropertyPanel = memo(function PropertyPanel({
64
69
  onAddTextField,
65
70
  onRemoveTextField,
66
71
  onAskAgent: _onAskAgent,
72
+ onToggleElementHidden,
67
73
  onImportAssets,
68
74
  fontAssets = [],
69
75
  onImportFonts,
@@ -94,6 +100,8 @@ export const PropertyPanel = memo(function PropertyPanel({
94
100
  recordingState,
95
101
  recordingDuration,
96
102
  onToggleRecording,
103
+ cropMode,
104
+ onCropModeChange,
97
105
  }: PropertyPanelProps) {
98
106
  const styles = element?.computedStyles ?? EMPTY_STYLES;
99
107
  const { showToast } = useStudioShellContext();
@@ -101,6 +109,10 @@ export const PropertyPanel = memo(function PropertyPanel({
101
109
  const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
102
110
  const storeTime = usePlayerStore((s) => s.currentTime);
103
111
  const isPlaying = usePlayerStore((s) => s.isPlaying);
112
+ const timelineElements = usePlayerStore((s) => s.elements);
113
+ const selectedElementId = usePlayerStore((s) => s.selectedElementId);
114
+ const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
115
+ const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
104
116
  const liveTimeRef = useRef(storeTime);
105
117
  const [, forceRender] = useState(0);
106
118
  useEffect(() => {
@@ -160,35 +172,7 @@ export const PropertyPanel = memo(function PropertyPanel({
160
172
  };
161
173
 
162
174
  if (!element) {
163
- return (
164
- <div className="flex h-full flex-col bg-neutral-900">
165
- <div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
166
- {multiSelectCount > 1 ? (
167
- <>
168
- <Layers size={18} className="mb-3 text-neutral-600" />
169
- <p className="text-sm font-medium text-neutral-200">
170
- {multiSelectCount} elements selected
171
- </p>
172
- <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
173
- Select a single element to edit its properties. Click an element in the preview or
174
- use the timeline layer panel.
175
- </p>
176
- </>
177
- ) : (
178
- <>
179
- <Eye size={18} className="mb-3 text-neutral-600" />
180
- <p className="text-sm font-medium text-neutral-200">
181
- Select an element in the preview.
182
- </p>
183
- <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
184
- The inspector is tuned for element edits with safer geometry controls, color
185
- picking, and cleaner grouped layer controls.
186
- </p>
187
- </>
188
- )}
189
- </div>
190
- </div>
191
- );
175
+ return <PropertyPanelEmptyState multiSelectCount={multiSelectCount} />;
192
176
  }
193
177
 
194
178
  const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
@@ -311,13 +295,32 @@ export const PropertyPanel = memo(function PropertyPanel({
311
295
  </div>
312
296
  <div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
313
297
  </div>
314
- <InspectorHeaderActions
315
- element={element}
316
- copied={clipboardCopied}
317
- onCopy={handleCopyElementInfo}
318
- onClear={onClearSelection}
319
- onUngroup={onUngroup}
320
- />
298
+ <div className="flex items-center gap-1">
299
+ {selectedElementId && onToggleElementHidden && (
300
+ <button
301
+ type="button"
302
+ aria-label={visibilityToggleLabel}
303
+ title={visibilityToggleLabel}
304
+ onClick={() => {
305
+ void onToggleElementHidden(selectedElementId, !selectedElementHidden);
306
+ }}
307
+ className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
308
+ >
309
+ {selectedElementHidden ? (
310
+ <EyeSlash size={13} weight="bold" aria-hidden="true" />
311
+ ) : (
312
+ <Eye size={13} weight="bold" aria-hidden="true" />
313
+ )}
314
+ </button>
315
+ )}
316
+ <InspectorHeaderActions
317
+ element={element}
318
+ copied={clipboardCopied}
319
+ onCopy={handleCopyElementInfo}
320
+ onClear={onClearSelection}
321
+ onUngroup={onUngroup}
322
+ />
323
+ </div>
321
324
  </div>
322
325
  </div>
323
326
  <div className="flex-1 overflow-y-auto">
@@ -581,6 +584,8 @@ export const PropertyPanel = memo(function PropertyPanel({
581
584
  onSetStyle={onSetStyle}
582
585
  onImportAssets={onImportAssets}
583
586
  gsapBorderRadius={gsapBorderRadius}
587
+ cropMode={cropMode}
588
+ onCropModeChange={onCropModeChange}
584
589
  />
585
590
  )}
586
591
  </div>
@@ -0,0 +1,33 @@
1
+ import { Eye, Layers } from "../../icons/SystemIcons";
2
+
3
+ export function PropertyPanelEmptyState({ multiSelectCount }: { multiSelectCount: number }) {
4
+ return (
5
+ <div className="flex h-full flex-col bg-neutral-900">
6
+ <div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
7
+ {multiSelectCount > 1 ? (
8
+ <>
9
+ <Layers size={18} className="mb-3 text-neutral-600" />
10
+ <p className="text-sm font-medium text-neutral-200">
11
+ {multiSelectCount} elements selected
12
+ </p>
13
+ <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
14
+ Select a single element to edit its properties. Click an element in the preview or use
15
+ the timeline layer panel.
16
+ </p>
17
+ </>
18
+ ) : (
19
+ <>
20
+ <Eye size={18} className="mb-3 text-neutral-600" />
21
+ <p className="text-sm font-medium text-neutral-200">
22
+ Select an element in the preview.
23
+ </p>
24
+ <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
25
+ The inspector is tuned for element edits with safer geometry controls, color picking,
26
+ and cleaner grouped layer controls.
27
+ </p>
28
+ </>
29
+ )}
30
+ </div>
31
+ </div>
32
+ );
33
+ }
@@ -1,5 +1,5 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
- import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
2
+ import { Crop, MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
3
3
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
4
4
  import { usePlayerStore } from "../../player/store/playerStore";
5
5
 
@@ -39,6 +39,9 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
39
39
  const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
40
40
  const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
41
41
  const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
42
+ const cropAvailable = usePlayerStore((s) => s.cropAvailable);
43
+ const cropMode = usePlayerStore((s) => s.cropMode);
44
+ const setCropMode = usePlayerStore((s) => s.setCropMode);
42
45
  const popoverRef = useRef<HTMLDivElement>(null);
43
46
  const gridButtonRef = useRef<HTMLButtonElement>(null);
44
47
 
@@ -99,6 +102,22 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
99
102
  className="absolute top-2 right-2 z-50 flex items-center gap-1"
100
103
  onPointerDown={(e) => e.stopPropagation()}
101
104
  >
105
+ {cropAvailable && (
106
+ <button
107
+ type="button"
108
+ className={`rounded-md p-1.5 transition-colors ${
109
+ cropMode
110
+ ? "bg-studio-accent/20 text-studio-accent"
111
+ : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
112
+ }`}
113
+ onClick={() => setCropMode(!cropMode)}
114
+ title={cropMode ? "Exit crop (Esc)" : "Crop selection"}
115
+ aria-label="Crop selection"
116
+ aria-pressed={cropMode}
117
+ >
118
+ <Crop size={16} weight={cropMode ? "fill" : "regular"} />
119
+ </button>
120
+ )}
102
121
  {motionPathCreateAvailable && (
103
122
  <button
104
123
  type="button"
@@ -0,0 +1,113 @@
1
+ import { roundToCenti } from "../../utils/rounding";
2
+
3
+ export interface ClipPathInsetSides {
4
+ top: number;
5
+ right: number;
6
+ bottom: number;
7
+ left: number;
8
+ }
9
+
10
+ export type ParsedInsetClipPathSides = ClipPathInsetSides & { radius: number };
11
+
12
+ function formatClipNumber(value: number): string {
13
+ const rounded = roundToCenti(value);
14
+ return Number.isInteger(rounded)
15
+ ? `${rounded}`
16
+ : rounded.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
17
+ }
18
+
19
+ function formatClipPx(value: number): string {
20
+ return `${formatClipNumber(Math.max(0, value))}px`;
21
+ }
22
+
23
+ function parseInsetLengthPx(value: string): number | null {
24
+ const normalized = value.trim();
25
+ if (normalized === "0") return 0;
26
+ const match = /^(-?\d+(?:\.\d+)?)px$/i.exec(normalized);
27
+ if (!match) return null;
28
+ const parsed = Number.parseFloat(match[1]);
29
+ return Number.isFinite(parsed) ? Math.max(0, parsed) : null;
30
+ }
31
+
32
+ function sidesFromInsetTokens(tokens: number[]): ClipPathInsetSides | null {
33
+ if (tokens.length < 1 || tokens.length > 4) return null;
34
+ // CSS shorthand expansion: T | T R | T R B | T R B L
35
+ const [top, right = top, bottom = top, left = right] = tokens;
36
+ if (top === undefined || right === undefined || bottom === undefined || left === undefined) {
37
+ return null;
38
+ }
39
+ return { top, right, bottom, left };
40
+ }
41
+
42
+ export function inferClipPathPreset(
43
+ value: string | undefined,
44
+ ): "none" | "inset" | "circle" | "custom" {
45
+ const normalized = value?.trim();
46
+ if (!normalized || normalized === "none") return "none";
47
+ if (/^inset\(/i.test(normalized)) return "inset";
48
+ if (/^circle\(/i.test(normalized)) return "circle";
49
+ return "custom";
50
+ }
51
+
52
+ export function parseInsetClipPathSides(
53
+ value: string | undefined,
54
+ ): ParsedInsetClipPathSides | null {
55
+ // Unambiguous pattern (no nested optional whitespace) to avoid polynomial
56
+ // backtracking on adversarial input; trim the payload instead.
57
+ const match = /^inset\(([^()]*)\)$/i.exec(value?.trim() ?? "");
58
+ if (!match) return null;
59
+ const parts = match[1]
60
+ .trim()
61
+ .replace(/\s+/g, " ")
62
+ .split(/ round /i);
63
+ const insetPart = parts[0]?.trim();
64
+ if (!insetPart || parts.length > 2) return null;
65
+
66
+ const tokens = insetPart.split(/\s+/).map(parseInsetLengthPx);
67
+ if (tokens.some((token) => token == null)) return null;
68
+ const numericTokens: number[] = [];
69
+ for (const token of tokens) {
70
+ if (token == null) return null;
71
+ numericTokens.push(token);
72
+ }
73
+ const sides = sidesFromInsetTokens(numericTokens);
74
+ if (!sides) return null;
75
+
76
+ const radiusPart = parts[1]?.trim();
77
+ const radius = radiusPart ? parseInsetLengthPx(radiusPart) : 0;
78
+ if (radius == null) return null;
79
+ return { ...sides, radius };
80
+ }
81
+
82
+ export function getClipPathInsetPx(value: string | undefined): number {
83
+ const parsed = parseInsetClipPathSides(value);
84
+ if (!parsed) return 0;
85
+ const { top, right, bottom, left } = parsed;
86
+ return top === right && top === bottom && top === left ? top : 0;
87
+ }
88
+
89
+ export function buildClipPathValue(
90
+ preset: "none" | "inset" | "circle" | "custom",
91
+ radiusValue: number,
92
+ fallback: string | undefined,
93
+ ) {
94
+ if (preset === "custom") return fallback?.trim() || "none";
95
+ if (preset === "circle") return "circle(50% at 50% 50%)";
96
+ if (preset === "inset") {
97
+ return `inset(0 round ${formatClipNumber(Math.max(0, radiusValue))}px)`;
98
+ }
99
+ return "none";
100
+ }
101
+
102
+ export function buildInsetClipPathSides(sides: ClipPathInsetSides, radiusPx: number = 0): string {
103
+ const values = [sides.top, sides.right, sides.bottom, sides.left].map(formatClipPx);
104
+ const [top, right, bottom, left] = values;
105
+ const inset =
106
+ top === right && top === bottom && top === left ? top : `${top} ${right} ${bottom} ${left}`;
107
+ const radius = Math.max(0, radiusPx);
108
+ return radius > 0 ? `inset(${inset} round ${formatClipNumber(radius)}px)` : `inset(${inset})`;
109
+ }
110
+
111
+ export function buildInsetClipPathValue(insetPx: number, radiusValue: number): string {
112
+ return `inset(${formatClipPx(insetPx)} round ${formatClipNumber(Math.max(0, radiusValue))}px)`;
113
+ }
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ cropRectFromInsets,
4
+ resolveCropInsetFromEdgeDrag,
5
+ resolveCropInsetFromMoveDrag,
6
+ } from "./domEditOverlayCrop";
7
+
8
+ describe("resolveCropInsetFromEdgeDrag", () => {
9
+ const startInsets = { top: 10, right: 20, bottom: 30, left: 40 };
10
+
11
+ it("converts overlay-space edge movement into element-space inset changes", () => {
12
+ expect(
13
+ resolveCropInsetFromEdgeDrag({
14
+ edge: "left",
15
+ startInsets,
16
+ deltaX: 20,
17
+ deltaY: 0,
18
+ scaleX: 2,
19
+ scaleY: 1,
20
+ width: 200,
21
+ height: 120,
22
+ }),
23
+ ).toEqual({ top: 10, right: 20, bottom: 30, left: 50 });
24
+
25
+ expect(
26
+ resolveCropInsetFromEdgeDrag({
27
+ edge: "right",
28
+ startInsets,
29
+ deltaX: 20,
30
+ deltaY: 0,
31
+ scaleX: 2,
32
+ scaleY: 1,
33
+ width: 200,
34
+ height: 120,
35
+ }),
36
+ ).toEqual({ top: 10, right: 10, bottom: 30, left: 40 });
37
+ });
38
+
39
+ it("clamps edited insets so opposing sides never overlap", () => {
40
+ expect(
41
+ resolveCropInsetFromEdgeDrag({
42
+ edge: "left",
43
+ startInsets,
44
+ deltaX: 400,
45
+ deltaY: 0,
46
+ scaleX: 1,
47
+ scaleY: 1,
48
+ width: 100,
49
+ height: 120,
50
+ }),
51
+ ).toEqual({ top: 10, right: 20, bottom: 30, left: 80 });
52
+
53
+ expect(
54
+ resolveCropInsetFromEdgeDrag({
55
+ edge: "top",
56
+ startInsets,
57
+ deltaX: 0,
58
+ deltaY: -40,
59
+ scaleX: 1,
60
+ scaleY: 2,
61
+ width: 200,
62
+ height: 120,
63
+ }),
64
+ ).toEqual({ top: 0, right: 20, bottom: 30, left: 40 });
65
+ });
66
+ });
67
+
68
+ describe("cropRectFromInsets", () => {
69
+ it("shrinks the overlay rect by scaled insets", () => {
70
+ expect(
71
+ cropRectFromInsets(
72
+ { left: 100, top: 50, width: 200, height: 100 },
73
+ { top: 10, right: 40, bottom: 20, left: 30 },
74
+ 2,
75
+ 1,
76
+ ),
77
+ ).toEqual({ left: 160, top: 60, width: 60, height: 70 });
78
+ });
79
+
80
+ it("clamps to zero size when insets exceed the rect", () => {
81
+ const r = cropRectFromInsets(
82
+ { left: 0, top: 0, width: 100, height: 100 },
83
+ { top: 300, right: 300, bottom: 300, left: 300 },
84
+ 1,
85
+ 1,
86
+ );
87
+ expect(r.width).toBe(0);
88
+ expect(r.height).toBe(0);
89
+ });
90
+ });
91
+
92
+ describe("resolveCropInsetFromMoveDrag", () => {
93
+ const startInsets = { top: 10, right: 20, bottom: 30, left: 40 };
94
+
95
+ it("shifts opposing insets together so the crop size stays constant", () => {
96
+ expect(
97
+ resolveCropInsetFromMoveDrag({ startInsets, deltaX: 20, deltaY: -10, scaleX: 2, scaleY: 1 }),
98
+ ).toEqual({ top: 0, right: 10, bottom: 40, left: 50 });
99
+ });
100
+
101
+ it("clamps the window inside the element bounds", () => {
102
+ expect(
103
+ resolveCropInsetFromMoveDrag({ startInsets, deltaX: 999, deltaY: 999, scaleX: 1, scaleY: 1 }),
104
+ ).toEqual({ top: 40, right: 0, bottom: 0, left: 60 });
105
+ });
106
+ });
@@ -0,0 +1,115 @@
1
+ import { parseInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
2
+
3
+ export type CropEdge = "top" | "right" | "bottom" | "left";
4
+
5
+ export interface CropScreenRect {
6
+ left: number;
7
+ top: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+
12
+ /** Element-space insets → the cropped region in overlay (screen) space. */
13
+ export function cropRectFromInsets(
14
+ rect: CropScreenRect,
15
+ insets: ClipPathInsetSides,
16
+ scaleX: number,
17
+ scaleY: number,
18
+ ): CropScreenRect {
19
+ const sx = scaleX > 0 ? scaleX : 1;
20
+ const sy = scaleY > 0 ? scaleY : 1;
21
+ const left = rect.left + insets.left * sx;
22
+ const top = rect.top + insets.top * sy;
23
+ return {
24
+ left,
25
+ top,
26
+ width: Math.max(0, rect.width - (insets.left + insets.right) * sx),
27
+ height: Math.max(0, rect.height - (insets.top + insets.bottom) * sy),
28
+ };
29
+ }
30
+
31
+ /** Current inset crop of an element (inline first, computed fallback), or zeros. */
32
+ export function readElementCropInsets(element: HTMLElement): ClipPathInsetSides & {
33
+ radius: number;
34
+ } {
35
+ const inline = element.style.getPropertyValue("clip-path").trim();
36
+ const value =
37
+ inline || element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
38
+ const parsed = parseInsetClipPathSides(value === "none" ? "" : value);
39
+ return parsed ?? { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
40
+ }
41
+
42
+ export interface CropInsetDragInput {
43
+ edge: CropEdge;
44
+ startInsets: ClipPathInsetSides;
45
+ deltaX: number;
46
+ deltaY: number;
47
+ scaleX: number;
48
+ scaleY: number;
49
+ width: number;
50
+ height: number;
51
+ }
52
+
53
+ function clampInset(value: number, max: number): number {
54
+ if (!Number.isFinite(value)) return 0;
55
+ return Math.min(Math.max(0, value), Math.max(0, max));
56
+ }
57
+
58
+ export function resolveCropInsetFromEdgeDrag(input: CropInsetDragInput): ClipPathInsetSides {
59
+ const scaleX = input.scaleX > 0 ? input.scaleX : 1;
60
+ const scaleY = input.scaleY > 0 ? input.scaleY : 1;
61
+ const next = { ...input.startInsets };
62
+
63
+ if (input.edge === "left") {
64
+ next.left = clampInset(
65
+ input.startInsets.left + input.deltaX / scaleX,
66
+ input.width - next.right,
67
+ );
68
+ } else if (input.edge === "right") {
69
+ next.right = clampInset(
70
+ input.startInsets.right - input.deltaX / scaleX,
71
+ input.width - next.left,
72
+ );
73
+ } else if (input.edge === "top") {
74
+ next.top = clampInset(
75
+ input.startInsets.top + input.deltaY / scaleY,
76
+ input.height - next.bottom,
77
+ );
78
+ } else {
79
+ next.bottom = clampInset(
80
+ input.startInsets.bottom - input.deltaY / scaleY,
81
+ input.height - next.top,
82
+ );
83
+ }
84
+
85
+ return next;
86
+ }
87
+
88
+ /** Drag the whole crop window: both opposing insets shift together, the crop
89
+ * size stays constant, clamped inside the element bounds. */
90
+ export function resolveCropInsetFromMoveDrag(input: {
91
+ startInsets: ClipPathInsetSides;
92
+ deltaX: number;
93
+ deltaY: number;
94
+ scaleX: number;
95
+ scaleY: number;
96
+ }): ClipPathInsetSides {
97
+ const sx = input.scaleX > 0 ? input.scaleX : 1;
98
+ const sy = input.scaleY > 0 ? input.scaleY : 1;
99
+ const totalX = input.startInsets.left + input.startInsets.right;
100
+ const totalY = input.startInsets.top + input.startInsets.bottom;
101
+ const left = Math.min(Math.max(0, input.startInsets.left + input.deltaX / sx), totalX);
102
+ const top = Math.min(Math.max(0, input.startInsets.top + input.deltaY / sy), totalY);
103
+ return { left, right: totalX - left, top, bottom: totalY - top };
104
+ }
105
+
106
+ /** Display-only hug: shrink a projected rect by the element's inset crop.
107
+ * For rects nothing writes back to (e.g. the hover ring). */
108
+ export function hugRectForElement(
109
+ rect: CropScreenRect & { editScaleX: number; editScaleY: number },
110
+ element: HTMLElement,
111
+ ): CropScreenRect {
112
+ const insets = readElementCropInsets(element);
113
+ if (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0) return rect;
114
+ return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY);
115
+ }
@@ -1,5 +1,6 @@
1
1
  import { type DomEditSelection, findElementForSelection } from "./domEditing";
2
2
  import { isElementVisibleThroughAncestors } from "./domEditingDom";
3
+ import { hugRectForElement } from "./domEditOverlayCrop";
3
4
 
4
5
  export interface OverlayRect {
5
6
  left: number;
@@ -84,6 +85,19 @@ export function resolveDomEditCoordinateScale(input: {
84
85
  };
85
86
  }
86
87
 
88
+ /** toOverlayRect, then shrunk to the element's visible (inset-cropped) region.
89
+ * For consumers that reason about what's ON SCREEN — snap targets, marquee
90
+ * hit-tests, display outlines. The selection box must keep the full rect
91
+ * (it is the gesture coordinate basis). */
92
+ export function toVisibleOverlayRect(
93
+ overlayEl: HTMLDivElement,
94
+ iframe: HTMLIFrameElement,
95
+ element: HTMLElement,
96
+ ): OverlayRect | null {
97
+ const rect = toOverlayRect(overlayEl, iframe, element);
98
+ return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
99
+ }
100
+
87
101
  export function toOverlayRect(
88
102
  overlayEl: HTMLDivElement,
89
103
  iframe: HTMLIFrameElement,
@@ -0,0 +1,39 @@
1
+ import type { DomEditSelection } from "./domEditing";
2
+
3
+ export interface DomEditSelectionShapeStyles {
4
+ borderRadius: string | number;
5
+ clipPath?: string;
6
+ }
7
+
8
+ export function readDomEditSelectionShapeStyles(
9
+ selection: DomEditSelection | null,
10
+ ): DomEditSelectionShapeStyles {
11
+ const fallback = {
12
+ borderRadius: 8,
13
+ clipPath: undefined,
14
+ };
15
+ if (!selection?.element) return fallback;
16
+ try {
17
+ const tag = selection.element.tagName.toLowerCase();
18
+ if (tag === "svg" || tag === "img" || tag === "video" || tag === "canvas") return fallback;
19
+ const win = selection.element.ownerDocument.defaultView;
20
+ if (!win) return fallback;
21
+ const cs = win.getComputedStyle(selection.element);
22
+ const borderRadius = cs.borderRadius;
23
+ const clipPath = cs.clipPath;
24
+ return {
25
+ borderRadius: borderRadius && borderRadius !== "0px" ? borderRadius : 4,
26
+ clipPath: clipPath && clipPath !== "none" ? clipPath : undefined,
27
+ };
28
+ } catch {
29
+ return fallback;
30
+ }
31
+ }
32
+
33
+ /** Selection-box chrome: none when the crop-outline child draws it, inset
34
+ * shadow for clip-mirrored shapes, plain border otherwise. */
35
+ export function resolveBoxChromeClass(hasCropOutline: boolean, boxClipPath?: string): string {
36
+ if (hasCropOutline) return "";
37
+ if (boxClipPath) return "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]";
38
+ return "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]";
39
+ }
@@ -76,6 +76,7 @@ describe("resolveDomEditCapabilities", () => {
76
76
  canEditStyles: true,
77
77
  canMove: true,
78
78
  canResize: true,
79
+ canCrop: true,
79
80
  canApplyManualOffset: true,
80
81
  canApplyManualSize: true,
81
82
  canApplyManualRotation: true,
@@ -424,6 +425,7 @@ describe("resolveDomEditSelection", () => {
424
425
  canEditStyles: false,
425
426
  canMove: true,
426
427
  canResize: true,
428
+ canCrop: true,
427
429
  canApplyManualOffset: false,
428
430
  canApplyManualSize: false,
429
431
  canApplyManualRotation: false,
@@ -28,6 +28,7 @@ export function isElementComputedVisible(el: HTMLElement): boolean {
28
28
 
29
29
  const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
30
30
 
31
+ // fallow-ignore-next-line complexity
31
32
  function hasVisualPresence(el: HTMLElement): boolean {
32
33
  const win = el.ownerDocument.defaultView;
33
34
  if (!win) return false;
@@ -236,9 +237,13 @@ export function isLargeRasterDomEditSelection(
236
237
 
237
238
  // ─── Element finders ──────────────────────────────────────────────────────────
238
239
 
240
+ type FindElementSelection = Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex"> & {
241
+ sourceFile?: string;
242
+ };
243
+
239
244
  export function findElementForSelection(
240
245
  doc: Document,
241
- selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
246
+ selection: FindElementSelection,
242
247
  activeCompositionPath: string | null = null,
243
248
  ): HTMLElement | null {
244
249
  if (selection.hfId) {
@@ -259,6 +264,7 @@ export function findElementForSelection(
259
264
 
260
265
  if (!selection.selector) return null;
261
266
 
267
+ // fallow-ignore-next-line code-duplication
262
268
  if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
263
269
  const matches = querySelectorAllSafely(doc, selection.selector).filter(
264
270
  (candidate): candidate is HTMLElement =>
@@ -270,6 +276,7 @@ export function findElementForSelection(
270
276
  return matches[selection.selectorIndex] ?? null;
271
277
  }
272
278
 
279
+ // fallow-ignore-next-line code-duplication
273
280
  const matches = querySelectorAllSafely(doc, selection.selector).filter(
274
281
  (candidate): candidate is HTMLElement =>
275
282
  isHtmlElement(candidate) &&