@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.
- package/dist/assets/index-0P10SwC_.css +1 -0
- package/dist/assets/{index-ClUipc8i.js → index-312a3Ceu.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B9YvRJz1.js} +194 -194
- package/dist/assets/{index-Ykq7ihge.js → index-CQHiecE7.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
- package/dist/chunk-RCLGSZ7C.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.html +2 -2
- package/dist/index.js +2171 -1458
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +7 -6
- package/src/components/StudioPreviewArea.tsx +8 -0
- package/src/components/StudioRightPanel.tsx +6 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +111 -108
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +48 -0
- package/src/components/editor/PropertyPanel.tsx +9 -30
- 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/domEditingTypes.ts +3 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/propertyPanelHelpers.ts +10 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +2 -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/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/store/playerStore.ts +14 -0
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /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 {
|
|
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 =
|
|
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
|
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|