@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
@@ -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,
@@ -50,6 +50,9 @@ export const CURATED_STYLE_PROPERTIES = [
50
50
  export interface DomEditCapabilities {
51
51
  canSelect: boolean;
52
52
  canEditStyles: boolean;
53
+ /** Can take a non-destructive `clip-path: inset()` crop — broader than
54
+ * canEditStyles (a sub-composition host is croppable from the parent view). */
55
+ canCrop: boolean;
53
56
  /** Directly editable authored left/top style fields. Canvas drag uses manual edits instead. */
54
57
  canMove: boolean;
55
58
  /** Directly editable authored width/height style fields. Canvas resize uses manual edits instead. */
@@ -1,10 +1,11 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { useCallback, useRef, useState } from "react";
2
3
  import type { DomEditSelection } from "./domEditing";
3
4
  import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
4
5
  import { isElementComputedVisible } from "./domEditingElement";
5
6
  import { coversComposition } from "../../utils/studioPreviewHelpers";
6
7
  import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
7
- import { toOverlayRect } from "./domEditOverlayGeometry";
8
+ import { toVisibleOverlayRect } from "./domEditOverlayGeometry";
8
9
 
9
10
  interface MarqueeState {
10
11
  startX: number;
@@ -57,7 +58,7 @@ function collectMarqueeHits(
57
58
  const el = item.element;
58
59
  if (!isElementComputedVisible(el)) continue;
59
60
  if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
60
- const overlayRect = toOverlayRect(overlayEl, iframe, el);
61
+ const overlayRect = toVisibleOverlayRect(overlayEl, iframe, el);
61
62
  if (!overlayRect) continue;
62
63
  const r: Rect = {
63
64
  left: overlayRect.left,
@@ -160,6 +160,16 @@ const BOX_SHADOW_PRESETS = {
160
160
 
161
161
  export type BoxShadowPreset = keyof typeof BOX_SHADOW_PRESETS | "custom";
162
162
 
163
+ export {
164
+ buildClipPathValue,
165
+ buildInsetClipPathSides,
166
+ buildInsetClipPathValue,
167
+ getClipPathInsetPx,
168
+ inferClipPathPreset,
169
+ parseInsetClipPathSides,
170
+ type ClipPathInsetSides,
171
+ } from "./clipPathHelpers";
172
+
163
173
  /* ------------------------------------------------------------------ */
164
174
  /* Shared types */
165
175
  /* ------------------------------------------------------------------ */
@@ -314,23 +324,6 @@ export function buildBoxShadowPresetValue(
314
324
  return BOX_SHADOW_PRESETS[preset];
315
325
  }
316
326
 
317
- export function inferClipPathPreset(
318
- value: string | undefined,
319
- ): "none" | "inset" | "circle" | "custom" {
320
- const normalized = value?.trim();
321
- if (!normalized || normalized === "none") return "none";
322
- if (/^inset\(/i.test(normalized)) return "inset";
323
- if (/^circle\(/i.test(normalized)) return "circle";
324
- return "custom";
325
- }
326
-
327
- export function getClipPathInsetPx(value: string | undefined): number {
328
- const match = /^inset\(\s*(-?\d+(?:\.\d+)?)px\b/i.exec(value?.trim() ?? "");
329
- if (!match) return 0;
330
- const parsed = Number.parseFloat(match[1]);
331
- return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
332
- }
333
-
334
327
  export function buildStrokeWidthStyleUpdates(
335
328
  nextWidth: string,
336
329
  currentBorderStyle: string | undefined,
@@ -359,23 +352,6 @@ export function buildStrokeStyleUpdates(
359
352
  return updates;
360
353
  }
361
354
 
362
- export function buildClipPathValue(
363
- preset: "none" | "inset" | "circle" | "custom",
364
- radiusValue: number,
365
- fallback: string | undefined,
366
- ) {
367
- if (preset === "custom") return fallback?.trim() || "none";
368
- if (preset === "circle") return "circle(50% at 50% 50%)";
369
- if (preset === "inset") {
370
- return `inset(0 round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
371
- }
372
- return "none";
373
- }
374
-
375
- export function buildInsetClipPathValue(insetPx: number, radiusValue: number): string {
376
- return `inset(${formatNumericValue(Math.max(0, insetPx))}px round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
377
- }
378
-
379
355
  export function adjustNumericToken(
380
356
  value: string,
381
357
  direction: 1 | -1,
@@ -1,10 +1,11 @@
1
1
  import { useEffect, useState } from "react";
2
- import { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
2
+ import { Eye, Layers, Palette, Scissors, Settings, Square, Zap } from "../../icons/SystemIcons";
3
3
  import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
4
4
  import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
5
5
  import {
6
6
  buildBoxShadowPresetValue,
7
7
  buildClipPathValue,
8
+ buildInsetClipPathSides,
8
9
  buildInsetClipPathValue,
9
10
  buildStrokeStyleUpdates,
10
11
  buildStrokeWidthStyleUpdates,
@@ -17,10 +18,12 @@ import {
17
18
  inferClipPathPreset,
18
19
  LABEL,
19
20
  normalizePanelPxValue,
21
+ parseInsetClipPathSides,
20
22
  parseNumericValue,
21
23
  parsePxMetricValue,
22
24
  RESPONSIVE_GRID,
23
25
  setCssFilterFunctionPx,
26
+ type ClipPathInsetSides,
24
27
  type BoxShadowPreset,
25
28
  } from "./propertyPanelHelpers";
26
29
  import {
@@ -35,6 +38,7 @@ import { ColorField } from "./propertyPanelColor";
35
38
  import { GradientField, ImageFillField } from "./propertyPanelFill";
36
39
  import { BorderRadiusEditor } from "./BorderRadiusEditor";
37
40
 
41
+ // fallow-ignore-next-line complexity
38
42
  export function StyleSections({
39
43
  projectId,
40
44
  element,
@@ -43,6 +47,8 @@ export function StyleSections({
43
47
  onSetStyle,
44
48
  onImportAssets,
45
49
  gsapBorderRadius,
50
+ cropMode = false,
51
+ onCropModeChange,
46
52
  }: {
47
53
  projectId: string;
48
54
  element: DomEditSelection;
@@ -51,6 +57,8 @@ export function StyleSections({
51
57
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
52
58
  onImportAssets?: (files: FileList) => Promise<string[]>;
53
59
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
60
+ cropMode?: boolean;
61
+ onCropModeChange?: (active: boolean) => void;
54
62
  }) {
55
63
  const styleEditingDisabled = !element.capabilities.canEditStyles;
56
64
  const isFlex = styles.display === "flex" || styles.display === "inline-flex";
@@ -86,7 +94,16 @@ export function StyleSections({
86
94
  const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
87
95
  const clipPathValue = styles["clip-path"] || "none";
88
96
  const clipPathPreset = inferClipPathPreset(clipPathValue);
97
+ const parsedClipInsets = parseInsetClipPathSides(clipPathValue);
89
98
  const clipInsetValue = getClipPathInsetPx(clipPathValue);
99
+ const clipInsetSides = parsedClipInsets ?? {
100
+ top: clipInsetValue,
101
+ right: clipInsetValue,
102
+ bottom: clipInsetValue,
103
+ left: clipInsetValue,
104
+ radius: radiusValue,
105
+ };
106
+ const showClipInsetSides = clipPathPreset === "inset" || parsedClipInsets != null;
90
107
  const backgroundImage = styles["background-image"] ?? "none";
91
108
  const hasTextControls = isTextEditableSelection(element);
92
109
 
@@ -117,6 +134,19 @@ export function StyleSections({
117
134
  }
118
135
  };
119
136
 
137
+ const commitClipInsetSide = (side: keyof ClipPathInsetSides, nextValue: string) => {
138
+ const next = parsePxMetricValue(nextValue);
139
+ if (next == null) return;
140
+ const sides: ClipPathInsetSides = {
141
+ top: clipInsetSides.top,
142
+ right: clipInsetSides.right,
143
+ bottom: clipInsetSides.bottom,
144
+ left: clipInsetSides.left,
145
+ };
146
+ sides[side] = next;
147
+ onSetStyle("clip-path", buildInsetClipPathSides(sides, clipInsetSides.radius));
148
+ };
149
+
120
150
  return (
121
151
  <>
122
152
  {isFlex && (
@@ -344,6 +374,50 @@ export function StyleSections({
344
374
  }
345
375
  />
346
376
  </div>
377
+ {showClipInsetSides && (
378
+ <div className="grid gap-2">
379
+ <div className="grid grid-cols-4 gap-2">
380
+ <MetricField
381
+ label="T"
382
+ value={formatPxMetricValue(clipInsetSides.top)}
383
+ disabled={styleEditingDisabled}
384
+ onCommit={(next) => commitClipInsetSide("top", next)}
385
+ />
386
+ <MetricField
387
+ label="R"
388
+ value={formatPxMetricValue(clipInsetSides.right)}
389
+ disabled={styleEditingDisabled}
390
+ onCommit={(next) => commitClipInsetSide("right", next)}
391
+ />
392
+ <MetricField
393
+ label="B"
394
+ value={formatPxMetricValue(clipInsetSides.bottom)}
395
+ disabled={styleEditingDisabled}
396
+ onCommit={(next) => commitClipInsetSide("bottom", next)}
397
+ />
398
+ <MetricField
399
+ label="L"
400
+ value={formatPxMetricValue(clipInsetSides.left)}
401
+ disabled={styleEditingDisabled}
402
+ onCommit={(next) => commitClipInsetSide("left", next)}
403
+ />
404
+ </div>
405
+ </div>
406
+ )}
407
+ <button
408
+ type="button"
409
+ disabled={styleEditingDisabled || !onCropModeChange}
410
+ aria-pressed={cropMode}
411
+ onClick={() => onCropModeChange?.(!cropMode)}
412
+ className={`inline-flex h-8 w-fit items-center gap-1.5 rounded-md border px-2 text-[11px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
413
+ cropMode
414
+ ? "border-studio-accent/60 bg-studio-accent/15 text-studio-accent"
415
+ : "border-panel-border bg-panel-input text-panel-text-2 hover:text-white"
416
+ }`}
417
+ >
418
+ <Scissors size={13} />
419
+ Crop
420
+ </button>
347
421
  </div>
348
422
  </Section>
349
423
 
@@ -108,4 +108,6 @@ export interface PropertyPanelProps {
108
108
  recordingState?: "idle" | "recording" | "preview";
109
109
  recordingDuration?: number;
110
110
  onToggleRecording?: () => void;
111
+ cropMode?: boolean;
112
+ onCropModeChange?: (active: boolean) => void;
111
113
  }
@@ -1,9 +1,8 @@
1
- // fallow-ignore-file unused-file
2
1
  // fallow-ignore-file code-duplication
3
2
  import type { DomEditSelection } from "./domEditing";
4
3
  import {
5
4
  isElementVisibleForOverlay,
6
- toOverlayRect,
5
+ toVisibleOverlayRect,
7
6
  type OverlayRect,
8
7
  } from "./domEditOverlayGeometry";
9
8
  import {
@@ -106,7 +105,7 @@ export function collectSnapContext(input: {
106
105
  id: string;
107
106
  }> = [];
108
107
  for (let i = 0; i < elements.length; i++) {
109
- const rect = toOverlayRect(input.overlayEl, input.iframe, elements[i]);
108
+ const rect = toVisibleOverlayRect(input.overlayEl, input.iframe, elements[i]);
110
109
  if (rect) entries.push({ rect, id: `snap-target-${i}` });
111
110
  }
112
111