react-pixcraft 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/LICENSE +21 -0
- package/README.md +60 -0
- package/package.json +34 -0
- package/src/ImageEditor.jsx +646 -0
- package/src/index.js +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BHALGAMA MAYUR
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# react-pixcraft
|
|
2
|
+
|
|
3
|
+
A lightweight React image editor with crop, rotate, flip, and scale. Uses **only React** and the native HTML5 Canvas API—no Konva, use-image, or other image libraries.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Crop** – Free-form or fixed aspect ratio (e.g. 16:9, 1:1)
|
|
8
|
+
- **Rotate** – 90° left, 90° right, 180°
|
|
9
|
+
- **Flip** – Horizontal and vertical
|
|
10
|
+
- **Scale** – Resize by width/height with aspect ratio lock
|
|
11
|
+
- **Undo / Redo**
|
|
12
|
+
- **Export** – Get edited image as Blob
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install react-pixcraft
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Peer Dependencies
|
|
21
|
+
|
|
22
|
+
- `react` >= 17.0.0
|
|
23
|
+
- `react-dom` >= 17.0.0
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```jsx
|
|
28
|
+
import { ImageEditor } from "react-pixcraft";
|
|
29
|
+
|
|
30
|
+
function App() {
|
|
31
|
+
const handleExport = (blob, meta) => {
|
|
32
|
+
console.log("Exported:", meta.width, "x", meta.height);
|
|
33
|
+
// Use blob for upload, download, etc.
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<ImageEditor
|
|
38
|
+
src="https://example.com/image.jpg"
|
|
39
|
+
onExport={handleExport}
|
|
40
|
+
maxViewport={{ width: 1920, height: 1080 }}
|
|
41
|
+
mime="image/png"
|
|
42
|
+
className="my-editor"
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Props
|
|
49
|
+
|
|
50
|
+
| Prop | Type | Default | Description |
|
|
51
|
+
|--------------|------------|----------------------|----------------------------------------------|
|
|
52
|
+
| `src` | `string` | `null` | Initial image URL (or null to upload first) |
|
|
53
|
+
| `onExport` | `function` | — | `(blob, { width, height, mime }) => void` |
|
|
54
|
+
| `maxViewport`| `object` | `{ width: 1920, height: 1080 }` | Max display size |
|
|
55
|
+
| `mime` | `string` | `"image/png"` | Output MIME type (e.g. `"image/jpeg"`) |
|
|
56
|
+
| `className` | `string` | — | Optional CSS class for the container |
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-pixcraft",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lightweight React image editor with crop, rotate, flip, and scale. Uses only React + native Canvas API.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"module": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.js",
|
|
10
|
+
"require": "./src/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "echo \"No tests yet\""
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"react",
|
|
21
|
+
"image",
|
|
22
|
+
"editor",
|
|
23
|
+
"crop",
|
|
24
|
+
"rotate",
|
|
25
|
+
"flip",
|
|
26
|
+
"canvas"
|
|
27
|
+
],
|
|
28
|
+
"author": "Mayur Bhalgama",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": ">=17.0.0",
|
|
32
|
+
"react-dom": ">=17.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ImageEditor – React image editor using only React + native Canvas API
|
|
12
|
+
* Features: crop (free + aspect), rotate, flip, scale, undo/redo, export as Blob
|
|
13
|
+
* Dependencies: React only (no konva, use-image, etc.)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ---------- Helpers ----------
|
|
17
|
+
function loadImage(url) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const img = new window.Image();
|
|
20
|
+
img.crossOrigin = "anonymous";
|
|
21
|
+
img.onload = () => resolve(img);
|
|
22
|
+
img.onerror = reject;
|
|
23
|
+
img.src = url;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function dataUrlToBlob(dataUrl) {
|
|
28
|
+
const [meta, b64] = dataUrl.split(",");
|
|
29
|
+
const mime = /data:(.*);base64/.exec(meta)?.[1] || "image/png";
|
|
30
|
+
const bin = atob(b64);
|
|
31
|
+
const len = bin.length;
|
|
32
|
+
const arr = new Uint8Array(len);
|
|
33
|
+
for (let i = 0; i < len; i++) arr[i] = bin.charCodeAt(i);
|
|
34
|
+
return new Blob([arr], { type: mime });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_VIEWPORT = { width: 1920, height: 1080 };
|
|
38
|
+
|
|
39
|
+
function clamp(v, min, max) {
|
|
40
|
+
return Math.min(Math.max(v, min), max);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clampRectToViewport(rect, viewport) {
|
|
44
|
+
const width = clamp(rect.width, 10, viewport.w);
|
|
45
|
+
const height = clamp(rect.height, 10, viewport.h);
|
|
46
|
+
const x = clamp(rect.x, 0, viewport.w - width);
|
|
47
|
+
const y = clamp(rect.y, 0, viewport.h - height);
|
|
48
|
+
return { x, y, width, height };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeFromPoints(sx, sy, px, py, aspect) {
|
|
52
|
+
let w = Math.abs(px - sx);
|
|
53
|
+
let h = Math.abs(py - sy);
|
|
54
|
+
if (aspect && aspect > 0) {
|
|
55
|
+
if (w / (h || 1) > aspect) h = w / aspect;
|
|
56
|
+
else w = h * aspect;
|
|
57
|
+
}
|
|
58
|
+
const x = px >= sx ? sx : sx - w;
|
|
59
|
+
const y = py >= sy ? sy : sy - h;
|
|
60
|
+
return { x, y, width: w, height: h };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ui = {
|
|
64
|
+
btn: {
|
|
65
|
+
padding: "8px 12px",
|
|
66
|
+
border: "1px solid #d0d7de",
|
|
67
|
+
background: "#f6f8fa",
|
|
68
|
+
borderRadius: 6,
|
|
69
|
+
cursor: "pointer",
|
|
70
|
+
fontSize: 14,
|
|
71
|
+
},
|
|
72
|
+
btnPrimary: {
|
|
73
|
+
padding: "8px 12px",
|
|
74
|
+
border: "1px solid #1f6feb",
|
|
75
|
+
background: "#2f81f7",
|
|
76
|
+
color: "white",
|
|
77
|
+
borderRadius: 6,
|
|
78
|
+
cursor: "pointer",
|
|
79
|
+
},
|
|
80
|
+
input: {
|
|
81
|
+
padding: 8,
|
|
82
|
+
border: "1px solid #d0d7de",
|
|
83
|
+
borderRadius: 6,
|
|
84
|
+
fontSize: 14,
|
|
85
|
+
width: "100%",
|
|
86
|
+
},
|
|
87
|
+
label: { fontSize: 12, color: "#57606a" },
|
|
88
|
+
card: {
|
|
89
|
+
border: "1px solid #d0d7de",
|
|
90
|
+
borderRadius: 12,
|
|
91
|
+
padding: 16,
|
|
92
|
+
background: "white",
|
|
93
|
+
},
|
|
94
|
+
heading: { margin: 0, fontSize: 16, fontWeight: 600 },
|
|
95
|
+
small: { fontSize: 12, color: "#57606a" },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const btnWithDisabled = (disabled) => ({
|
|
99
|
+
...ui.btn,
|
|
100
|
+
opacity: disabled ? 0.5 : 1,
|
|
101
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export default function ImageEditor({
|
|
105
|
+
src,
|
|
106
|
+
maxViewport = DEFAULT_VIEWPORT,
|
|
107
|
+
onExport,
|
|
108
|
+
mime = "image/png",
|
|
109
|
+
className,
|
|
110
|
+
}) {
|
|
111
|
+
const canvasRef = useRef(null);
|
|
112
|
+
const [baseURL, setBaseURL] = useState(src ?? null);
|
|
113
|
+
const [loadedImg, setLoadedImg] = useState(null);
|
|
114
|
+
const [imgSize, setImgSize] = useState({ width: 0, height: 0 });
|
|
115
|
+
|
|
116
|
+
const [rotation, setRotation] = useState(0);
|
|
117
|
+
const [flip, setFlip] = useState({ x: false, y: false });
|
|
118
|
+
|
|
119
|
+
const [cropMode, setCropMode] = useState(false);
|
|
120
|
+
const [cropRect, setCropRect] = useState(null);
|
|
121
|
+
const [aspect, setAspect] = useState(undefined);
|
|
122
|
+
|
|
123
|
+
const [scaleW, setScaleW] = useState("");
|
|
124
|
+
const [scaleH, setScaleH] = useState("");
|
|
125
|
+
|
|
126
|
+
const [history, setHistory] = useState([]);
|
|
127
|
+
const [redo, setRedo] = useState([]);
|
|
128
|
+
|
|
129
|
+
const fileInputRef = useRef(null);
|
|
130
|
+
const scaleInputRef = useRef({ width: false, height: false });
|
|
131
|
+
|
|
132
|
+
const isDrawingRef = useRef(false);
|
|
133
|
+
const startPointRef = useRef(null);
|
|
134
|
+
const dragStartRef = useRef(null);
|
|
135
|
+
|
|
136
|
+
const viewport = useMemo(() => {
|
|
137
|
+
const imgW = imgSize.width || 1;
|
|
138
|
+
const imgH = imgSize.height || 1;
|
|
139
|
+
const maxW = maxViewport.width;
|
|
140
|
+
const maxH = maxViewport.height;
|
|
141
|
+
const scale = Math.min(maxW / imgW, maxH / imgH, 1);
|
|
142
|
+
const w = Math.round(imgW * scale);
|
|
143
|
+
const h = Math.round(imgH * scale);
|
|
144
|
+
return { w, h, scale };
|
|
145
|
+
}, [imgSize, maxViewport.width, maxViewport.height]);
|
|
146
|
+
|
|
147
|
+
// Load image when baseURL changes
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!baseURL) {
|
|
150
|
+
setLoadedImg(null);
|
|
151
|
+
setImgSize({ width: 0, height: 0 });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
loadImage(baseURL)
|
|
155
|
+
.then((img) => {
|
|
156
|
+
setLoadedImg(img);
|
|
157
|
+
setImgSize({ width: img.naturalWidth || img.width, height: img.naturalHeight || img.height });
|
|
158
|
+
})
|
|
159
|
+
.catch(() => {
|
|
160
|
+
setLoadedImg(null);
|
|
161
|
+
setImgSize({ width: 0, height: 0 });
|
|
162
|
+
});
|
|
163
|
+
}, [baseURL]);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
setRotation(0);
|
|
167
|
+
setFlip({ x: false, y: false });
|
|
168
|
+
setCropMode(false);
|
|
169
|
+
setCropRect(null);
|
|
170
|
+
setScaleW("");
|
|
171
|
+
setScaleH("");
|
|
172
|
+
scaleInputRef.current = { width: false, height: false };
|
|
173
|
+
}, [baseURL]);
|
|
174
|
+
|
|
175
|
+
const originalAspectRatio = useMemo(() => {
|
|
176
|
+
if (imgSize.width && imgSize.height) return imgSize.width / imgSize.height;
|
|
177
|
+
if (viewport.w > 0 && viewport.h > 0) return viewport.w / viewport.h;
|
|
178
|
+
return null;
|
|
179
|
+
}, [imgSize, viewport]);
|
|
180
|
+
|
|
181
|
+
const pushHistory = useCallback((url, w, h) => {
|
|
182
|
+
setHistory((hstack) => [...hstack, { dataURL: url, width: w, height: h }]);
|
|
183
|
+
setRedo([]);
|
|
184
|
+
}, []);
|
|
185
|
+
|
|
186
|
+
const undo = useCallback(() => {
|
|
187
|
+
setHistory((h) => {
|
|
188
|
+
if (h.length === 0) return h;
|
|
189
|
+
const prev = h[h.length - 1];
|
|
190
|
+
setRedo((r) => [...r, { dataURL: baseURL, width: imgSize.width, height: imgSize.height }]);
|
|
191
|
+
setBaseURL(prev.dataURL);
|
|
192
|
+
return h.slice(0, -1);
|
|
193
|
+
});
|
|
194
|
+
}, [baseURL, imgSize]);
|
|
195
|
+
|
|
196
|
+
const redoAction = useCallback(() => {
|
|
197
|
+
setRedo((r) => {
|
|
198
|
+
if (r.length === 0) return r;
|
|
199
|
+
const next = r[0];
|
|
200
|
+
setHistory((h) => [...h, { dataURL: baseURL, width: imgSize.width, height: imgSize.height }]);
|
|
201
|
+
setBaseURL(next.dataURL);
|
|
202
|
+
return r.slice(1);
|
|
203
|
+
});
|
|
204
|
+
}, [baseURL, imgSize]);
|
|
205
|
+
|
|
206
|
+
// Draw image with rotation and flip onto canvas
|
|
207
|
+
const redraw = useCallback(() => {
|
|
208
|
+
const canvas = canvasRef.current;
|
|
209
|
+
const img = loadedImg;
|
|
210
|
+
if (!canvas || !img) return;
|
|
211
|
+
|
|
212
|
+
const ctx = canvas.getContext("2d");
|
|
213
|
+
const { w, h, scale } = viewport;
|
|
214
|
+
|
|
215
|
+
canvas.width = w;
|
|
216
|
+
canvas.height = h;
|
|
217
|
+
|
|
218
|
+
ctx.clearRect(0, 0, w, h);
|
|
219
|
+
|
|
220
|
+
ctx.save();
|
|
221
|
+
|
|
222
|
+
ctx.translate(w / 2, h / 2);
|
|
223
|
+
ctx.rotate((rotation * Math.PI) / 180);
|
|
224
|
+
ctx.scale(flip.x ? -1 : 1, flip.y ? -1 : 1);
|
|
225
|
+
ctx.translate(-w / 2, -h / 2);
|
|
226
|
+
|
|
227
|
+
const imgW = img.naturalWidth || img.width;
|
|
228
|
+
const imgH = img.naturalHeight || img.height;
|
|
229
|
+
const drawW = imgW * scale;
|
|
230
|
+
const drawH = imgH * scale;
|
|
231
|
+
const ox = (w - drawW) / 2;
|
|
232
|
+
const oy = (h - drawH) / 2;
|
|
233
|
+
ctx.drawImage(img, 0, 0, imgW, imgH, ox, oy, drawW, drawH);
|
|
234
|
+
|
|
235
|
+
ctx.restore();
|
|
236
|
+
|
|
237
|
+
// Draw crop overlay and rect
|
|
238
|
+
if (cropRect && cropRect.width > 0 && cropRect.height > 0) {
|
|
239
|
+
ctx.fillStyle = "rgba(0,0,0,0.4)";
|
|
240
|
+
ctx.fillRect(0, 0, w, h);
|
|
241
|
+
ctx.clearRect(cropRect.x, cropRect.y, cropRect.width, cropRect.height);
|
|
242
|
+
ctx.strokeStyle = "#2f81f7";
|
|
243
|
+
ctx.lineWidth = 2;
|
|
244
|
+
ctx.setLineDash([6, 4]);
|
|
245
|
+
ctx.strokeRect(cropRect.x, cropRect.y, cropRect.width, cropRect.height);
|
|
246
|
+
}
|
|
247
|
+
}, [loadedImg, viewport, rotation, flip, cropRect]);
|
|
248
|
+
|
|
249
|
+
useLayoutEffect(() => {
|
|
250
|
+
redraw();
|
|
251
|
+
}, [redraw]);
|
|
252
|
+
|
|
253
|
+
const commitCurrentViewToBitmap = useCallback(
|
|
254
|
+
async (targetW, targetH) => {
|
|
255
|
+
const img = loadedImg;
|
|
256
|
+
if (!img) return;
|
|
257
|
+
|
|
258
|
+
const { w, h, scale } = viewport;
|
|
259
|
+
const imgW = img.naturalWidth || img.width;
|
|
260
|
+
const imgH = img.naturalHeight || img.height;
|
|
261
|
+
const drawW = imgW * scale;
|
|
262
|
+
const drawH = imgH * scale;
|
|
263
|
+
const ox = (w - drawW) / 2;
|
|
264
|
+
const oy = (h - drawH) / 2;
|
|
265
|
+
|
|
266
|
+
const temp = document.createElement("canvas");
|
|
267
|
+
temp.width = w;
|
|
268
|
+
temp.height = h;
|
|
269
|
+
const tctx = temp.getContext("2d");
|
|
270
|
+
tctx.save();
|
|
271
|
+
tctx.translate(w / 2, h / 2);
|
|
272
|
+
tctx.rotate((rotation * Math.PI) / 180);
|
|
273
|
+
tctx.scale(flip.x ? -1 : 1, flip.y ? -1 : 1);
|
|
274
|
+
tctx.translate(-w / 2, -h / 2);
|
|
275
|
+
tctx.drawImage(img, 0, 0, imgW, imgH, ox, oy, drawW, drawH);
|
|
276
|
+
tctx.restore();
|
|
277
|
+
|
|
278
|
+
let outW = w;
|
|
279
|
+
let outH = h;
|
|
280
|
+
if (targetW || targetH) {
|
|
281
|
+
if (targetW && targetH) {
|
|
282
|
+
outW = targetW;
|
|
283
|
+
outH = targetH;
|
|
284
|
+
} else if (targetW) {
|
|
285
|
+
outW = targetW;
|
|
286
|
+
outH = Math.round(targetW / (w / h));
|
|
287
|
+
} else {
|
|
288
|
+
outH = targetH;
|
|
289
|
+
outW = Math.round(targetH * (w / h));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const offscreen = document.createElement("canvas");
|
|
294
|
+
offscreen.width = outW;
|
|
295
|
+
offscreen.height = outH;
|
|
296
|
+
const octx = offscreen.getContext("2d");
|
|
297
|
+
octx.drawImage(temp, 0, 0, w, h, 0, 0, outW, outH);
|
|
298
|
+
|
|
299
|
+
const dataURL = offscreen.toDataURL(mime);
|
|
300
|
+
const decoded = await loadImage(dataURL);
|
|
301
|
+
pushHistory(baseURL || dataURL, imgSize.width, imgSize.height);
|
|
302
|
+
setBaseURL(dataURL);
|
|
303
|
+
setImgSize({ width: decoded.width, height: decoded.height });
|
|
304
|
+
},
|
|
305
|
+
[loadedImg, viewport, rotation, flip, baseURL, imgSize, mime, pushHistory]
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const rotateLeft = () => {
|
|
309
|
+
setRotation((d) => (d - 90 + 360) % 360);
|
|
310
|
+
requestAnimationFrame(() => commitCurrentViewToBitmap());
|
|
311
|
+
};
|
|
312
|
+
const rotateRight = () => {
|
|
313
|
+
setRotation((d) => (d + 90) % 360);
|
|
314
|
+
requestAnimationFrame(() => commitCurrentViewToBitmap());
|
|
315
|
+
};
|
|
316
|
+
const rotate180 = () => {
|
|
317
|
+
setRotation((d) => (d + 180) % 360);
|
|
318
|
+
requestAnimationFrame(() => commitCurrentViewToBitmap());
|
|
319
|
+
};
|
|
320
|
+
const flipH = () => {
|
|
321
|
+
setFlip((f) => ({ ...f, x: !f.x }));
|
|
322
|
+
requestAnimationFrame(() => commitCurrentViewToBitmap());
|
|
323
|
+
};
|
|
324
|
+
const flipV = () => {
|
|
325
|
+
setFlip((f) => ({ ...f, y: !f.y }));
|
|
326
|
+
requestAnimationFrame(() => commitCurrentViewToBitmap());
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const getCanvasPoint = (e) => {
|
|
330
|
+
const canvas = canvasRef.current;
|
|
331
|
+
if (!canvas) return null;
|
|
332
|
+
const rect = canvas.getBoundingClientRect();
|
|
333
|
+
const scaleX = canvas.width / rect.width;
|
|
334
|
+
const scaleY = canvas.height / rect.height;
|
|
335
|
+
return {
|
|
336
|
+
x: (e.clientX - rect.left) * scaleX,
|
|
337
|
+
y: (e.clientY - rect.top) * scaleY,
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const onCanvasMouseDown = (e) => {
|
|
342
|
+
const pos = getCanvasPoint(e);
|
|
343
|
+
if (!pos) return;
|
|
344
|
+
|
|
345
|
+
if (cropMode) {
|
|
346
|
+
if (cropRect && pos.x >= cropRect.x && pos.x <= cropRect.x + cropRect.width && pos.y >= cropRect.y && pos.y <= cropRect.y + cropRect.height) {
|
|
347
|
+
dragStartRef.current = { x: pos.x - cropRect.x, y: pos.y - cropRect.y, rect: { ...cropRect } };
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
isDrawingRef.current = true;
|
|
351
|
+
startPointRef.current = pos;
|
|
352
|
+
setCropRect({ x: pos.x, y: pos.y, width: 0, height: 0 });
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const onCanvasMouseMove = (e) => {
|
|
357
|
+
const pos = getCanvasPoint(e);
|
|
358
|
+
if (!pos) return;
|
|
359
|
+
|
|
360
|
+
if (cropMode && isDrawingRef.current && startPointRef.current) {
|
|
361
|
+
const { x: sx, y: sy } = startPointRef.current;
|
|
362
|
+
const next = normalizeFromPoints(sx, sy, pos.x, pos.y, aspect);
|
|
363
|
+
setCropRect(clampRectToViewport(next, viewport));
|
|
364
|
+
} else if (cropMode && dragStartRef.current) {
|
|
365
|
+
const { rect, x: dx, y: dy } = dragStartRef.current;
|
|
366
|
+
const nx = pos.x - dx;
|
|
367
|
+
const ny = pos.y - dy;
|
|
368
|
+
const clamped = clampRectToViewport(
|
|
369
|
+
{ ...rect, x: nx, y: ny },
|
|
370
|
+
viewport
|
|
371
|
+
);
|
|
372
|
+
setCropRect(clamped);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const onCanvasMouseUp = () => {
|
|
377
|
+
isDrawingRef.current = false;
|
|
378
|
+
startPointRef.current = null;
|
|
379
|
+
dragStartRef.current = null;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const onCanvasMouseLeave = () => {
|
|
383
|
+
onCanvasMouseUp();
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const applyCrop = useCallback(async () => {
|
|
387
|
+
if (!canvasRef.current || !cropRect) return;
|
|
388
|
+
|
|
389
|
+
const canvas = canvasRef.current;
|
|
390
|
+
const ctx = canvas.getContext("2d");
|
|
391
|
+
const { w, h } = viewport;
|
|
392
|
+
|
|
393
|
+
const x = Math.min(cropRect.x, cropRect.x + cropRect.width);
|
|
394
|
+
const y = Math.min(cropRect.y, cropRect.y + cropRect.height);
|
|
395
|
+
const cw = Math.abs(cropRect.width);
|
|
396
|
+
const ch = Math.abs(cropRect.height);
|
|
397
|
+
|
|
398
|
+
const offscreen = document.createElement("canvas");
|
|
399
|
+
offscreen.width = cw;
|
|
400
|
+
offscreen.height = ch;
|
|
401
|
+
const octx = offscreen.getContext("2d");
|
|
402
|
+
octx.drawImage(canvas, x, y, cw, ch, 0, 0, cw, ch);
|
|
403
|
+
|
|
404
|
+
const dataURL = offscreen.toDataURL(mime);
|
|
405
|
+
const img = await loadImage(dataURL);
|
|
406
|
+
pushHistory(baseURL || dataURL, imgSize.width, imgSize.height);
|
|
407
|
+
setBaseURL(dataURL);
|
|
408
|
+
setImgSize({ width: img.width, height: img.height });
|
|
409
|
+
setCropRect(null);
|
|
410
|
+
setCropMode(false);
|
|
411
|
+
}, [cropRect, viewport, baseURL, imgSize, mime, pushHistory]);
|
|
412
|
+
|
|
413
|
+
const clearCrop = () => setCropRect(null);
|
|
414
|
+
|
|
415
|
+
const onScale = async () => {
|
|
416
|
+
const w = parseInt(scaleW || "0", 10);
|
|
417
|
+
const h = parseInt(scaleH || "0", 10);
|
|
418
|
+
if (!w && !h) return;
|
|
419
|
+
await commitCurrentViewToBitmap(w || undefined, h || undefined);
|
|
420
|
+
setScaleW("");
|
|
421
|
+
setScaleH("");
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const onSave = () => {
|
|
425
|
+
const canvas = canvasRef.current;
|
|
426
|
+
if (!canvas) return;
|
|
427
|
+
const dataURL = canvas.toDataURL(mime);
|
|
428
|
+
const blob = dataUrlToBlob(dataURL);
|
|
429
|
+
onExport?.(blob, { width: viewport.w, height: viewport.h, mime });
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const onCancel = () => {
|
|
433
|
+
if (history.length === 0) return;
|
|
434
|
+
const first = history[0];
|
|
435
|
+
setBaseURL(first.dataURL);
|
|
436
|
+
setHistory([]);
|
|
437
|
+
setRedo([]);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
const onPickFile = (e) => {
|
|
441
|
+
const file = e.target.files?.[0];
|
|
442
|
+
if (!file) return;
|
|
443
|
+
setBaseURL(URL.createObjectURL(file));
|
|
444
|
+
setHistory([]);
|
|
445
|
+
setRedo([]);
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const originalSize = `${imgSize.width} × ${imgSize.height}`;
|
|
449
|
+
const currentSize = `${viewport.w} × ${viewport.h}`;
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<div className={className || ""}>
|
|
453
|
+
<div style={{ ...ui.card, marginBottom: 12 }}>
|
|
454
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
|
|
455
|
+
<h3 style={ui.heading}>Attachment details</h3>
|
|
456
|
+
<div style={ui.small}>{baseURL ? "Image loaded" : "Choose an image"}</div>
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center", marginBottom: 12 }}>
|
|
460
|
+
<button style={ui.btn} onClick={() => setCropMode((v) => !v)}>Crop</button>
|
|
461
|
+
<button style={ui.btn} onClick={rotateLeft}>Rotate 90° left</button>
|
|
462
|
+
<button style={ui.btn} onClick={rotateRight}>Rotate 90° right</button>
|
|
463
|
+
<button style={ui.btn} onClick={rotate180}>Rotate 180°</button>
|
|
464
|
+
<button style={ui.btn} onClick={flipV}>Flip vertical</button>
|
|
465
|
+
<button style={ui.btn} onClick={flipH}>Flip horizontal</button>
|
|
466
|
+
<span style={{ margin: "0 6px", opacity: 0.4 }}>|</span>
|
|
467
|
+
<button style={btnWithDisabled(!history.length)} onClick={undo} disabled={!history.length}>Undo</button>
|
|
468
|
+
<button style={btnWithDisabled(!redo.length)} onClick={redoAction} disabled={!redo.length}>Redo</button>
|
|
469
|
+
<button style={btnWithDisabled(!history.length)} onClick={onCancel} disabled={!history.length}>Cancel Editing</button>
|
|
470
|
+
<div style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center" }}>
|
|
471
|
+
{cropRect ? <button style={ui.btnPrimary} onClick={applyCrop}>Apply Crop</button> : null}
|
|
472
|
+
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: "none" }} onChange={onPickFile} />
|
|
473
|
+
<button style={ui.btn} onClick={() => fileInputRef.current?.click()}>Upload</button>
|
|
474
|
+
<button style={ui.btnPrimary} onClick={onSave}>Save Edits</button>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 320px", gap: 16 }}>
|
|
479
|
+
<div style={{ border: "1px solid #d0d7de", borderRadius: 12, padding: 8, background: "#f8fafc" }}>
|
|
480
|
+
{!baseURL ? (
|
|
481
|
+
<div style={{ height: 420, display: "flex", alignItems: "center", justifyContent: "center", color: "#57606a", fontSize: 14 }}>
|
|
482
|
+
Select or upload an image to begin.
|
|
483
|
+
</div>
|
|
484
|
+
) : (
|
|
485
|
+
<canvas
|
|
486
|
+
ref={canvasRef}
|
|
487
|
+
width={viewport.w}
|
|
488
|
+
height={viewport.h}
|
|
489
|
+
onMouseDown={onCanvasMouseDown}
|
|
490
|
+
onMouseMove={onCanvasMouseMove}
|
|
491
|
+
onMouseUp={onCanvasMouseUp}
|
|
492
|
+
onMouseLeave={onCanvasMouseLeave}
|
|
493
|
+
style={{
|
|
494
|
+
display: "block",
|
|
495
|
+
margin: "0 auto",
|
|
496
|
+
maxWidth: "100%",
|
|
497
|
+
backgroundImage: "linear-gradient(45deg,#eee 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#eee 75%)",
|
|
498
|
+
backgroundSize: "16px 16px",
|
|
499
|
+
backgroundPosition: "0 0,8px 8px",
|
|
500
|
+
borderRadius: 8,
|
|
501
|
+
}}
|
|
502
|
+
/>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
507
|
+
<div style={ui.card}>
|
|
508
|
+
<h4 style={ui.heading}>Scale image</h4>
|
|
509
|
+
<div style={{ ...ui.small, marginTop: 6 }}>Original dimensions {originalSize}</div>
|
|
510
|
+
<div style={ui.small}>Current view {currentSize}</div>
|
|
511
|
+
<div style={{ display: "grid", gridTemplateColumns: "90px 1fr", gap: 8, marginTop: 8 }}>
|
|
512
|
+
<label style={ui.label}>Width</label>
|
|
513
|
+
<input
|
|
514
|
+
style={ui.input}
|
|
515
|
+
value={scaleW}
|
|
516
|
+
onFocus={() => { scaleInputRef.current.width = true; scaleInputRef.current.height = false; }}
|
|
517
|
+
onChange={(e) => {
|
|
518
|
+
const value = e.target.value.replace(/[^0-9]/g, "");
|
|
519
|
+
scaleInputRef.current.width = true;
|
|
520
|
+
scaleInputRef.current.height = false;
|
|
521
|
+
setScaleW(value);
|
|
522
|
+
if (value && originalAspectRatio > 0) {
|
|
523
|
+
const w = parseInt(value, 10);
|
|
524
|
+
if (!Number.isNaN(w) && w > 0) setScaleH(Math.round(w / originalAspectRatio).toString());
|
|
525
|
+
} else if (!value) setScaleH("");
|
|
526
|
+
}}
|
|
527
|
+
placeholder="px"
|
|
528
|
+
/>
|
|
529
|
+
<label style={ui.label}>Height</label>
|
|
530
|
+
<input
|
|
531
|
+
style={ui.input}
|
|
532
|
+
value={scaleH}
|
|
533
|
+
onFocus={() => { scaleInputRef.current.width = false; scaleInputRef.current.height = true; }}
|
|
534
|
+
onChange={(e) => {
|
|
535
|
+
const value = e.target.value.replace(/[^0-9]/g, "");
|
|
536
|
+
scaleInputRef.current.width = false;
|
|
537
|
+
scaleInputRef.current.height = true;
|
|
538
|
+
setScaleH(value);
|
|
539
|
+
if (value && originalAspectRatio > 0) {
|
|
540
|
+
const h = parseInt(value, 10);
|
|
541
|
+
if (!Number.isNaN(h) && h > 0) setScaleW(Math.round(h * originalAspectRatio).toString());
|
|
542
|
+
} else if (!value) setScaleW("");
|
|
543
|
+
}}
|
|
544
|
+
placeholder="px"
|
|
545
|
+
/>
|
|
546
|
+
</div>
|
|
547
|
+
<div style={{ marginTop: 8 }}>
|
|
548
|
+
<button style={ui.btn} onClick={onScale}>Scale</button>
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
|
|
552
|
+
<div style={ui.card}>
|
|
553
|
+
<h4 style={ui.heading}>Crop image</h4>
|
|
554
|
+
<div style={{ display: "grid", gridTemplateColumns: "110px 1fr", gap: 8, marginTop: 8 }}>
|
|
555
|
+
<label style={ui.label}>Aspect ratio</label>
|
|
556
|
+
<input
|
|
557
|
+
style={ui.input}
|
|
558
|
+
placeholder="e.g. 16/9 or 1.333"
|
|
559
|
+
onBlur={(e) => {
|
|
560
|
+
const v = e.target.value.trim();
|
|
561
|
+
if (!v) return setAspect(undefined);
|
|
562
|
+
let val = Number(v);
|
|
563
|
+
if (Number.isNaN(val) && v.includes("/")) {
|
|
564
|
+
const [a, b] = v.split("/").map(Number);
|
|
565
|
+
if (!Number.isNaN(a) && !Number.isNaN(b) && b !== 0) val = a / b;
|
|
566
|
+
}
|
|
567
|
+
setAspect(!Number.isNaN(val) && val > 0 ? val : undefined);
|
|
568
|
+
}}
|
|
569
|
+
/>
|
|
570
|
+
<label style={ui.label}>Start (x, y)</label>
|
|
571
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
|
572
|
+
<input
|
|
573
|
+
type="number"
|
|
574
|
+
style={ui.input}
|
|
575
|
+
disabled={!cropRect}
|
|
576
|
+
value={cropRect ? Math.round(cropRect.x) : ""}
|
|
577
|
+
onChange={(e) => {
|
|
578
|
+
if (!cropRect) return;
|
|
579
|
+
setCropRect((r) => clampRectToViewport({ ...r, x: parseInt(e.target.value || "0", 10) }, viewport));
|
|
580
|
+
}}
|
|
581
|
+
/>
|
|
582
|
+
<input
|
|
583
|
+
type="number"
|
|
584
|
+
style={ui.input}
|
|
585
|
+
disabled={!cropRect}
|
|
586
|
+
value={cropRect ? Math.round(cropRect.y) : ""}
|
|
587
|
+
onChange={(e) => {
|
|
588
|
+
if (!cropRect) return;
|
|
589
|
+
setCropRect((r) => clampRectToViewport({ ...r, y: parseInt(e.target.value || "0", 10) }, viewport));
|
|
590
|
+
}}
|
|
591
|
+
/>
|
|
592
|
+
</div>
|
|
593
|
+
<label style={ui.label}>Selection (w × h)</label>
|
|
594
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
|
595
|
+
<input
|
|
596
|
+
type="number"
|
|
597
|
+
min={10}
|
|
598
|
+
style={ui.input}
|
|
599
|
+
disabled={!cropRect}
|
|
600
|
+
value={cropRect ? Math.round(cropRect.width) : ""}
|
|
601
|
+
onChange={(e) => {
|
|
602
|
+
if (!cropRect) return;
|
|
603
|
+
let w = Math.max(10, parseInt(e.target.value || "0", 10));
|
|
604
|
+
let h = cropRect.height;
|
|
605
|
+
if (aspect > 0) h = Math.round(w / aspect);
|
|
606
|
+
setCropRect((r) => clampRectToViewport({ ...r, width: w, height: h }, viewport));
|
|
607
|
+
}}
|
|
608
|
+
/>
|
|
609
|
+
<input
|
|
610
|
+
type="number"
|
|
611
|
+
min={10}
|
|
612
|
+
style={ui.input}
|
|
613
|
+
disabled={!cropRect}
|
|
614
|
+
value={cropRect ? Math.round(cropRect.height) : ""}
|
|
615
|
+
onChange={(e) => {
|
|
616
|
+
if (!cropRect) return;
|
|
617
|
+
let h = Math.max(10, parseInt(e.target.value || "0", 10));
|
|
618
|
+
let w = cropRect.width;
|
|
619
|
+
if (aspect > 0) w = Math.round(h * aspect);
|
|
620
|
+
setCropRect((r) => clampRectToViewport({ ...r, width: w, height: h }, viewport));
|
|
621
|
+
}}
|
|
622
|
+
/>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
|
|
626
|
+
<button style={{ ...ui.btn, flex: 1 }} disabled={!cropRect} onClick={applyCrop}>Apply Crop</button>
|
|
627
|
+
<button style={{ ...ui.btn, flex: 1 }} disabled={!cropRect} onClick={clearCrop}>Clear Crop</button>
|
|
628
|
+
</div>
|
|
629
|
+
</div>
|
|
630
|
+
|
|
631
|
+
<div style={ui.card}>
|
|
632
|
+
<h4 style={ui.heading}>Rotation & Flip</h4>
|
|
633
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginTop: 8 }}>
|
|
634
|
+
<button style={ui.btn} onClick={rotateLeft}>Rotate 90° left</button>
|
|
635
|
+
<button style={ui.btn} onClick={rotateRight}>Rotate 90° right</button>
|
|
636
|
+
<button style={{ ...ui.btn, gridColumn: "1 / span 2" }} onClick={rotate180}>Rotate 180°</button>
|
|
637
|
+
<button style={ui.btn} onClick={flipV}>Flip vertical</button>
|
|
638
|
+
<button style={ui.btn} onClick={flipH}>Flip horizontal</button>
|
|
639
|
+
</div>
|
|
640
|
+
</div>
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
);
|
|
646
|
+
}
|
package/src/index.js
ADDED