pixedi 1.0.0

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 (110) hide show
  1. package/.github/workflows/deploy.yml +54 -0
  2. package/.prettierr +9 -0
  3. package/.storybook/main.ts +16 -0
  4. package/.storybook/preview.ts +53 -0
  5. package/.vscode/extensions.json +6 -0
  6. package/README.md +75 -0
  7. package/eslint.config.js +22 -0
  8. package/index.html +13 -0
  9. package/package.json +57 -0
  10. package/pnpm-workspace.yaml +2 -0
  11. package/public/favicon.svg +1 -0
  12. package/src/app/App.tsx +10 -0
  13. package/src/app/entries/index.ts +145 -0
  14. package/src/app/providers/RouterProvider.tsx +6 -0
  15. package/src/app/providers/index.ts +1 -0
  16. package/src/app/router/AppRouter.tsx +12 -0
  17. package/src/app/router/index.ts +0 -0
  18. package/src/app/styles/index.css +4 -0
  19. package/src/features/gallery/GalleryPage.tsx +33 -0
  20. package/src/features/gallery/ImageEditPage.tsx +37 -0
  21. package/src/features/gallery/index.ts +2 -0
  22. package/src/main.tsx +10 -0
  23. package/src/shared/components/ImageCard/ImageCard.tsx +54 -0
  24. package/src/shared/components/ImageCard/index.ts +1 -0
  25. package/src/shared/components/ImageEditor/ImageEditor.tsx +48 -0
  26. package/src/shared/components/ImageEditor/assets/icons.tsx +114 -0
  27. package/src/shared/components/ImageEditor/constants.ts +118 -0
  28. package/src/shared/components/ImageEditor/crop/Crop.tsx +60 -0
  29. package/src/shared/components/ImageEditor/crop/CropBorders.tsx +28 -0
  30. package/src/shared/components/ImageEditor/crop/CropLines.tsx +8 -0
  31. package/src/shared/components/ImageEditor/crop/CropPointer.tsx +21 -0
  32. package/src/shared/components/ImageEditor/crop/CropPointers.tsx +44 -0
  33. package/src/shared/components/ImageEditor/crop/CropTools.tsx +138 -0
  34. package/src/shared/components/ImageEditor/crop/crop.css +180 -0
  35. package/src/shared/components/ImageEditor/crop/index.ts +8 -0
  36. package/src/shared/components/ImageEditor/crop/useCropInteraction.ts +230 -0
  37. package/src/shared/components/ImageEditor/eventBus.ts +8 -0
  38. package/src/shared/components/ImageEditor/frame/Frame.tsx +48 -0
  39. package/src/shared/components/ImageEditor/frame/frame.css +40 -0
  40. package/src/shared/components/ImageEditor/frame/index.ts +1 -0
  41. package/src/shared/components/ImageEditor/header/Header.tsx +81 -0
  42. package/src/shared/components/ImageEditor/header/header.css +27 -0
  43. package/src/shared/components/ImageEditor/header/index.ts +1 -0
  44. package/src/shared/components/ImageEditor/hooks/index.ts +5 -0
  45. package/src/shared/components/ImageEditor/hooks/useAbove.tsx +18 -0
  46. package/src/shared/components/ImageEditor/hooks/useBellow.tsx +18 -0
  47. package/src/shared/components/ImageEditor/hooks/useImageLoader.tsx +110 -0
  48. package/src/shared/components/ImageEditor/hooks/useImageProcessor.test.tsx +147 -0
  49. package/src/shared/components/ImageEditor/hooks/useImageProcessor.ts +225 -0
  50. package/src/shared/components/ImageEditor/hooks/useMobile.tsx +33 -0
  51. package/src/shared/components/ImageEditor/index.css +145 -0
  52. package/src/shared/components/ImageEditor/index.ts +1 -0
  53. package/src/shared/components/ImageEditor/provider/ImageEditorProvider.test.tsx +126 -0
  54. package/src/shared/components/ImageEditor/provider/ImageEditorProvider.tsx +107 -0
  55. package/src/shared/components/ImageEditor/provider/StoreContext.tsx +20 -0
  56. package/src/shared/components/ImageEditor/provider/initialState.ts +30 -0
  57. package/src/shared/components/ImageEditor/provider/useImageEditorContext.tsx +12 -0
  58. package/src/shared/components/ImageEditor/resize/ResizeTools.tsx +183 -0
  59. package/src/shared/components/ImageEditor/resize/index.ts +1 -0
  60. package/src/shared/components/ImageEditor/resize/resize.css +56 -0
  61. package/src/shared/components/ImageEditor/sidebar/Sidebar.tsx +54 -0
  62. package/src/shared/components/ImageEditor/sidebar/SidebarCrop.tsx +57 -0
  63. package/src/shared/components/ImageEditor/sidebar/SidebarPresets.tsx +54 -0
  64. package/src/shared/components/ImageEditor/sidebar/SidebarResize.tsx +26 -0
  65. package/src/shared/components/ImageEditor/sidebar/index.ts +1 -0
  66. package/src/shared/components/ImageEditor/sidebar/sidebar.css +101 -0
  67. package/src/shared/components/ImageEditor/types.ts +59 -0
  68. package/src/shared/components/ImageEditor/ui/button/Button.stories.tsx +42 -0
  69. package/src/shared/components/ImageEditor/ui/button/Button.tsx +21 -0
  70. package/src/shared/components/ImageEditor/ui/button/button.css +69 -0
  71. package/src/shared/components/ImageEditor/ui/button-crop/ButtonCrop.stories.tsx +26 -0
  72. package/src/shared/components/ImageEditor/ui/button-crop/ButtonCrop.tsx +29 -0
  73. package/src/shared/components/ImageEditor/ui/button-crop/button-crop.css +38 -0
  74. package/src/shared/components/ImageEditor/ui/index.ts +6 -0
  75. package/src/shared/components/ImageEditor/ui/input/Input.stories.tsx +45 -0
  76. package/src/shared/components/ImageEditor/ui/input/Input.tsx +20 -0
  77. package/src/shared/components/ImageEditor/ui/input/input.css +48 -0
  78. package/src/shared/components/ImageEditor/ui/input-pixel/InputPixel.stories.tsx +33 -0
  79. package/src/shared/components/ImageEditor/ui/input-pixel/InputPixel.tsx +23 -0
  80. package/src/shared/components/ImageEditor/ui/input-pixel/input-pixel.css +24 -0
  81. package/src/shared/components/ImageEditor/ui/select/Select.stories.tsx +67 -0
  82. package/src/shared/components/ImageEditor/ui/select/Select.tsx +161 -0
  83. package/src/shared/components/ImageEditor/ui/select/select.css +147 -0
  84. package/src/shared/components/ImageEditor/ui/separator/Separator.stories.tsx +37 -0
  85. package/src/shared/components/ImageEditor/ui/separator/Separator.tsx +27 -0
  86. package/src/shared/components/ImageEditor/ui/separator/separator.css +19 -0
  87. package/src/shared/components/ImageEditor/utils.test.ts +136 -0
  88. package/src/shared/components/ImageEditor/utils.ts +497 -0
  89. package/src/shared/components/ui/index.ts +1 -0
  90. package/src/shared/components/ui/preloader.tsx +78 -0
  91. package/src/shared/config/index.ts +3 -0
  92. package/src/shared/config/routes.ts +4 -0
  93. package/src/shared/features/gallery/GalleryPage.tsx +33 -0
  94. package/src/shared/features/gallery/ImageEditPage.tsx +37 -0
  95. package/src/shared/features/gallery/index.ts +2 -0
  96. package/src/shared/hooks/index.ts +1 -0
  97. package/src/shared/hooks/useImagePreload.tsx +49 -0
  98. package/src/shared/types/index.ts +11 -0
  99. package/src/test/setup.ts +14 -0
  100. package/src/virtual-css-injected-by-js.d.ts +8 -0
  101. package/src/widget.tsx +119 -0
  102. package/tsconfig.app.json +29 -0
  103. package/tsconfig.json +12 -0
  104. package/tsconfig.lib.json +18 -0
  105. package/tsconfig.node.json +23 -0
  106. package/vite-env.d.ts +1 -0
  107. package/vite.config.ts +24 -0
  108. package/vite.lib.config.ts +33 -0
  109. package/vite.widget.config.ts +39 -0
  110. package/wrangler.json +5 -0
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { fn } from "@storybook/test";
3
+ import { Select, type SelectOption } from "./Select";
4
+
5
+ const items = [
6
+ { value: "1", label: "Small" },
7
+ { value: "2", label: "Medium" },
8
+ { value: "3", label: "Large" },
9
+ ];
10
+
11
+ const groupedItems = [
12
+ {
13
+ label: "Sizes",
14
+ options: [
15
+ { value: "s", label: "Small", rightLabel: "S" },
16
+ { value: "m", label: "Medium", rightLabel: "M" },
17
+ { value: "l", label: "Large", rightLabel: "L" },
18
+ ],
19
+ },
20
+ {
21
+ label: "Formats",
22
+ options: [
23
+ { value: "jpg", label: "JPEG" },
24
+ { value: "png", label: "PNG" },
25
+ { value: "webp", label: "WebP" },
26
+ ],
27
+ },
28
+ ] as SelectOption[];
29
+
30
+ const meta = {
31
+ title: "ImageEditor/UI/Select",
32
+ component: Select,
33
+ tags: ["autodocs"],
34
+ args: {
35
+ items,
36
+ value: "1",
37
+ placeholder: "Select an option",
38
+ onChange: fn(),
39
+ },
40
+ decorators: [
41
+ (Story) => (
42
+ <div style={{ minHeight: 300, maxWidth: 320 }}>
43
+ <Story />
44
+ </div>
45
+ ),
46
+ ],
47
+ } satisfies Meta<typeof Select>;
48
+
49
+ export default meta;
50
+ type Story = StoryObj<typeof meta>;
51
+
52
+ export const SingleItems: Story = {};
53
+
54
+ export const GroupedItems: Story = {
55
+ args: {
56
+ items: groupedItems,
57
+ value: "m",
58
+ },
59
+ };
60
+
61
+ export const CustomRenderOption: Story = {
62
+ args: {
63
+ renderOption: (option) => (
64
+ <strong style={{ color: "#60a5fa" }}>{option.label}</strong>
65
+ ),
66
+ },
67
+ };
@@ -0,0 +1,161 @@
1
+ import { useEffect, useRef, useState, useMemo, useLayoutEffect } from "react";
2
+ import { ChevronDown, Check } from "../../assets/icons";
3
+ import "./select.css";
4
+
5
+ export interface SelectOption {
6
+ value: string;
7
+ label: string;
8
+ options?: SelectOption[];
9
+ rightLabel?: string;
10
+ fullName?: string;
11
+ }
12
+
13
+ interface SelectProps {
14
+ items: SelectOption[];
15
+ value: string;
16
+ onChange: (value: string) => void;
17
+ placeholder?: string;
18
+ className?: string;
19
+ renderOption?: (option: SelectOption) => React.ReactNode;
20
+ }
21
+
22
+ export const Select: React.FC<SelectProps> = ({
23
+ items,
24
+ value,
25
+ onChange,
26
+ placeholder = "Select an option",
27
+ className = "",
28
+ renderOption,
29
+ }) => {
30
+ const [isOpen, setIsOpen] = useState(false);
31
+
32
+ const containerRef = useRef<HTMLDivElement>(null);
33
+ const triggerRef = useRef<HTMLButtonElement>(null);
34
+ const contentRef = useRef<HTMLDivElement>(null);
35
+
36
+ const allOptions = useMemo(() => {
37
+ const flatten = (list: SelectOption[]): SelectOption[] => {
38
+ return list.reduce<SelectOption[]>((acc, item) => {
39
+ if (item.options) {
40
+ return [
41
+ ...acc,
42
+ ...flatten(
43
+ item.options.map((option) => ({
44
+ ...option,
45
+ fullName: `${item.label} ${option.label}`,
46
+ })),
47
+ ),
48
+ ];
49
+ }
50
+ return [...acc, item];
51
+ }, []);
52
+ };
53
+ return flatten(items);
54
+ }, [items]);
55
+
56
+ const selectedOption = allOptions.find((opt) => opt.value === value);
57
+
58
+ useEffect(() => {
59
+ const handleClickOutside = (event: MouseEvent) => {
60
+ if (
61
+ containerRef.current &&
62
+ !containerRef.current.contains(event.target as Node)
63
+ ) {
64
+ setIsOpen(false);
65
+ }
66
+ };
67
+ document.addEventListener("mousedown", handleClickOutside);
68
+ return () => document.removeEventListener("mousedown", handleClickOutside);
69
+ }, []);
70
+
71
+ useLayoutEffect(() => {
72
+ if (!isOpen || !triggerRef.current || !contentRef.current) return;
73
+
74
+ const editorRect = containerRef.current
75
+ ?.closest<HTMLElement>(".picedi.main")
76
+ ?.getBoundingClientRect();
77
+ const triggerRect = triggerRef.current.getBoundingClientRect();
78
+ const contentRect = contentRef.current.getBoundingClientRect();
79
+ const availableBottom = (editorRect?.bottom ?? window.innerHeight) - 16;
80
+ const contentBottom = triggerRect.bottom + contentRect.height + 4;
81
+
82
+ let contentTop = "calc(100% + 4px)";
83
+ if (contentBottom > availableBottom) {
84
+ const diff = contentBottom - availableBottom;
85
+ contentTop = `calc(100% + 4px - ${diff}px)`;
86
+ }
87
+
88
+ contentRef.current.style.top = contentTop;
89
+ }, [isOpen, items]);
90
+
91
+ const handleSelectItem = (val: string) => {
92
+ onChange(val);
93
+ setIsOpen(false);
94
+ };
95
+
96
+ return (
97
+ <div
98
+ ref={containerRef}
99
+ className={`select-wrapper ${className}`.trim()}
100
+ data-state={isOpen ? "open" : "closed"}
101
+ >
102
+ <button
103
+ ref={triggerRef}
104
+ type="button"
105
+ className="select-trigger"
106
+ onClick={() => setIsOpen(!isOpen)}
107
+ >
108
+ <span>{selectedOption ? selectedOption.fullName : placeholder}</span>
109
+ <ChevronDown className="select-trigger-arrow" />
110
+ </button>
111
+
112
+ <div ref={contentRef} className="select-content">
113
+ {items.map((item, index) => {
114
+ if (item.options) {
115
+ return (
116
+ <div key={`group-${index}`}>
117
+ {/* Group Name */}
118
+ <div className="select-group-label semibold">{item.label}</div>
119
+ {item.options.map((option) => (
120
+ <div
121
+ key={option.value}
122
+ className="select-item"
123
+ onClick={() => handleSelectItem(option.value)}
124
+ >
125
+ {renderOption ? (
126
+ renderOption(option)
127
+ ) : (
128
+ <span className="select-item-left">{option.label}</span>
129
+ )}
130
+ <div className="select-item-addon">
131
+ {option.rightLabel && <span>{option.rightLabel}</span>}
132
+ {value === option.value && (
133
+ <Check className="select-item-check" />
134
+ )}
135
+ {value !== option.value && <b />}
136
+ </div>
137
+ </div>
138
+ ))}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ return (
144
+ <div
145
+ key={item.value}
146
+ className="select-item"
147
+ onClick={() => handleSelectItem(item.value)}
148
+ >
149
+ {renderOption ? (
150
+ renderOption(item)
151
+ ) : (
152
+ <span className="select-item-left">{item.label}1</span>
153
+ )}
154
+ {value === item.value && <Check className="select-item-check" />}
155
+ </div>
156
+ );
157
+ })}
158
+ </div>
159
+ </div>
160
+ );
161
+ };
@@ -0,0 +1,147 @@
1
+ .select-wrapper {
2
+ position: relative;
3
+ width: 100%;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ .select-trigger {
8
+ width: 100%;
9
+ height: 32px;
10
+ padding-left: 10px;
11
+ padding-right: 8px;
12
+ font-family: inherit;
13
+ font-size: 14px;
14
+ color: var(--foreground);
15
+ background-color: var(--background);
16
+ border: 1px solid var(--border);
17
+ border-radius: 4px;
18
+ cursor: pointer;
19
+ box-sizing: border-box;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: space-between;
23
+ outline: none;
24
+ user-select: none;
25
+ transition: border-color 0.2s;
26
+ }
27
+
28
+ .select-trigger:hover,
29
+ .select-trigger:focus {
30
+ border-color: var(--muted-foreground);
31
+ }
32
+
33
+ .select-trigger-arrow {
34
+ width: 16px;
35
+ height: 16px;
36
+ color: var(--muted-foreground);
37
+ transition: transform 0.2s;
38
+ }
39
+
40
+ .select-wrapper[data-state="open"] .select-trigger-arrow {
41
+ transform: rotate(180deg);
42
+ }
43
+
44
+ .select-content {
45
+ position: absolute;
46
+ top: calc(100% + 4px);
47
+ left: 0;
48
+ width: 100%;
49
+ max-height: 282px;
50
+ overflow-y: auto;
51
+ background-color: var(--background);
52
+ border: 1px solid var(--border);
53
+ border-radius: 4px;
54
+ box-shadow:
55
+ 0 10px 15px -3px rgba(0, 0, 0, 0.1),
56
+ 0 4px 6px -2px rgba(0, 0, 0, 0.05);
57
+ z-index: 50;
58
+ box-sizing: border-box;
59
+ padding: 4px;
60
+ }
61
+
62
+ .select-content::-webkit-scrollbar {
63
+ width: 4px;
64
+ }
65
+
66
+ .select-content::-webkit-scrollbar-track {
67
+ background: transparent;
68
+ }
69
+
70
+ .select-content::-webkit-scrollbar-thumb {
71
+ background-color: var(--border);
72
+ border-radius: 10px;
73
+ }
74
+
75
+ .select-wrapper[data-state="closed"] .select-content {
76
+ display: none;
77
+ }
78
+
79
+ .select-group-label {
80
+ font-size: 12px;
81
+ font-weight: 500;
82
+ color: var(--muted-foreground);
83
+ padding: 8px 10px;
84
+ user-select: none;
85
+ }
86
+
87
+ .select-item {
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: space-between;
91
+ width: 100%;
92
+ padding: 4px 10px;
93
+ color: var(--foreground);
94
+ border-radius: 4px;
95
+ cursor: pointer;
96
+ user-select: none;
97
+ box-sizing: border-box;
98
+ background: transparent;
99
+ }
100
+
101
+ .select-item:hover {
102
+ background-color: var(--secondary);
103
+ }
104
+
105
+ .select-item-left {
106
+ font-size: 14px;
107
+ font-weight: 400;
108
+ }
109
+
110
+ .select-item-addon {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 8px;
114
+ }
115
+
116
+ .select-item-addon > span {
117
+ font-size: 12px;
118
+ color: var(--muted-foreground);
119
+ }
120
+
121
+ .select-item-addon > svg {
122
+ font-size: 14px;
123
+ }
124
+
125
+ .select-item-addon > b {
126
+ width: 16px;
127
+ height: 16px;
128
+ flex-shrink: 0;
129
+ }
130
+
131
+ .select-item-right-container {
132
+ display: flex;
133
+ align-items: center;
134
+ gap: 8px;
135
+ }
136
+
137
+ .select-item-right {
138
+ font-size: 14px;
139
+ color: var(--muted-foreground);
140
+ }
141
+
142
+ .select-item-check {
143
+ width: 16px;
144
+ height: 16px;
145
+ color: var(--foreground);
146
+ flex-shrink: 0;
147
+ }
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Separator } from "./Separator";
3
+
4
+ const meta = {
5
+ title: "ImageEditor/UI/Separator",
6
+ component: Separator,
7
+ tags: ["autodocs"],
8
+ } satisfies Meta<typeof Separator>;
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof meta>;
12
+
13
+ export const Horizontal: Story = {
14
+ args: {
15
+ orientation: "horizontal",
16
+ },
17
+ decorators: [
18
+ (Story) => (
19
+ <div style={{ width: 200 }}>
20
+ <Story />
21
+ </div>
22
+ ),
23
+ ],
24
+ };
25
+
26
+ export const Vertical: Story = {
27
+ args: {
28
+ orientation: "vertical",
29
+ },
30
+ decorators: [
31
+ (Story) => (
32
+ <div style={{ height: 100, display: "flex" }}>
33
+ <Story />
34
+ </div>
35
+ ),
36
+ ],
37
+ };
@@ -0,0 +1,27 @@
1
+ import { forwardRef } from "react";
2
+ import "./separator.css";
3
+
4
+ interface SeparatorProps extends React.HTMLAttributes<HTMLHRElement> {
5
+ orientation?: "horizontal" | "vertical";
6
+ className?: string;
7
+ }
8
+
9
+ export const Separator = forwardRef<HTMLHRElement, SeparatorProps>(
10
+ ({ className = "", orientation = "horizontal", ...props }, ref) => {
11
+ // Сглобяваме чистите класове
12
+ const combinedClasses = `separator ${className}`.trim();
13
+
14
+ return (
15
+ <hr
16
+ ref={ref}
17
+ role="separator"
18
+ aria-orientation={orientation}
19
+ data-orientation={orientation}
20
+ className={combinedClasses}
21
+ {...props}
22
+ />
23
+ );
24
+ },
25
+ );
26
+
27
+ Separator.displayName = "Separator";
@@ -0,0 +1,19 @@
1
+ .separator {
2
+ display: block;
3
+ flex-shrink: 0;
4
+ background-color: var(--border);
5
+ border: none;
6
+ box-sizing: border-box;
7
+ }
8
+
9
+ .separator[data-orientation="horizontal"] {
10
+ height: 1px;
11
+ width: 100%;
12
+ }
13
+
14
+ .separator[data-orientation="vertical"] {
15
+ width: 1px;
16
+ height: 100%;
17
+ align-self: stretch;
18
+ margin: 0;
19
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ degree2Rad,
4
+ getCropByNewSizes,
5
+ getCropPoints,
6
+ getInitalCrop,
7
+ } from "./utils";
8
+
9
+ const element = {
10
+ offsetLeft: 10,
11
+ offsetTop: 20,
12
+ offsetWidth: 80,
13
+ offsetHeight: 60,
14
+ } as HTMLElement;
15
+
16
+ describe("getInitalCrop", () => {
17
+ it.each([
18
+ [1, 200, 100, { x: 30, y: 10, w: 40, h: 80 }],
19
+ [
20
+ 16 / 9,
21
+ 200,
22
+ 100,
23
+ { x: 14.444444444444446, y: 10, w: 71.11111111111111, h: 80 },
24
+ ],
25
+ [9 / 16, 200, 100, { x: 38.75, y: 10, w: 22.5, h: 80 }],
26
+ [1, 100, 200, { x: 10, y: 30, w: 80, h: 40 }],
27
+ ])(
28
+ "centers a %s crop inside a %sx%s frame",
29
+ (ratio, width, height, expected) => {
30
+ const crop = getInitalCrop(ratio, width, height);
31
+
32
+ expect(crop.x).toBeCloseTo(expected.x);
33
+ expect(crop.y).toBeCloseTo(expected.y);
34
+ expect(crop.w).toBeCloseTo(expected.w);
35
+ expect(crop.h).toBeCloseTo(expected.h);
36
+ expect(crop.x + crop.w / 2).toBeCloseTo(50);
37
+ expect(crop.y + crop.h / 2).toBeCloseTo(50);
38
+ },
39
+ );
40
+ });
41
+
42
+ describe("getCropPoints", () => {
43
+ it("keeps free-form edge resizing within the frame and minimum width", () => {
44
+ const crop = getCropPoints(
45
+ "r",
46
+ true,
47
+ 1,
48
+ 100,
49
+ 0,
50
+ -100,
51
+ 0,
52
+ 100,
53
+ 100,
54
+ { x: 10, y: 20, w: 40, h: 50 },
55
+ element,
56
+ );
57
+
58
+ expect(crop).toEqual({ x: 10, y: 20, w: 32, h: 50 });
59
+ });
60
+
61
+ it("keeps free-form corner resizing within parent bounds", () => {
62
+ const crop = getCropPoints(
63
+ "br",
64
+ true,
65
+ 1,
66
+ 0,
67
+ 0,
68
+ 200,
69
+ 200,
70
+ 100,
71
+ 100,
72
+ { x: 20, y: 30, w: 40, h: 40 },
73
+ element,
74
+ );
75
+
76
+ expect(crop).toEqual({ x: 20, y: 30, w: 80, h: 70 });
77
+ });
78
+
79
+ it("preserves fixed ratios when the requested resize remains valid", () => {
80
+ const crop = getCropPoints(
81
+ "r",
82
+ false,
83
+ 2,
84
+ 100,
85
+ 0,
86
+ 120,
87
+ 0,
88
+ 200,
89
+ 200,
90
+ { x: 50, y: 50, w: 80, h: 40 },
91
+ element,
92
+ );
93
+
94
+ expect(crop).toEqual({ x: 50, y: 45, w: 100, h: 50 });
95
+ expect(crop.w / crop.h).toBe(2);
96
+ });
97
+
98
+ it("restores the element rectangle for invalid fixed-ratio resizes", () => {
99
+ const crop = getCropPoints(
100
+ "r",
101
+ false,
102
+ 2,
103
+ 100,
104
+ 0,
105
+ 200,
106
+ 0,
107
+ 100,
108
+ 100,
109
+ { x: 10, y: 20, w: 80, h: 40 },
110
+ element,
111
+ );
112
+
113
+ expect(crop).toEqual({ x: 10, y: 20, w: 80, h: 60 });
114
+ });
115
+ });
116
+
117
+ describe("crop utility helpers", () => {
118
+ it("rescales crop rectangles and clamps their visible bounds", () => {
119
+ expect(
120
+ getCropByNewSizes({ x: -1, y: 10, w: 80, h: 60 }, 100, 50, 50),
121
+ ).toEqual({
122
+ x: 0,
123
+ y: 20,
124
+ w: 100,
125
+ h: 50,
126
+ });
127
+ });
128
+
129
+ it.each([
130
+ [0, 0],
131
+ [90, Math.PI / 2],
132
+ [180, Math.PI],
133
+ ])("converts %s degrees to radians", (degrees, radians) => {
134
+ expect(degree2Rad(degrees)).toBeCloseTo(radians);
135
+ });
136
+ });