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 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
@@ -0,0 +1,2 @@
1
+ export { default as ImageEditor } from "./ImageEditor.jsx";
2
+ export { default } from "./ImageEditor.jsx";