@hyperframes/studio 0.7.38 → 0.7.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
- package/dist/chunk-BA66NM4L.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +47 -29
- package/dist/index.html +2 -2
- package/dist/index.js +4432 -3045
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +9 -7
- package/src/components/StudioPreviewArea.tsx +14 -0
- package/src/components/StudioRightPanel.tsx +9 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +140 -151
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +73 -0
- package/src/components/editor/PropertyPanel.tsx +42 -37
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +21 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +3 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +19 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
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
|
-
<
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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:
|
|
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) &&
|