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,180 @@
1
+ .crop-image {
2
+ position: absolute;
3
+ inset: 0;
4
+ width: 100%;
5
+ height: 100%;
6
+ }
7
+
8
+ .crop-box {
9
+ position: absolute;
10
+ cursor: move;
11
+ }
12
+
13
+ .crop-tools {
14
+ position: absolute;
15
+ bottom: 24px;
16
+ left: 50%;
17
+ transform: translateX(-50%);
18
+ background-color: var(--background);
19
+ display: flex;
20
+ height: 36px;
21
+ padding-left: 12px;
22
+ align-items: center;
23
+ border-radius: 4px;
24
+ box-sizing: border-box;
25
+ border: 1px solid var(--border);
26
+ box-shadow:
27
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
28
+ 0 2px 4px -1px rgba(0, 0, 0, 0.06);
29
+ }
30
+
31
+ .crop-tools-info {
32
+ display: inline-block;
33
+ min-width: 54px;
34
+ margin-top: -2px;
35
+ }
36
+
37
+ .crop-tools-info-label {
38
+ font-size: 12px;
39
+ line-height: 12px;
40
+ color: var(--muted-foreground);
41
+ }
42
+
43
+ .crop-tools-info-value {
44
+ font-size: 14px;
45
+ line-height: 14px;
46
+ }
47
+
48
+ .crop-b {
49
+ position: absolute;
50
+ background-color: var(--accent-blue);
51
+ }
52
+ .crop-b-t {
53
+ top: 0;
54
+ left: 0;
55
+ width: 100%;
56
+ height: 2px;
57
+ cursor: ns-resize;
58
+ }
59
+ .crop-b-r {
60
+ top: 0;
61
+ right: 0;
62
+ width: 2px;
63
+ height: 100%;
64
+ cursor: ew-resize;
65
+ }
66
+ .crop-b-b {
67
+ bottom: 0;
68
+ left: 0;
69
+ width: 100%;
70
+ height: 2px;
71
+ cursor: ns-resize;
72
+ transform: translateY(1px);
73
+ }
74
+ .crop-b-l {
75
+ top: 0;
76
+ left: -1px;
77
+ width: 2px;
78
+ height: 100%;
79
+ cursor: ew-resize;
80
+ transform: translateX(1px);
81
+ }
82
+
83
+ .crop-l-box {
84
+ position: absolute;
85
+ inset: 2px;
86
+ pointer-events: none;
87
+ }
88
+ .crop-l {
89
+ position: absolute;
90
+ border: 1px dashed var(--color-white);
91
+ opacity: 0.5;
92
+ }
93
+ .crop-l-v {
94
+ width: 33%;
95
+ height: 100%;
96
+ top: 0;
97
+ left: 33%;
98
+ border-top: 0;
99
+ border-bottom: 0;
100
+ }
101
+ .crop-l-h {
102
+ width: 100%;
103
+ height: 33%;
104
+ top: 33%;
105
+ left: 0;
106
+ border-right: 0;
107
+ border-left: 0;
108
+ }
109
+
110
+ .crop-ps {
111
+ position: absolute;
112
+ box-sizing: border-box;
113
+ width: 12px;
114
+ height: 12px;
115
+ border: 1px solid var(--accent-blue);
116
+ background-color: var(--overlay-bg-white);
117
+ border-radius: 50%;
118
+ }
119
+ .crop-ps-tl {
120
+ top: -5px;
121
+ left: -5px;
122
+ cursor: nwse-resize;
123
+ }
124
+ .crop-ps-t {
125
+ top: -5px;
126
+ left: 50%;
127
+ transform: translateX(-50%);
128
+ cursor: ns-resize;
129
+ }
130
+ .crop-ps-tr {
131
+ top: -5px;
132
+ right: -5px;
133
+ cursor: nesw-resize;
134
+ }
135
+ .crop-ps-r {
136
+ top: 50%;
137
+ right: -5px;
138
+ transform: translateY(-50%);
139
+ cursor: ew-resize;
140
+ }
141
+ .crop-ps-br {
142
+ bottom: -5px;
143
+ right: -5px;
144
+ cursor: nwse-resize;
145
+ }
146
+ .crop-ps-b {
147
+ bottom: -5px;
148
+ left: 50%;
149
+ transform: translateX(-50%);
150
+ cursor: ns-resize;
151
+ }
152
+ .crop-ps-bl {
153
+ bottom: -5px;
154
+ left: -5px;
155
+ cursor: nesw-resize;
156
+ }
157
+ .crop-ps-l {
158
+ top: 50%;
159
+ left: -5px;
160
+ transform: translateY(-50%);
161
+ cursor: ew-resize;
162
+ }
163
+
164
+ .crop-p-b {
165
+ position: absolute;
166
+ inset: 0;
167
+ box-sizing: border-box;
168
+ border: 2px solid var(--accent-blue);
169
+ }
170
+ .crop-p-p {
171
+ position: absolute;
172
+ width: 32px;
173
+ height: 32px;
174
+ right: -16px;
175
+ bottom: -16px;
176
+ background-color: var(--overlay-bg-white);
177
+ border-radius: 50%;
178
+ border: 3px solid var(--accent-blue);
179
+ box-sizing: border-box;
180
+ }
@@ -0,0 +1,8 @@
1
+ export { Crop } from "./Crop";
2
+ import { CropTools } from "./CropTools";
3
+ export { CropBorders } from "./CropBorders";
4
+ export { CropLines } from "./CropLines";
5
+ export { CropPointers } from "./CropPointers";
6
+ export { useCropInteraction } from "./useCropInteraction";
7
+ export { CropPointer } from "./CropPointer";
8
+ export { CropTools };
@@ -0,0 +1,230 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useImageEditorContext } from "../provider/useImageEditorContext";
3
+ import { getCropPoints } from "../utils";
4
+ import type { CropRect, Direction } from "../types";
5
+ import { emitCropUpdate } from "../eventBus";
6
+ import { useMobile } from "../hooks";
7
+
8
+ function clamp(value: number, min: number, max: number): number {
9
+ return Math.min(Math.max(value, min), max);
10
+ }
11
+
12
+ function pct(px: number, total: number): number {
13
+ return (px / total) * 100;
14
+ }
15
+
16
+ function applyRect(
17
+ el: HTMLDivElement,
18
+ xPct: number,
19
+ yPct: number,
20
+ wPct: number,
21
+ hPct: number,
22
+ ): void {
23
+ el.style.left = `${xPct}%`;
24
+ el.style.top = `${yPct}%`;
25
+ el.style.width = `${wPct}%`;
26
+ el.style.height = `${hPct}%`;
27
+ }
28
+
29
+ function applyClipPath(
30
+ img: HTMLImageElement,
31
+ xPct: number,
32
+ yPct: number,
33
+ wPct: number,
34
+ hPct: number,
35
+ ): void {
36
+ img.style.clipPath = `xywh(${xPct}% ${yPct}% ${wPct}% ${hPct}%)`;
37
+ }
38
+
39
+ type UseCropInteractionArgs = {
40
+ imgRef: React.RefObject<HTMLImageElement | null>;
41
+ cropRef: React.RefObject<HTMLDivElement | null>;
42
+ };
43
+
44
+ export const useCropInteraction = ({
45
+ imgRef,
46
+ cropRef,
47
+ }: UseCropInteractionArgs) => {
48
+ const { getCurrentAction, getLastHistoryItem } = useImageEditorContext();
49
+ const mobile = useMobile();
50
+
51
+ const startPointRef = useRef<{ x: number; y: number } | null>(null);
52
+ const directionRef = useRef<Direction | "">("");
53
+ const posRef = useRef<{ xPct: number; yPct: number }>({ xPct: 0, yPct: 0 });
54
+ const cropRectRef = useRef<CropRect>({ x: 0, y: 0, w: 0, h: 0 });
55
+
56
+ const handleCropStart = (
57
+ e: React.MouseEvent | React.TouchEvent,
58
+ type: Direction,
59
+ cursor?: string,
60
+ ) => {
61
+ if (!cropRef.current) return;
62
+
63
+ e.stopPropagation();
64
+
65
+ cropRectRef.current = {
66
+ x: cropRef.current.offsetLeft,
67
+ y: cropRef.current.offsetTop,
68
+ w: cropRef.current.offsetWidth,
69
+ h: cropRef.current.offsetHeight,
70
+ };
71
+
72
+ const clientX = "clientX" in e ? e.clientX : e.touches[0].clientX;
73
+ const clientY = "clientY" in e ? e.clientY : e.touches[0].clientY;
74
+ startPointRef.current = { x: clientX, y: clientY };
75
+ directionRef.current = type;
76
+
77
+ if (!mobile) {
78
+ cropRef.current.style.cursor = `${cursor}-resize`;
79
+ document.body.style.cursor = `${cursor}-resize`;
80
+ }
81
+ };
82
+
83
+ useEffect(() => {
84
+ const handleDragStart = (e: MouseEvent | TouchEvent) => {
85
+ if (cropRef.current) {
86
+ const parent = cropRef.current.parentElement;
87
+ if (parent) {
88
+ posRef.current = {
89
+ xPct: pct(cropRef.current.offsetLeft, parent.offsetWidth),
90
+ yPct: pct(cropRef.current.offsetTop, parent.offsetHeight),
91
+ };
92
+ }
93
+ }
94
+ const clientX = "clientX" in e ? e.clientX : e.touches[0].clientX;
95
+ const clientY = "clientY" in e ? e.clientY : e.touches[0].clientY;
96
+ startPointRef.current = { x: clientX, y: clientY };
97
+ document.body.style.cursor = "move";
98
+ };
99
+
100
+ const handleMove = (e: MouseEvent | TouchEvent) => {
101
+ if (!imgRef.current || !cropRef.current || !startPointRef.current) return;
102
+
103
+ const elCrop = cropRef.current;
104
+ const elImage = imgRef.current;
105
+ const parent = elCrop.parentElement;
106
+ if (!parent) return;
107
+
108
+ const parentW = parent.offsetWidth;
109
+ const parentH = parent.offsetHeight;
110
+
111
+ if (directionRef.current) {
112
+ handleResize(e, elCrop, elImage, parentW, parentH);
113
+ return;
114
+ }
115
+
116
+ handleDrag(e, elCrop, elImage, parentW, parentH);
117
+ };
118
+
119
+ const handleResize = (
120
+ e: MouseEvent | TouchEvent,
121
+ elCrop: HTMLDivElement,
122
+ elImage: HTMLImageElement,
123
+ parentW: number,
124
+ parentH: number,
125
+ ) => {
126
+ const action = getCurrentAction();
127
+ if (!action || action.name !== "crop" || !startPointRef.current) return;
128
+
129
+ const { isFree, ratio } = action.args;
130
+ const clientX = "clientX" in e ? e.clientX : e.touches[0].clientX;
131
+ const clientY = "clientY" in e ? e.clientY : e.touches[0].clientY;
132
+ const { x, y, w, h } = getCropPoints(
133
+ directionRef.current as Direction,
134
+ isFree,
135
+ ratio,
136
+ startPointRef.current.x,
137
+ startPointRef.current.y,
138
+ clientX,
139
+ clientY,
140
+ parentW,
141
+ parentH,
142
+ cropRectRef.current,
143
+ elCrop,
144
+ );
145
+
146
+ const xPct = pct(x, parentW);
147
+ const yPct = pct(y, parentH);
148
+ const wPct = pct(w, parentW);
149
+ const hPct = pct(h, parentH);
150
+
151
+ applyRect(elCrop, xPct, yPct, wPct, hPct);
152
+ applyClipPath(elImage, xPct, yPct, wPct, hPct);
153
+
154
+ const lastHistoryItem = getLastHistoryItem();
155
+ if (!lastHistoryItem) return;
156
+ const { width: imgW, height: imgH } = lastHistoryItem;
157
+ emitCropUpdate({
158
+ x: Math.round((xPct / 100) * imgW),
159
+ y: Math.round((yPct / 100) * imgH),
160
+ w: Math.round((wPct / 100) * imgW),
161
+ h: Math.round((hPct / 100) * imgH),
162
+ });
163
+ };
164
+
165
+ const handleDrag = (
166
+ e: MouseEvent | TouchEvent,
167
+ elCrop: HTMLDivElement,
168
+ elImage: HTMLImageElement,
169
+ parentW: number,
170
+ parentH: number,
171
+ ) => {
172
+ if (!startPointRef.current) return;
173
+ console.log("HandleDrag");
174
+ const frmWidth = elCrop.offsetWidth;
175
+ const frmHeight = elCrop.offsetHeight;
176
+
177
+ const clientX = "clientX" in e ? e.clientX : e.touches[0].clientX;
178
+ const clientY = "clientY" in e ? e.clientY : e.touches[0].clientY;
179
+ const dxPct = pct(clientX - startPointRef.current.x, parentW);
180
+ const dyPct = pct(clientY - startPointRef.current.y, parentH);
181
+ startPointRef.current = { x: clientX, y: clientY };
182
+
183
+ const maxXPct = pct(parentW - frmWidth, parentW);
184
+ const maxYPct = pct(parentH - frmHeight, parentH);
185
+
186
+ const leftPct = clamp(posRef.current.xPct + dxPct, 0, maxXPct);
187
+ const topPct = clamp(posRef.current.yPct + dyPct, 0, maxYPct);
188
+ const wPct = pct(frmWidth, parentW);
189
+ const hPct = pct(frmHeight, parentH);
190
+
191
+ posRef.current = { xPct: leftPct, yPct: topPct };
192
+
193
+ applyRect(elCrop, leftPct, topPct, wPct, hPct);
194
+ applyClipPath(elImage, leftPct, topPct, wPct, hPct);
195
+
196
+ const { width: imgW, height: imgH } = getLastHistoryItem();
197
+ emitCropUpdate({
198
+ x: Math.round((leftPct / 100) * imgW),
199
+ y: Math.round((topPct / 100) * imgH),
200
+ w: Math.round((wPct / 100) * imgW),
201
+ h: Math.round((hPct / 100) * imgH),
202
+ });
203
+ };
204
+
205
+ const handleMoveEnd = () => {
206
+ if (!cropRef.current) return;
207
+
208
+ startPointRef.current = null;
209
+ directionRef.current = "";
210
+ cropRef.current.style.cursor = "move";
211
+ document.body.style.cursor = "auto";
212
+ };
213
+
214
+ if (!cropRef.current) return;
215
+
216
+ const controller = new AbortController();
217
+ const { signal } = controller;
218
+
219
+ cropRef.current.addEventListener("mousedown", handleDragStart, { signal });
220
+ cropRef.current.addEventListener("touchstart", handleDragStart, { signal });
221
+ document.addEventListener("mousemove", handleMove, { signal });
222
+ document.addEventListener("touchmove", handleMove, { signal });
223
+ document.addEventListener("mouseup", handleMoveEnd, { signal });
224
+ document.addEventListener("touchend", handleMoveEnd, { signal });
225
+
226
+ return () => controller.abort();
227
+ }, [getCurrentAction, getLastHistoryItem, imgRef, cropRef]);
228
+
229
+ return { handleCropStart };
230
+ };
@@ -0,0 +1,8 @@
1
+ import type { CropRect } from "./types";
2
+
3
+ export const eventBus = new EventTarget();
4
+
5
+ export const emitCropUpdate = (detail: CropRect) => {
6
+ const event = new CustomEvent<CropRect>("crop-update", { detail });
7
+ eventBus.dispatchEvent(event);
8
+ };
@@ -0,0 +1,48 @@
1
+ import { useRef } from "react";
2
+ import { useImageEditorContext } from "../provider/useImageEditorContext";
3
+ import { Crop } from "../crop";
4
+ import { CropTools } from "../crop";
5
+ import "./frame.css";
6
+ import { ResizeTools } from "../resize";
7
+
8
+ export const Frame = () => {
9
+ const { getLastHistoryItem, currentAction } = useImageEditorContext();
10
+ const frameRef = useRef<HTMLDivElement>(null);
11
+ const imageRef = useRef<HTMLImageElement>(null);
12
+ const { width, height, base64 } = getLastHistoryItem();
13
+
14
+ const isCrop = currentAction?.name === "crop";
15
+ const isResize = currentAction?.name === "resize";
16
+ const isFade = isCrop || isResize;
17
+
18
+ const handleResizing = (scale: number) => {
19
+ if (frameRef.current) {
20
+ frameRef.current.style.transform = `scale(${scale / 100})`;
21
+ }
22
+ };
23
+
24
+ const frameClassName = `frame ${isFade ? "mask" : ""}`;
25
+ const imageClassName = `frame-image ${isFade ? "frame-image--faded" : ""}`;
26
+
27
+ return (
28
+ <div className={frameClassName}>
29
+ <div
30
+ ref={frameRef}
31
+ style={{
32
+ aspectRatio: `${width} / ${height}`,
33
+ }}
34
+ className="frame-preview"
35
+ >
36
+ <img
37
+ ref={imageRef}
38
+ src={base64}
39
+ alt="Image"
40
+ className={imageClassName}
41
+ />
42
+ {isCrop && <Crop key={currentAction?.args?.id} />}
43
+ </div>
44
+ {isResize && <ResizeTools onResizing={handleResizing} />}
45
+ {isCrop && <CropTools />}
46
+ </div>
47
+ );
48
+ };
@@ -0,0 +1,40 @@
1
+ .frame {
2
+ display: flex;
3
+ width: 100%;
4
+ height: 100%;
5
+ min-height: 0;
6
+ position: relative;
7
+ overflow: hidden;
8
+ justify-content: center;
9
+ align-items: center;
10
+ padding: 16px;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ .frame--faded {
15
+ background-color: rgba(0, 0, 0, 0.5);
16
+ }
17
+
18
+ .frame-preview {
19
+ position: relative;
20
+ max-width: 100%;
21
+ max-height: 100%;
22
+ width: auto;
23
+ height: auto;
24
+ min-height: 0;
25
+ min-width: 0;
26
+ flex-shrink: 1;
27
+ display: block;
28
+ user-select: none;
29
+ transition: transform 0.2s ease;
30
+ }
31
+
32
+ .frame-image {
33
+ width: 100%;
34
+ height: 100%;
35
+ object-fit: contain;
36
+ }
37
+
38
+ .frame-image--faded {
39
+ opacity: 0.4;
40
+ }
@@ -0,0 +1 @@
1
+ export { Frame } from "./Frame";
@@ -0,0 +1,81 @@
1
+ import { useState } from "react";
2
+ import { ArrowLeft, Check, Undo, Redo, Loader } from "../assets/icons";
3
+ import { useImageEditorContext } from "../provider/useImageEditorContext";
4
+ import { Button } from "../ui";
5
+ import type { FuncSaveArgs } from "../types";
6
+ import "./header.css";
7
+
8
+ type HeaderProps = {
9
+ onBack: () => void;
10
+ onSave: FuncSaveArgs;
11
+ };
12
+
13
+ export const Header = ({ onBack, onSave }: HeaderProps) => {
14
+ const [isSaving, setIsSaving] = useState(false);
15
+ const {
16
+ history,
17
+ undo,
18
+ redo,
19
+ resetHistory,
20
+ resetHistoryAfterSave,
21
+ getLastHistoryItem,
22
+ } = useImageEditorContext();
23
+
24
+ const { base64 } = getLastHistoryItem();
25
+ const showHistory = history.items.length > 1 && !isSaving;
26
+ const disabledUndo = history.pointer === 0;
27
+ const disabledRedo = history.pointer === history.items.length - 1;
28
+ const disableSave = history.items.length < 2 || isSaving;
29
+
30
+ const handleSave = async () => {
31
+ if (!base64) return;
32
+ setIsSaving(true);
33
+ try {
34
+ await onSave(base64);
35
+ resetHistoryAfterSave();
36
+ } finally {
37
+ setIsSaving(false);
38
+ }
39
+ };
40
+
41
+ return (
42
+ <div className="header">
43
+ <Button variant="ghost" className="btn-rect" onClick={onBack}>
44
+ <ArrowLeft />
45
+ </Button>
46
+
47
+ <div className="header-tools">
48
+ {showHistory && (
49
+ <div className="header-history">
50
+ <Button
51
+ variant="outline"
52
+ disabled={disabledUndo}
53
+ className="btn-rect"
54
+ onClick={undo}
55
+ >
56
+ <Undo />
57
+ </Button>
58
+ <div className="header-history-text">
59
+ {history.pointer + 1}/{history.items.length}
60
+ </div>
61
+ <Button
62
+ variant="outline"
63
+ disabled={disabledRedo}
64
+ className="btn-rect"
65
+ onClick={redo}
66
+ >
67
+ <Redo />
68
+ </Button>
69
+ </div>
70
+ )}
71
+ <Button variant="outline" disabled={isSaving} onClick={resetHistory}>
72
+ Reset
73
+ </Button>
74
+ <Button disabled={disableSave} onClick={handleSave}>
75
+ {isSaving ? <Loader /> : <Check />}
76
+ Save
77
+ </Button>
78
+ </div>
79
+ </div>
80
+ );
81
+ };
@@ -0,0 +1,27 @@
1
+ .header {
2
+ height: 48px;
3
+ display: flex;
4
+ justify-content: space-between;
5
+ align-items: center;
6
+ padding-left: 8px;
7
+ padding-right: 8px;
8
+ box-sizing: border-box;
9
+ background-color: var(--background);
10
+ border-bottom: 1px solid var(--border);
11
+ }
12
+
13
+ .header-tools {
14
+ display: flex;
15
+ gap: 8px;
16
+ }
17
+
18
+ .header-history {
19
+ display: flex;
20
+ gap: 8px;
21
+ align-items: center;
22
+ }
23
+
24
+ .header-history-text {
25
+ color: var(--muted-foreground);
26
+ font-size: 14px;
27
+ }
@@ -0,0 +1 @@
1
+ export { Header } from "./Header";
@@ -0,0 +1,5 @@
1
+ export { useImageLoader } from "./useImageLoader";
2
+ export { useImageProcessor } from "./useImageProcessor";
3
+ export { useMobile } from "./useMobile";
4
+ export { useBellow } from "./useBellow";
5
+ export { useAbove } from "./useAbove";
@@ -0,0 +1,18 @@
1
+ import { useEffect, useState } from "react";
2
+ import { breakpoints, type Breakpoint } from "../types";
3
+
4
+ export function useAbove(breakpoint: Breakpoint): boolean {
5
+ const [isAbove, setIsAbove] = useState(
6
+ () => window.innerWidth >= breakpoints[breakpoint],
7
+ );
8
+
9
+ useEffect(() => {
10
+ function handleResize() {
11
+ setIsAbove(window.innerWidth >= breakpoints[breakpoint]);
12
+ }
13
+ window.addEventListener("resize", handleResize);
14
+ return () => window.removeEventListener("resize", handleResize);
15
+ }, [breakpoint]);
16
+
17
+ return isAbove;
18
+ }
@@ -0,0 +1,18 @@
1
+ import { useEffect, useState } from "react";
2
+ import { breakpoints, type Breakpoint } from "../types";
3
+
4
+ export function useBellow(breakpoint: Breakpoint): boolean {
5
+ const [isBellow, setIsBellow] = useState(
6
+ () => window.innerWidth < breakpoints[breakpoint],
7
+ );
8
+
9
+ useEffect(() => {
10
+ function handleResize() {
11
+ setIsBellow(window.innerWidth < breakpoints[breakpoint]);
12
+ }
13
+ window.addEventListener("resize", handleResize);
14
+ return () => window.removeEventListener("resize", handleResize);
15
+ }, [breakpoint]);
16
+
17
+ return isBellow;
18
+ }