@hyperframes/studio 0.7.37 → 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/hyperframes-player-BBrKzTGd.js +459 -0
- package/dist/assets/index-0P10SwC_.css +1 -0
- package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
- package/dist/assets/index-B9YvRJz1.js +396 -0
- package/dist/assets/{index-Ct-X2wlf.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 +48 -10
- package/dist/index.html +2 -2
- package/dist/index.js +6045 -3507
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -24
- package/src/components/AskAgentModal.tsx +23 -6
- package/src/components/LintModal.tsx +40 -10
- package/src/components/MediaPreview.tsx +36 -1
- package/src/components/SaveQueuePausedBanner.tsx +7 -6
- package/src/components/StudioErrorBoundary.tsx +19 -31
- package/src/components/StudioFeedbackBar.tsx +11 -2
- package/src/components/StudioGlobalDragOverlay.tsx +1 -1
- package/src/components/StudioHeader.tsx +184 -106
- package/src/components/StudioLeftSidebar.tsx +24 -3
- package/src/components/StudioOverlays.tsx +34 -8
- package/src/components/StudioPreviewArea.tsx +8 -0
- package/src/components/StudioRightPanel.tsx +160 -9
- package/src/components/StudioSplash.tsx +6 -3
- package/src/components/StudioToast.tsx +12 -11
- package/src/components/TimelineToolbar.test.tsx +9 -3
- package/src/components/TimelineToolbar.tsx +63 -29
- 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 +27 -47
- 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/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -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/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
- package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
- package/src/components/editor/propertyPanelHelpers.ts +32 -125
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +113 -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/nle/CompositionBreadcrumb.tsx +3 -2
- package/src/components/nle/NLELayout.tsx +54 -51
- package/src/components/nle/NLEPreview.tsx +41 -5
- package/src/components/nle/TimelineResizeDivider.tsx +97 -0
- package/src/components/nle/usePreviewBlockDrop.ts +31 -7
- package/src/components/renders/RenderQueue.tsx +125 -24
- package/src/components/renders/RenderQueueItem.tsx +132 -64
- package/src/components/renders/useRenderQueue.ts +136 -24
- 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/components/storyboard/StoryboardFrameFocus.tsx +39 -16
- package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
- package/src/components/storyboard/StoryboardView.tsx +6 -0
- package/src/components/studioColorGradingScope.ts +124 -0
- package/src/components/studioMediaJobs.ts +123 -0
- package/src/components/ui/Button.tsx +20 -11
- package/src/components/ui/HyperframesLoader.tsx +9 -2
- package/src/components/ui/SearchInput.tsx +53 -0
- package/src/components/ui/Tooltip.tsx +52 -6
- package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
- package/src/components/ui/useDialogBehavior.ts +83 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +7 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/useCompositionContentLoader.ts +40 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useEditorSave.ts +14 -0
- package/src/hooks/useFileManager.ts +39 -25
- package/src/hooks/useFrameCapture.ts +12 -1
- package/src/hooks/usePanelLayout.ts +1 -0
- package/src/hooks/usePreviewPersistence.ts +24 -15
- package/src/hooks/useStudioContextValue.ts +5 -0
- package/src/hooks/useToast.ts +66 -13
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/store/playerStore.ts +14 -0
- package/src/styles/studio.css +61 -0
- package/src/utils/sdkResolverShadow.test.ts +53 -3
- package/src/utils/sdkResolverShadow.ts +41 -1
- package/src/utils/studioPendingEdits.test.ts +43 -0
- package/src/utils/studioPendingEdits.ts +45 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
- package/dist/assets/index-DmkOvZns.css +0 -1
- package/dist/assets/index-SYSbQyK4.js +0 -396
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
buildInsetClipPathSides,
|
|
3
4
|
buildStrokeStyleUpdates,
|
|
4
5
|
buildStrokeWidthStyleUpdates,
|
|
5
6
|
getClipPathInsetPx,
|
|
@@ -7,6 +8,7 @@ import {
|
|
|
7
8
|
inferBoxShadowPreset,
|
|
8
9
|
inferClipPathPreset,
|
|
9
10
|
normalizePanelPxValue,
|
|
11
|
+
parseInsetClipPathSides,
|
|
10
12
|
setCssFilterFunctionPx,
|
|
11
13
|
} from "./PropertyPanel";
|
|
12
14
|
|
|
@@ -49,6 +51,52 @@ describe("PropertyPanel style helpers", () => {
|
|
|
49
51
|
expect(getClipPathInsetPx("circle(50% at 50% 50%)")).toBe(0);
|
|
50
52
|
});
|
|
51
53
|
|
|
54
|
+
it("builds and parses 4-side inset clip paths without losing radius", () => {
|
|
55
|
+
expect(buildInsetClipPathSides({ top: 10, right: 20, bottom: 30, left: 40 }, 6)).toBe(
|
|
56
|
+
"inset(10px 20px 30px 40px round 6px)",
|
|
57
|
+
);
|
|
58
|
+
expect(parseInsetClipPathSides("inset(10px 20px 30px 40px round 6px)")).toEqual({
|
|
59
|
+
top: 10,
|
|
60
|
+
right: 20,
|
|
61
|
+
bottom: 30,
|
|
62
|
+
left: 40,
|
|
63
|
+
radius: 6,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("emits the single-value inset form when all sides are equal", () => {
|
|
68
|
+
expect(buildInsetClipPathSides({ top: 12.5, right: 12.5, bottom: 12.5, left: 12.5 })).toBe(
|
|
69
|
+
"inset(12.5px)",
|
|
70
|
+
);
|
|
71
|
+
expect(parseInsetClipPathSides("inset(12.5px)")).toEqual({
|
|
72
|
+
top: 12.5,
|
|
73
|
+
right: 12.5,
|
|
74
|
+
bottom: 12.5,
|
|
75
|
+
left: 12.5,
|
|
76
|
+
radius: 0,
|
|
77
|
+
});
|
|
78
|
+
expect(getClipPathInsetPx("inset(12.5px 12.5px 12.5px 12.5px)")).toBe(12.5);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("accepts CSS shorthand inset values and rejects unsupported clip paths", () => {
|
|
82
|
+
expect(parseInsetClipPathSides("inset(10px 20px)")).toEqual({
|
|
83
|
+
top: 10,
|
|
84
|
+
right: 20,
|
|
85
|
+
bottom: 10,
|
|
86
|
+
left: 20,
|
|
87
|
+
radius: 0,
|
|
88
|
+
});
|
|
89
|
+
expect(parseInsetClipPathSides("inset(10px 20px 30px)")).toEqual({
|
|
90
|
+
top: 10,
|
|
91
|
+
right: 20,
|
|
92
|
+
bottom: 30,
|
|
93
|
+
left: 20,
|
|
94
|
+
radius: 0,
|
|
95
|
+
});
|
|
96
|
+
expect(parseInsetClipPathSides("inset(10%)")).toBeNull();
|
|
97
|
+
expect(parseInsetClipPathSides("circle(50% at 50% 50%)")).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
52
100
|
it("keeps stroke width and style edits visually effective", () => {
|
|
53
101
|
expect(buildStrokeWidthStyleUpdates("3px", "none")).toEqual([
|
|
54
102
|
["border-width", "3px"],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { Move } from "../../icons/SystemIcons";
|
|
3
3
|
import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
|
4
4
|
import { useStudioShellContext } from "../../contexts/StudioContext";
|
|
5
5
|
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
|
@@ -22,18 +22,16 @@ import { TextSection, StyleSections } from "./propertyPanelSections";
|
|
|
22
22
|
import { GsapAnimationSection } from "./GsapAnimationSection";
|
|
23
23
|
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
|
|
24
24
|
import { KeyframeNavigation } from "./KeyframeNavigation";
|
|
25
|
-
import {
|
|
26
|
-
STUDIO_COLOR_GRADING_ENABLED,
|
|
27
|
-
STUDIO_GSAP_PANEL_ENABLED,
|
|
28
|
-
STUDIO_KEYFRAMES_ENABLED,
|
|
29
|
-
} from "./manualEditingAvailability";
|
|
25
|
+
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
|
|
30
26
|
import { usePlayerStore, liveTime } from "../../player";
|
|
31
27
|
import { TimingSection } from "./propertyPanelTimingSection";
|
|
32
28
|
import { type PropertyPanelProps } from "./propertyPanelHelpers";
|
|
33
29
|
import { GestureRecordPanelButton } from "./GestureRecordControl";
|
|
30
|
+
import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
|
|
34
31
|
|
|
35
32
|
// Re-export helpers that external consumers import from this module
|
|
36
33
|
export {
|
|
34
|
+
buildInsetClipPathSides,
|
|
37
35
|
buildStrokeStyleUpdates,
|
|
38
36
|
buildStrokeWidthStyleUpdates,
|
|
39
37
|
getCssFilterFunctionPx,
|
|
@@ -41,6 +39,7 @@ export {
|
|
|
41
39
|
inferBoxShadowPreset,
|
|
42
40
|
inferClipPathPreset,
|
|
43
41
|
normalizePanelPxValue,
|
|
42
|
+
parseInsetClipPathSides,
|
|
44
43
|
setCssFilterFunctionPx,
|
|
45
44
|
} from "./propertyPanelHelpers";
|
|
46
45
|
|
|
@@ -57,7 +56,9 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
57
56
|
onSetStyle,
|
|
58
57
|
onSetAttribute,
|
|
59
58
|
onSetAttributeLive,
|
|
59
|
+
onApplyColorGradingScope,
|
|
60
60
|
onSetHtmlAttribute,
|
|
61
|
+
onRemoveBackground,
|
|
61
62
|
onSetManualOffset,
|
|
62
63
|
onSetManualSize,
|
|
63
64
|
onSetManualRotation,
|
|
@@ -96,6 +97,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
96
97
|
recordingState,
|
|
97
98
|
recordingDuration,
|
|
98
99
|
onToggleRecording,
|
|
100
|
+
cropMode,
|
|
101
|
+
onCropModeChange,
|
|
99
102
|
}: PropertyPanelProps) {
|
|
100
103
|
const styles = element?.computedStyles ?? EMPTY_STYLES;
|
|
101
104
|
const { showToast } = useStudioShellContext();
|
|
@@ -162,35 +165,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
162
165
|
};
|
|
163
166
|
|
|
164
167
|
if (!element) {
|
|
165
|
-
return
|
|
166
|
-
<div className="flex h-full flex-col bg-neutral-900">
|
|
167
|
-
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
|
168
|
-
{multiSelectCount > 1 ? (
|
|
169
|
-
<>
|
|
170
|
-
<Layers size={18} className="mb-3 text-neutral-600" />
|
|
171
|
-
<p className="text-sm font-medium text-neutral-200">
|
|
172
|
-
{multiSelectCount} elements selected
|
|
173
|
-
</p>
|
|
174
|
-
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
|
175
|
-
Select a single element to edit its properties. Click an element in the preview or
|
|
176
|
-
use the timeline layer panel.
|
|
177
|
-
</p>
|
|
178
|
-
</>
|
|
179
|
-
) : (
|
|
180
|
-
<>
|
|
181
|
-
<Eye size={18} className="mb-3 text-neutral-600" />
|
|
182
|
-
<p className="text-sm font-medium text-neutral-200">
|
|
183
|
-
Select an element in the preview.
|
|
184
|
-
</p>
|
|
185
|
-
<p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
|
|
186
|
-
The inspector is tuned for element edits with safer geometry controls, color
|
|
187
|
-
picking, and cleaner grouped layer controls.
|
|
188
|
-
</p>
|
|
189
|
-
</>
|
|
190
|
-
)}
|
|
191
|
-
</div>
|
|
192
|
-
</div>
|
|
193
|
-
);
|
|
168
|
+
return <PropertyPanelEmptyState multiSelectCount={multiSelectCount} />;
|
|
194
169
|
}
|
|
195
170
|
|
|
196
171
|
const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
|
|
@@ -352,18 +327,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
352
327
|
onSetAttribute={onSetAttribute}
|
|
353
328
|
/>
|
|
354
329
|
)}
|
|
355
|
-
{sections.
|
|
356
|
-
<MediaSection
|
|
357
|
-
projectDir={projectDir}
|
|
358
|
-
element={element}
|
|
359
|
-
styles={styles}
|
|
360
|
-
onSetStyle={onSetStyle}
|
|
361
|
-
onSetAttribute={onSetAttribute}
|
|
362
|
-
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
363
|
-
/>
|
|
364
|
-
)}
|
|
365
|
-
|
|
366
|
-
{STUDIO_COLOR_GRADING_ENABLED && sections.colorGrading && (
|
|
330
|
+
{sections.colorGrading && (
|
|
367
331
|
<ColorGradingSection
|
|
368
332
|
key={[
|
|
369
333
|
element.id ?? "",
|
|
@@ -371,11 +335,25 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
371
335
|
element.selector ?? "",
|
|
372
336
|
String(element.selectorIndex ?? ""),
|
|
373
337
|
].join("|")}
|
|
338
|
+
projectId={projectId}
|
|
374
339
|
element={element}
|
|
375
340
|
assets={assets}
|
|
376
341
|
previewIframeRef={previewIframeRef}
|
|
377
342
|
onImportAssets={onImportAssets}
|
|
378
343
|
onSetAttributeLive={onSetAttributeLive}
|
|
344
|
+
onApplyScope={onApplyColorGradingScope}
|
|
345
|
+
/>
|
|
346
|
+
)}
|
|
347
|
+
|
|
348
|
+
{sections.media && (
|
|
349
|
+
<MediaSection
|
|
350
|
+
projectDir={projectDir}
|
|
351
|
+
element={element}
|
|
352
|
+
styles={styles}
|
|
353
|
+
onSetStyle={onSetStyle}
|
|
354
|
+
onSetAttribute={onSetAttribute}
|
|
355
|
+
onSetHtmlAttribute={onSetHtmlAttribute}
|
|
356
|
+
onRemoveBackground={onRemoveBackground}
|
|
379
357
|
/>
|
|
380
358
|
)}
|
|
381
359
|
|
|
@@ -580,6 +558,8 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
580
558
|
onSetStyle={onSetStyle}
|
|
581
559
|
onImportAssets={onImportAssets}
|
|
582
560
|
gsapBorderRadius={gsapBorderRadius}
|
|
561
|
+
cropMode={cropMode}
|
|
562
|
+
onCropModeChange={onCropModeChange}
|
|
583
563
|
/>
|
|
584
564
|
)}
|
|
585
565
|
</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,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { patchMediaColorGradingInHtml } from "./colorGradingScopePatch";
|
|
3
|
+
|
|
4
|
+
describe("patchMediaColorGradingInHtml", () => {
|
|
5
|
+
it("adds color grading to video and image tags only", () => {
|
|
6
|
+
const { html, count } = patchMediaColorGradingInHtml(
|
|
7
|
+
`<div><video id="v"></video><img id="i" /><audio id="a"></audio></div>`,
|
|
8
|
+
`{"preset":"natural-lift"}`,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
expect(count).toBe(2);
|
|
12
|
+
expect(html).toContain(
|
|
13
|
+
`video id="v" data-color-grading="{"preset":"natural-lift"}"`,
|
|
14
|
+
);
|
|
15
|
+
expect(html).toContain(
|
|
16
|
+
`img id="i" data-color-grading="{"preset":"natural-lift"}"`,
|
|
17
|
+
);
|
|
18
|
+
expect(html).toContain(`<audio id="a"></audio>`);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("replaces existing color grading without touching other attributes", () => {
|
|
22
|
+
const { html, count } = patchMediaColorGradingInHtml(
|
|
23
|
+
`<video muted data-color-grading='old' playsinline></video>`,
|
|
24
|
+
`{"adjust":{"exposure":0.2}}`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect(count).toBe(1);
|
|
28
|
+
expect(html).toBe(
|
|
29
|
+
`<video muted data-color-grading="{"adjust":{"exposure":0.2}}" playsinline></video>`,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps quoted greater-than characters inside media attributes", () => {
|
|
34
|
+
const { html, count } = patchMediaColorGradingInHtml(
|
|
35
|
+
`<img alt="before > after" src="photo.jpg">`,
|
|
36
|
+
`{"adjust":{"contrast":0.1}}`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(count).toBe(1);
|
|
40
|
+
expect(html).toBe(
|
|
41
|
+
`<img alt="before > after" src="photo.jpg" data-color-grading="{"adjust":{"contrast":0.1}}">`,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("removes color grading when value is empty", () => {
|
|
46
|
+
const { html, count } = patchMediaColorGradingInHtml(
|
|
47
|
+
`<video data-color-grading="old"></video><img alt="">`,
|
|
48
|
+
null,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(count).toBe(1);
|
|
52
|
+
expect(html).toBe(`<video></video><img alt="">`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not patch media-looking text inside scripts, styles, or comments", () => {
|
|
56
|
+
const { html, count } = patchMediaColorGradingInHtml(
|
|
57
|
+
[
|
|
58
|
+
`<script>const tpl = '<video id="script-video"></video>';</script>`,
|
|
59
|
+
`<style>.icon::before { content: "<img>"; }</style>`,
|
|
60
|
+
`<!-- <img id="commented"> -->`,
|
|
61
|
+
`<video id="real"></video>`,
|
|
62
|
+
].join(""),
|
|
63
|
+
`{"preset":"clean-studio"}`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(count).toBe(1);
|
|
67
|
+
expect(html).toContain(`<script>const tpl = '<video id="script-video"></video>';</script>`);
|
|
68
|
+
expect(html).toContain(`<style>.icon::before { content: "<img>"; }</style>`);
|
|
69
|
+
expect(html).toContain(`<!-- <img id="commented"> -->`);
|
|
70
|
+
expect(html).toContain(
|
|
71
|
+
`<video id="real" data-color-grading="{"preset":"clean-studio"}"></video>`,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const MEDIA_TAG_RE = /<\s*(video|img)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
2
|
+
const COLOR_GRADING_ATTR_RE = /\sdata-color-grading=(["'])([\s\S]*?)\1/i;
|
|
3
|
+
const IGNORED_HTML_RANGE_RE = /<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>/gi;
|
|
4
|
+
|
|
5
|
+
interface TextRange {
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function collectIgnoredRanges(html: string): TextRange[] {
|
|
11
|
+
const ranges: TextRange[] = [];
|
|
12
|
+
let match: RegExpExecArray | null;
|
|
13
|
+
IGNORED_HTML_RANGE_RE.lastIndex = 0;
|
|
14
|
+
while ((match = IGNORED_HTML_RANGE_RE.exec(html)) !== null) {
|
|
15
|
+
ranges.push({ start: match.index, end: match.index + match[0].length });
|
|
16
|
+
}
|
|
17
|
+
return ranges;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isInsideRange(offset: number, ranges: TextRange[]): boolean {
|
|
21
|
+
return ranges.some((range) => offset >= range.start && offset < range.end);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function escapeHtmlAttribute(value: string): string {
|
|
25
|
+
return value
|
|
26
|
+
.replace(/&/g, "&")
|
|
27
|
+
.replace(/"/g, """)
|
|
28
|
+
.replace(/</g, "<")
|
|
29
|
+
.replace(/>/g, ">");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function patchMediaTag(tag: string, value: string | null): string {
|
|
33
|
+
if (value === null || value === "") {
|
|
34
|
+
return tag.replace(COLOR_GRADING_ATTR_RE, "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const nextAttr = ` data-color-grading="${escapeHtmlAttribute(value)}"`;
|
|
38
|
+
if (COLOR_GRADING_ATTR_RE.test(tag)) {
|
|
39
|
+
return tag.replace(COLOR_GRADING_ATTR_RE, nextAttr);
|
|
40
|
+
}
|
|
41
|
+
return tag.replace(/\s*\/?>$/, (end) => `${nextAttr}${end}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function patchMediaColorGradingInHtml(
|
|
45
|
+
html: string,
|
|
46
|
+
value: string | null,
|
|
47
|
+
): { html: string; count: number } {
|
|
48
|
+
let count = 0;
|
|
49
|
+
const ignoredRanges = collectIgnoredRanges(html);
|
|
50
|
+
const patched = html.replace(MEDIA_TAG_RE, (tag, _tagName, offset: number) => {
|
|
51
|
+
if (isInsideRange(offset, ignoredRanges)) return tag;
|
|
52
|
+
const next = patchMediaTag(tag, value);
|
|
53
|
+
if (next !== tag) count += 1;
|
|
54
|
+
return next;
|
|
55
|
+
});
|
|
56
|
+
return { html: patched, count };
|
|
57
|
+
}
|
|
@@ -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
|
+
});
|