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.
- package/.github/workflows/deploy.yml +54 -0
- package/.prettierr +9 -0
- package/.storybook/main.ts +16 -0
- package/.storybook/preview.ts +53 -0
- package/.vscode/extensions.json +6 -0
- package/README.md +75 -0
- package/eslint.config.js +22 -0
- package/index.html +13 -0
- package/package.json +57 -0
- package/pnpm-workspace.yaml +2 -0
- package/public/favicon.svg +1 -0
- package/src/app/App.tsx +10 -0
- package/src/app/entries/index.ts +145 -0
- package/src/app/providers/RouterProvider.tsx +6 -0
- package/src/app/providers/index.ts +1 -0
- package/src/app/router/AppRouter.tsx +12 -0
- package/src/app/router/index.ts +0 -0
- package/src/app/styles/index.css +4 -0
- package/src/features/gallery/GalleryPage.tsx +33 -0
- package/src/features/gallery/ImageEditPage.tsx +37 -0
- package/src/features/gallery/index.ts +2 -0
- package/src/main.tsx +10 -0
- package/src/shared/components/ImageCard/ImageCard.tsx +54 -0
- package/src/shared/components/ImageCard/index.ts +1 -0
- package/src/shared/components/ImageEditor/ImageEditor.tsx +48 -0
- package/src/shared/components/ImageEditor/assets/icons.tsx +114 -0
- package/src/shared/components/ImageEditor/constants.ts +118 -0
- package/src/shared/components/ImageEditor/crop/Crop.tsx +60 -0
- package/src/shared/components/ImageEditor/crop/CropBorders.tsx +28 -0
- package/src/shared/components/ImageEditor/crop/CropLines.tsx +8 -0
- package/src/shared/components/ImageEditor/crop/CropPointer.tsx +21 -0
- package/src/shared/components/ImageEditor/crop/CropPointers.tsx +44 -0
- package/src/shared/components/ImageEditor/crop/CropTools.tsx +138 -0
- package/src/shared/components/ImageEditor/crop/crop.css +180 -0
- package/src/shared/components/ImageEditor/crop/index.ts +8 -0
- package/src/shared/components/ImageEditor/crop/useCropInteraction.ts +230 -0
- package/src/shared/components/ImageEditor/eventBus.ts +8 -0
- package/src/shared/components/ImageEditor/frame/Frame.tsx +48 -0
- package/src/shared/components/ImageEditor/frame/frame.css +40 -0
- package/src/shared/components/ImageEditor/frame/index.ts +1 -0
- package/src/shared/components/ImageEditor/header/Header.tsx +81 -0
- package/src/shared/components/ImageEditor/header/header.css +27 -0
- package/src/shared/components/ImageEditor/header/index.ts +1 -0
- package/src/shared/components/ImageEditor/hooks/index.ts +5 -0
- package/src/shared/components/ImageEditor/hooks/useAbove.tsx +18 -0
- package/src/shared/components/ImageEditor/hooks/useBellow.tsx +18 -0
- package/src/shared/components/ImageEditor/hooks/useImageLoader.tsx +110 -0
- package/src/shared/components/ImageEditor/hooks/useImageProcessor.test.tsx +147 -0
- package/src/shared/components/ImageEditor/hooks/useImageProcessor.ts +225 -0
- package/src/shared/components/ImageEditor/hooks/useMobile.tsx +33 -0
- package/src/shared/components/ImageEditor/index.css +145 -0
- package/src/shared/components/ImageEditor/index.ts +1 -0
- package/src/shared/components/ImageEditor/provider/ImageEditorProvider.test.tsx +126 -0
- package/src/shared/components/ImageEditor/provider/ImageEditorProvider.tsx +107 -0
- package/src/shared/components/ImageEditor/provider/StoreContext.tsx +20 -0
- package/src/shared/components/ImageEditor/provider/initialState.ts +30 -0
- package/src/shared/components/ImageEditor/provider/useImageEditorContext.tsx +12 -0
- package/src/shared/components/ImageEditor/resize/ResizeTools.tsx +183 -0
- package/src/shared/components/ImageEditor/resize/index.ts +1 -0
- package/src/shared/components/ImageEditor/resize/resize.css +56 -0
- package/src/shared/components/ImageEditor/sidebar/Sidebar.tsx +54 -0
- package/src/shared/components/ImageEditor/sidebar/SidebarCrop.tsx +57 -0
- package/src/shared/components/ImageEditor/sidebar/SidebarPresets.tsx +54 -0
- package/src/shared/components/ImageEditor/sidebar/SidebarResize.tsx +26 -0
- package/src/shared/components/ImageEditor/sidebar/index.ts +1 -0
- package/src/shared/components/ImageEditor/sidebar/sidebar.css +101 -0
- package/src/shared/components/ImageEditor/types.ts +59 -0
- package/src/shared/components/ImageEditor/ui/button/Button.stories.tsx +42 -0
- package/src/shared/components/ImageEditor/ui/button/Button.tsx +21 -0
- package/src/shared/components/ImageEditor/ui/button/button.css +69 -0
- package/src/shared/components/ImageEditor/ui/button-crop/ButtonCrop.stories.tsx +26 -0
- package/src/shared/components/ImageEditor/ui/button-crop/ButtonCrop.tsx +29 -0
- package/src/shared/components/ImageEditor/ui/button-crop/button-crop.css +38 -0
- package/src/shared/components/ImageEditor/ui/index.ts +6 -0
- package/src/shared/components/ImageEditor/ui/input/Input.stories.tsx +45 -0
- package/src/shared/components/ImageEditor/ui/input/Input.tsx +20 -0
- package/src/shared/components/ImageEditor/ui/input/input.css +48 -0
- package/src/shared/components/ImageEditor/ui/input-pixel/InputPixel.stories.tsx +33 -0
- package/src/shared/components/ImageEditor/ui/input-pixel/InputPixel.tsx +23 -0
- package/src/shared/components/ImageEditor/ui/input-pixel/input-pixel.css +24 -0
- package/src/shared/components/ImageEditor/ui/select/Select.stories.tsx +67 -0
- package/src/shared/components/ImageEditor/ui/select/Select.tsx +161 -0
- package/src/shared/components/ImageEditor/ui/select/select.css +147 -0
- package/src/shared/components/ImageEditor/ui/separator/Separator.stories.tsx +37 -0
- package/src/shared/components/ImageEditor/ui/separator/Separator.tsx +27 -0
- package/src/shared/components/ImageEditor/ui/separator/separator.css +19 -0
- package/src/shared/components/ImageEditor/utils.test.ts +136 -0
- package/src/shared/components/ImageEditor/utils.ts +497 -0
- package/src/shared/components/ui/index.ts +1 -0
- package/src/shared/components/ui/preloader.tsx +78 -0
- package/src/shared/config/index.ts +3 -0
- package/src/shared/config/routes.ts +4 -0
- package/src/shared/features/gallery/GalleryPage.tsx +33 -0
- package/src/shared/features/gallery/ImageEditPage.tsx +37 -0
- package/src/shared/features/gallery/index.ts +2 -0
- package/src/shared/hooks/index.ts +1 -0
- package/src/shared/hooks/useImagePreload.tsx +49 -0
- package/src/shared/types/index.ts +11 -0
- package/src/test/setup.ts +14 -0
- package/src/virtual-css-injected-by-js.d.ts +8 -0
- package/src/widget.tsx +119 -0
- package/tsconfig.app.json +29 -0
- package/tsconfig.json +12 -0
- package/tsconfig.lib.json +18 -0
- package/tsconfig.node.json +23 -0
- package/vite-env.d.ts +1 -0
- package/vite.config.ts +24 -0
- package/vite.lib.config.ts +33 -0
- package/vite.widget.config.ts +39 -0
- 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
|
+
});
|