@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.
Files changed (109) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-0P10SwC_.css +1 -0
  3. package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
  4. package/dist/assets/index-B9YvRJz1.js +396 -0
  5. package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
  6. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  7. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  8. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  9. package/dist/index.d.ts +48 -10
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6045 -3507
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +26 -24
  15. package/src/components/AskAgentModal.tsx +23 -6
  16. package/src/components/LintModal.tsx +40 -10
  17. package/src/components/MediaPreview.tsx +36 -1
  18. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  19. package/src/components/StudioErrorBoundary.tsx +19 -31
  20. package/src/components/StudioFeedbackBar.tsx +11 -2
  21. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  22. package/src/components/StudioHeader.tsx +184 -106
  23. package/src/components/StudioLeftSidebar.tsx +24 -3
  24. package/src/components/StudioOverlays.tsx +34 -8
  25. package/src/components/StudioPreviewArea.tsx +8 -0
  26. package/src/components/StudioRightPanel.tsx +160 -9
  27. package/src/components/StudioSplash.tsx +6 -3
  28. package/src/components/StudioToast.tsx +12 -11
  29. package/src/components/TimelineToolbar.test.tsx +9 -3
  30. package/src/components/TimelineToolbar.tsx +63 -29
  31. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  32. package/src/components/editor/DomEditOverlay.tsx +111 -108
  33. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  34. package/src/components/editor/PropertyPanel.test.ts +48 -0
  35. package/src/components/editor/PropertyPanel.tsx +27 -47
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  37. package/src/components/editor/SnapToolbar.tsx +20 -1
  38. package/src/components/editor/clipPathHelpers.ts +113 -0
  39. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  40. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  41. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  42. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  43. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  44. package/src/components/editor/domEditOverlayShape.ts +39 -0
  45. package/src/components/editor/domEditing.test.ts +2 -0
  46. package/src/components/editor/domEditingTypes.ts +3 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  48. package/src/components/editor/manualEditingAvailability.ts +0 -6
  49. package/src/components/editor/marqueeCommit.ts +3 -2
  50. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  51. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  52. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  53. package/src/components/editor/propertyPanelHelpers.ts +32 -125
  54. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  55. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  56. package/src/components/editor/propertyPanelTypes.ts +113 -0
  57. package/src/components/editor/snapTargetCollection.ts +2 -3
  58. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  59. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  60. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  61. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  62. package/src/components/nle/NLELayout.tsx +54 -51
  63. package/src/components/nle/NLEPreview.tsx +41 -5
  64. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  65. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  66. package/src/components/renders/RenderQueue.tsx +125 -24
  67. package/src/components/renders/RenderQueueItem.tsx +132 -64
  68. package/src/components/renders/useRenderQueue.ts +136 -24
  69. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  70. package/src/components/sidebar/AssetsTab.tsx +113 -15
  71. package/src/components/sidebar/BlocksTab.tsx +2 -1
  72. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  73. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  74. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  75. package/src/components/storyboard/StoryboardView.tsx +6 -0
  76. package/src/components/studioColorGradingScope.ts +124 -0
  77. package/src/components/studioMediaJobs.ts +123 -0
  78. package/src/components/ui/Button.tsx +20 -11
  79. package/src/components/ui/HyperframesLoader.tsx +9 -2
  80. package/src/components/ui/SearchInput.tsx +53 -0
  81. package/src/components/ui/Tooltip.tsx +52 -6
  82. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  83. package/src/components/ui/useDialogBehavior.ts +83 -0
  84. package/src/contexts/PanelLayoutContext.tsx +3 -0
  85. package/src/contexts/StudioContext.tsx +7 -0
  86. package/src/hooks/domSelectionTestHarness.ts +1 -0
  87. package/src/hooks/useCompositionContentLoader.ts +40 -0
  88. package/src/hooks/useCropMode.ts +91 -0
  89. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  90. package/src/hooks/useEditorSave.ts +14 -0
  91. package/src/hooks/useFileManager.ts +39 -25
  92. package/src/hooks/useFrameCapture.ts +12 -1
  93. package/src/hooks/usePanelLayout.ts +1 -0
  94. package/src/hooks/usePreviewPersistence.ts +24 -15
  95. package/src/hooks/useStudioContextValue.ts +5 -0
  96. package/src/hooks/useToast.ts +66 -13
  97. package/src/player/components/ShortcutsPanel.tsx +9 -0
  98. package/src/player/store/playerStore.ts +14 -0
  99. package/src/styles/studio.css +61 -0
  100. package/src/utils/sdkResolverShadow.test.ts +53 -3
  101. package/src/utils/sdkResolverShadow.ts +41 -1
  102. package/src/utils/studioPendingEdits.test.ts +43 -0
  103. package/src/utils/studioPendingEdits.ts +45 -0
  104. package/src/utils/studioUiPreferences.ts +4 -0
  105. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  106. package/dist/assets/index-DmkOvZns.css +0 -1
  107. package/dist/assets/index-SYSbQyK4.js +0 -396
  108. package/dist/chunk-JND3XUJL.js.map +0 -1
  109. /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 { Eye, Layers, Move } from "../../icons/SystemIcons";
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.media && (
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="{&quot;preset&quot;:&quot;natural-lift&quot;}"`,
14
+ );
15
+ expect(html).toContain(
16
+ `img id="i" data-color-grading="{&quot;preset&quot;:&quot;natural-lift&quot;}"`,
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="{&quot;adjust&quot;:{&quot;exposure&quot;: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="{&quot;adjust&quot;:{&quot;contrast&quot;: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="{&quot;preset&quot;:&quot;clean-studio&quot;}"></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, "&amp;")
27
+ .replace(/"/g, "&quot;")
28
+ .replace(/</g, "&lt;")
29
+ .replace(/>/g, "&gt;");
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
+ });