react-photo-editor
Version:
React component and hook for image editing, offering controls for brightness, contrast, saturation, and grayscale, along with features to rotate, flip, pan, draw, and zoom photos.
697 lines (696 loc) • 28.1 kB
JavaScript
import { jsx as t, Fragment as we, jsxs as i } from "react/jsx-runtime";
import { useRef as fe, useState as s, useEffect as ye } from "react";
import './index.css';const be = ({
file: c,
defaultBrightness: T = 100,
defaultContrast: f = 100,
defaultSaturate: G = 100,
defaultGrayscale: J = 0,
defaultFlipHorizontal: q = !1,
defaultFlipVertical: A = !1,
defaultZoom: K = 1,
defaultRotate: D = 0,
defaultLineColor: y = "#000000",
defaultLineWidth: Q = 2,
defaultMode: pe = "pan"
}) => {
const p = fe(null), w = fe(new Image()), [L, he] = s(""), [o, _] = s(T), [S, ee] = s(f), [V, re] = s(G), [Y, te] = s(J), [I, ae] = s(D), [x, ne] = s(q), [b, oe] = s(A), [u, z] = s(K), [M, v] = s(!1), [P, B] = s(null), [k, H] = s(0), [N, X] = s(0), [W, ie] = s(pe), [U, O] = s(null), [E, j] = s(y), [$, se] = s(Q), m = fe([]);
ye(() => {
if (c) {
const e = URL.createObjectURL(c);
return he(e), () => {
URL.revokeObjectURL(e);
};
}
}, [c]), ye(() => {
R();
}, [
c,
L,
I,
x,
b,
u,
o,
S,
V,
Y,
k,
N
]);
const me = (e) => {
m.current.forEach(({ path: r, color: n, width: l }) => {
e.beginPath(), e.strokeStyle = n, e.lineWidth = l, e.lineCap = "round", e.lineJoin = "round", r.forEach((d, h) => {
h === 0 ? e.moveTo(d.x, d.y) : e.lineTo(d.x, d.y);
}), e.stroke();
});
}, R = () => {
if (!L) return;
const e = p.current, r = e == null ? void 0 : e.getContext("2d"), n = w.current;
w.current.src = L, w.current.onload = R, n.onload = () => {
if (e && r) {
const l = n.width * u, d = n.height * u, h = (n.width - l) / 2, g = (n.height - d) / 2;
if (e.width = n.width, e.height = n.height, r.clearRect(0, 0, e.width, e.height), r.filter = C(), r.save(), I) {
const F = e.width / 2, de = e.height / 2;
r.translate(F, de), r.rotate(I * Math.PI / 180), r.translate(-F, -de);
}
x && (r.translate(e.width, 0), r.scale(-1, 1)), b && (r.translate(0, e.height), r.scale(1, -1)), r.translate(h + k, g + N), r.scale(u, u), r.drawImage(n, 0, 0, e.width, e.height), r.restore(), r.filter = "none", me(r);
}
};
}, ue = () => new Promise((e) => {
const r = p.current;
if (!r || !c) {
e(null);
return;
}
const n = (c.name.split(".").pop() || "").toLowerCase();
let l;
switch (n) {
case "jpg":
case "jpeg":
l = "image/jpeg";
break;
case "png":
l = "image/png";
break;
default:
l = "image/png";
}
r.toBlob((d) => {
if (d) {
const h = new File([d], c.name, { type: d.type });
e(h);
} else
e(null);
}, l);
}), a = () => {
const e = p.current;
if (e && c) {
const r = document.createElement("a");
r.download = c.name, r.href = e.toDataURL(c == null ? void 0 : c.type), r.click();
}
}, C = () => `brightness(${o}%) contrast(${S}%) grayscale(${Y}%) saturate(${V}%)`, le = () => {
z((e) => e + 0.1);
}, ce = () => {
z((e) => Math.max(e - 0.1, 0.1));
};
return {
/** Reference to the canvas element. */
canvasRef: p,
/** Source URL of the image being edited. */
imageSrc: L,
/** Current brightness level. */
brightness: o,
/** Current contrast level. */
contrast: S,
/** Current saturation level. */
saturate: V,
/** Current grayscale level. */
grayscale: Y,
/** Current rotation angle in degrees. */
rotate: I,
/** Flag indicating if the image is flipped horizontally. */
flipHorizontal: x,
/** Flag indicating if the image is flipped vertically. */
flipVertical: b,
/** Current zoom level. */
zoom: u,
/** Flag indicating if the image is being dragged. */
isDragging: M,
/** Starting coordinates for panning. */
panStart: P,
/** Current horizontal offset for panning. */
offsetX: k,
/** Current vertical offset for panning. */
offsetY: N,
/** Current mode ('pan' or 'draw') */
mode: W,
/** Current line color. */
lineColor: E,
/** Current line width. */
lineWidth: $,
/** Function to set the brightness level. */
setBrightness: _,
/** Function to set the contrast level. */
setContrast: ee,
/** Function to set the saturation level. */
setSaturate: re,
/** Function to set the grayscale level. */
setGrayscale: te,
/** Function to set the rotation angle. */
setRotate: ae,
/** Function to set the horizontal flip state. */
setFlipHorizontal: ne,
/** Function to set the vertical flip state. */
setFlipVertical: oe,
/** Function to set the zoom level. */
setZoom: z,
/** Function to set the dragging state. */
setIsDragging: v,
/** Function to set the starting coordinates for panning. */
setPanStart: B,
/** Function to set the horizontal offset for panning. */
setOffsetX: H,
/** Function to set the vertical offset for panning. */
setOffsetY: X,
/** Function to zoom in. */
handleZoomIn: le,
/** Function to zoom out. */
handleZoomOut: ce,
/** Function to handle pointer down events. */
handlePointerDown: (e) => {
if (W === "draw") {
const r = p.current;
if (!r) return;
const n = r.getBoundingClientRect(), l = r.width / n.width, d = r.height / n.height, h = (e.clientX - n.left) * l, g = (e.clientY - n.top) * d;
O({ x: h, y: g }), m.current.push({ path: [{ x: h, y: g }], color: E, width: $ });
} else {
v(!0);
const r = e.clientX - (x ? -k : k), n = e.clientY - (b ? -N : N);
B({ x: r, y: n });
}
},
/** Function to handle pointer up events. */
handlePointerUp: () => {
v(!1), O(null);
},
/** Function to handle pointer move events. */
handlePointerMove: (e) => {
if (W === "draw" && U) {
const r = p.current, n = r == null ? void 0 : r.getContext("2d"), l = r == null ? void 0 : r.getBoundingClientRect();
if (!r || !n || !l) return;
const d = r.width / l.width, h = r.height / l.height, g = (e.clientX - l.left) * d, F = (e.clientY - l.top) * h, de = m.current[m.current.length - 1].path;
n.strokeStyle = E, n.lineWidth = $, n.lineCap = "round", n.lineJoin = "round", n.beginPath(), n.moveTo(U.x, U.y), n.lineTo(g, F), n.stroke(), O({ x: g, y: F }), de.push({ x: g, y: F });
return;
}
if (M && P) {
e.preventDefault();
const r = e.clientX - P.x, n = e.clientY - P.y;
H(x ? -r : r), X(b ? -n : n);
}
},
/** Function to handle wheel events for zooming. */
handleWheel: (e) => {
e.deltaY < 0 ? le() : ce();
},
/** Function to download the edited image. */
downloadImage: a,
/** Function to generate the edited image file. */
generateEditedFile: ue,
/** Function to reset filters and styles to default. */
resetFilters: () => {
_(T), ee(f), re(G), te(J), ae(D), ne(q), oe(A), z(K), j(y), se(Q), m.current = [], H(0), X(0), B(null), v(!1), ie("pan"), R();
},
/** Function to apply filters and transformations. */
applyFilter: R,
/** Function to set the mode. */
setMode: ie,
/** Function to set the line color. */
setLineColor: j,
/** Function to set the line width. */
setLineWidth: se
};
}, xe = "rpe-text-gray-900 rpe-bg-white rpe-border rpe-border-gray-300 rpe-ml-2 focus:rpe-outline-hidden hover:rpe-bg-gray-100 focus:rpe-ring-4 focus:rpe-ring-gray-100 rpe-font-medium rpe-rounded-full rpe-text-sm rpe-px-2 rpe-py-1 dark:rpe-bg-gray-800 dark:rpe-text-white dark:rpe-border-gray-600 dark:hover:rpe-bg-gray-700 dark:hover:rpe-border-gray-600 dark:focus:rpe-ring-gray-700", Ie = ({
file: c,
onSaveImage: T,
allowColorEditing: f = !0,
allowFlip: G = !0,
allowRotate: J = !0,
allowZoom: q = !0,
allowDrawing: A = !0,
downloadOnSave: K,
open: D,
onClose: y,
modalHeight: Q,
modalWidth: pe,
canvasHeight: p,
canvasWidth: w,
maxCanvasHeight: L,
maxCanvasWidth: he,
labels: o = {
close: "Close",
save: "Save",
rotate: "Rotate",
brightness: "Brightness",
contrast: "Contrast",
saturate: "Saturate",
grayscale: "Grayscale",
reset: "Reset photo",
flipHorizontal: "Flip photo horizontally",
flipVertical: "Flip photo vertically",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
draw: "Draw",
brushColor: "Choose brush color",
brushWidth: "Choose brush width"
}
}) => {
const {
canvasRef: _,
brightness: S,
setBrightness: ee,
contrast: V,
setContrast: re,
saturate: Y,
setSaturate: te,
grayscale: I,
setGrayscale: ae,
rotate: x,
setRotate: ne,
flipHorizontal: b,
setFlipHorizontal: oe,
flipVertical: u,
setFlipVertical: z,
mode: M,
setMode: v,
setLineColor: P,
lineColor: B,
setLineWidth: k,
lineWidth: H,
isDragging: N,
handlePointerDown: X,
handlePointerUp: W,
handlePointerMove: ie,
handleWheel: U,
handleZoomIn: O,
handleZoomOut: E,
resetFilters: j,
downloadImage: $,
generateEditedFile: se
} = be({ file: c });
ye(() => {
j();
}, [D]);
const m = (a, C, le, ce) => {
var ge;
const Z = parseInt((ge = a.target) == null ? void 0 : ge.value);
!isNaN(Z) && Z >= le && Z <= ce && C(Z);
}, me = [
{
name: o.rotate,
value: x,
setValue: ne,
min: -180,
max: 180,
type: "range",
id: "rotateInput",
"aria-labelledby": "rotateInputLabel",
hide: !J
},
{
name: o.brightness,
value: S,
setValue: ee,
min: 0,
max: 200,
type: "range",
id: "brightnessInput",
"aria-labelledby": "brightnessInputLabel",
hide: !f
},
{
name: o.contrast,
value: V,
setValue: re,
min: 0,
max: 200,
type: "range",
id: "contrastInput",
"aria-labelledby": "contrastInputLabel",
hide: !f
},
{
name: o.saturate,
value: Y,
setValue: te,
min: 0,
max: 200,
type: "range",
id: "saturateInput",
"aria-labelledby": "saturateInputLabel",
hide: !f
},
{
name: o.grayscale,
value: I,
setValue: ae,
min: 0,
max: 100,
type: "range",
id: "grayscaleInput",
"aria-labelledby": "grayscaleInputLabel",
hide: !f
}
], R = () => {
j(), y && y();
}, ue = async () => {
K && $();
const a = await se();
a && T(a), y && y();
};
return /* @__PURE__ */ t(we, { children: D && /* @__PURE__ */ i(we, { children: [
/* @__PURE__ */ t(
"div",
{
"data-testid": "photo-editor-main",
className: "photo-editor-main rpe-justify-center rpe-items-center rpe-flex rpe-overflow-auto rpe-fixed rpe-inset-0 rpe-z-50",
children: /* @__PURE__ */ i(
"div",
{
style: {
height: Q ?? "38rem",
width: pe ?? "40rem"
},
id: "photo-editor-modal",
className: "rpe-relative rpe-rounded-lg rpe-shadow-lg rpe-max-sm:w-[22rem] rpe-bg-white dark:rpe-bg-[#1e1e1e]",
children: [
/* @__PURE__ */ i("div", { className: "rpe-flex rpe-justify-end rpe-p-2 rpe-rounded-t", children: [
/* @__PURE__ */ t("button", { className: xe, onClick: R, type: "button", children: o.close }),
/* @__PURE__ */ t(
"button",
{
className: xe,
onClick: () => void ue(),
type: "button",
"data-testid": "save-button",
children: o.save
}
)
] }),
/* @__PURE__ */ t("div", { className: "rpe-p-2", children: /* @__PURE__ */ i("div", { className: "rpe-flex rpe-flex-col", children: [
/* @__PURE__ */ t(
"canvas",
{
style: {
width: w ?? "auto",
height: p ?? "auto",
maxHeight: L ?? "22rem",
maxWidth: he ?? "36rem"
},
className: `rpe-canvas rpe-touch-none rpe-border dark:rpe-border-gray-700 rpe-object-fill rpe-mx-auto
${M === "draw" ? "rpe-cursor-crosshair" : N ? "rpe-cursor-grabbing" : "rpe-cursor-grab"}`,
"data-testid": "image-editor-canvas",
id: "rpe-canvas",
ref: _,
onPointerDown: X,
onPointerMove: ie,
onPointerUp: W,
onPointerLeave: W,
onWheel: U,
width: typeof w == "number" ? w : void 0,
height: typeof p == "number" ? p : void 0
}
),
/* @__PURE__ */ t("div", { className: "rpe-items-center rpe-flex rpe-m-1 rpe-flex-col", children: /* @__PURE__ */ t("div", { className: "rpe-flex rpe-flex-col rpe-bottom-12 rpe-gap-1 rpe-mt-4 rpe-max-sm:w-72 rpe-w-11/12 rpe-absolute ", children: me.map(
(a) => !a.hide && /* @__PURE__ */ i(
"div",
{
className: "rpe-flex rpe-flex-row rpe-items-center",
children: [
/* @__PURE__ */ i(
"label",
{
id: `${a.name}InputLabel`,
className: "rpe-text-xs rpe-font-medium rpe-text-gray-900 dark:rpe-text-white rpe-w-10",
htmlFor: a.id,
children: [
a.name[0].toUpperCase() + a.name.slice(1),
":",
" "
]
}
),
/* @__PURE__ */ t(
"input",
{
id: a.id,
type: a.type,
value: a.value,
step: "1",
onChange: (C) => m(C, a.setValue, a.min, a.max),
min: a.min,
max: a.max,
className: "rpe-ml-[1.7rem] rpe-w-full rpe-h-1 rpe-bg-gray-200 rpe-rounded-lg rpe-appearance-none rpe-cursor-pointer rpe-range-sm dark:rpe-bg-gray-700",
"aria-labelledby": `${a.name}InputLabel`,
"aria-valuemin": a.min,
"aria-valuemax": a.max,
"aria-valuenow": a.value
}
),
/* @__PURE__ */ t(
"input",
{
type: "number",
value: a.value,
onChange: (C) => m(C, a.setValue, a.min, a.max),
min: a.min,
max: a.max,
className: "rpe-w-14 rpe-ml-2 rpe-rounded-md rpe-text-right rpe-bg-gray-100 rpe-text-black dark:rpe-bg-gray-700 dark:rpe-text-white",
"aria-labelledby": `${a.name}InputLabel`,
"aria-valuemin": a.min,
"aria-valuemax": a.max,
"aria-valuenow": a.value
}
)
]
},
a.name
)
) }) }),
/* @__PURE__ */ t("div", { className: "rpe-flex rpe-justify-center rpe-items-center rpe-content-center", children: /* @__PURE__ */ i("div", { className: "rpe-absolute rpe-bottom-0 rpe-mt-2 rpe-flex rpe-items-center", children: [
/* @__PURE__ */ t(
"button",
{
title: o.reset,
className: "rpe-mx-1 focus:rpe-ring-2 focus:rpe-ring-gray-300 dark:focus:rpe-ring-gray-700 rpe-rounded-md rpe-p-1",
onClick: j,
"aria-label": o.reset,
type: "button",
children: /* @__PURE__ */ i(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
className: "lucide lucide-rotate-ccw dark:rpe-stroke-slate-200",
children: [
/* @__PURE__ */ t("path", { d: "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }),
/* @__PURE__ */ t("path", { d: "M3 3v5h5" })
]
}
)
}
),
G && /* @__PURE__ */ i("div", { className: "rpe-inline-block", "data-testid": "flip-btns", children: [
/* @__PURE__ */ t(
"button",
{
className: "rpe-mx-1 focus:rpe-ring-2 focus:rpe-ring-gray-300 dark:focus:rpe-ring-gray-700 rpe-rounded-md rpe-p-1",
onClick: () => oe(!b),
type: "button",
title: o.flipHorizontal,
"aria-label": o.flipHorizontal,
children: /* @__PURE__ */ i(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
className: "lucide lucide-flip-horizontal dark:rpe-stroke-slate-200",
children: [
/* @__PURE__ */ t("path", { d: "M8 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h3" }),
/* @__PURE__ */ t("path", { d: "M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3" }),
/* @__PURE__ */ t("path", { d: "M12 20v2" }),
/* @__PURE__ */ t("path", { d: "M12 14v2" }),
/* @__PURE__ */ t("path", { d: "M12 8v2" }),
/* @__PURE__ */ t("path", { d: "M12 2v2" })
]
}
)
}
),
/* @__PURE__ */ t(
"button",
{
className: "rpe-mx-1 focus:rpe-ring-2 focus:rpe-ring-gray-300 dark:focus:rpe-ring-gray-700 rpe-rounded-md rpe-p-1",
onClick: () => z(!u),
type: "button",
title: o.flipVertical,
"aria-label": o.flipVertical,
children: /* @__PURE__ */ i(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
className: "lucide lucide-flip-vertical dark:rpe-stroke-slate-200",
children: [
/* @__PURE__ */ t("path", { d: "M21 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v3" }),
/* @__PURE__ */ t("path", { d: "M21 16v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3" }),
/* @__PURE__ */ t("path", { d: "M4 12H2" }),
/* @__PURE__ */ t("path", { d: "M10 12H8" }),
/* @__PURE__ */ t("path", { d: "M16 12h-2" }),
/* @__PURE__ */ t("path", { d: "M22 12h-2" })
]
}
)
}
)
] }),
q && /* @__PURE__ */ i("div", { className: "rpe-inline-block", "data-testid": "zoom-btns", children: [
/* @__PURE__ */ t(
"button",
{
"data-testid": "zoom-in",
type: "button",
className: "rpe-mx-1 focus:rpe-ring-2 focus:rpe-ring-gray-300 dark:focus:rpe-ring-gray-700 rpe-rounded-md rpe-p-1",
onClick: O,
title: o.zoomIn,
"aria-label": o.zoomIn,
children: /* @__PURE__ */ i(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
className: "lucide lucide-zoom-in dark:rpe-stroke-slate-200",
children: [
/* @__PURE__ */ t("circle", { cx: "11", cy: "11", r: "8" }),
/* @__PURE__ */ t("line", { x1: "21", x2: "16.65", y1: "21", y2: "16.65" }),
/* @__PURE__ */ t("line", { x1: "11", x2: "11", y1: "8", y2: "14" }),
/* @__PURE__ */ t("line", { x1: "8", x2: "14", y1: "11", y2: "11" })
]
}
)
}
),
/* @__PURE__ */ t(
"button",
{
"data-testid": "zoom-out",
type: "button",
className: "rpe-mx-1 focus:rpe-ring-2 focus:rpe-ring-gray-300 dark:focus:rpe-ring-gray-700 rpe-rounded-md rpe-p-1",
onClick: E,
title: o.zoomOut,
"aria-label": o.zoomOut,
children: /* @__PURE__ */ i(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
className: "lucide lucide-zoom-out dark:rpe-stroke-slate-200",
children: [
/* @__PURE__ */ t("circle", { cx: "11", cy: "11", r: "8" }),
/* @__PURE__ */ t("line", { x1: "21", x2: "16.65", y1: "21", y2: "16.65" }),
/* @__PURE__ */ t("line", { x1: "8", x2: "14", y1: "11", y2: "11" })
]
}
)
}
)
] }),
A && /* @__PURE__ */ i("div", { className: "rpe-flex rpe-items-center", children: [
/* @__PURE__ */ t(
"button",
{
"data-testid": "draw-btn",
type: "button",
className: "rpe-mx-1 focus:rpe-ring-2 focus:rpe-ring-gray-300 dark:focus:rpe-ring-gray-700 rpe-rounded-md rpe-p-1",
onClick: () => M == "pan" ? v("draw") : v("pan"),
title: o.draw,
"aria-label": o.draw,
children: /* @__PURE__ */ i(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: "2",
strokeLinecap: "round",
strokeLinejoin: "round",
className: "lucide lucide-brush dark:rpe-stroke-slate-200",
children: [
/* @__PURE__ */ t("path", { d: "m9.06 11.9 8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08" }),
/* @__PURE__ */ t("path", { d: "M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z" })
]
}
)
}
),
M == "draw" && /* @__PURE__ */ i("div", { className: "rpe-flex rpe-items-center dark:rpe-bg-zinc-600 rpe-bg-zinc-200 rpe-p-1 rpe-rounded-md", children: [
/* @__PURE__ */ t(
"input",
{
type: "color",
onChange: (a) => P(a.target.value),
className: "rpe-mx-1 rpe-w-6 rpe-h-6 rpe-rounded-sm focus:rpe-ring-2 focus:rpe-ring-gray-300 dark:focus:rpe-ring-gray-700",
id: "rpe-brush-color",
value: B,
title: o.brushColor,
"aria-label": o.brushColor
}
),
/* @__PURE__ */ t(
"input",
{
type: "number",
title: o.brushWidth,
"aria-label": o.brushWidth,
onChange: (a) => m(a, k, 2, 100),
className: "rpe-w-12 rpe-ml-2 rpe-rounded-md rpe-text-right rpe-bg-gray-100 rpe-text-black dark:rpe-bg-gray-700 dark:rpe-text-white rpe-mx-1 rpe-p-0 rpe-text-sm",
id: "rpe-brush-width",
value: H,
min: 2,
max: 100
}
)
] })
] })
] }) })
] }) })
]
}
)
}
),
/* @__PURE__ */ t("div", { className: "rpe-opacity-75 rpe-fixed rpe-inset-0 rpe-z-40 rpe-bg-black" })
] }) });
};
export {
Ie as ReactPhotoEditor,
be as usePhotoEditor
};