Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add draft image editor #25

Merged
merged 1 commit into from May 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Expand Up @@ -21,6 +21,7 @@
"@tailwindcss/typography": "^0.5.9",
"@tanstack/react-query": "^4.29.7",
"@types/react": "^18.2.5",
"@types/react-avatar-editor": "^13.0.0",
"@types/react-dom": "^18.2.2",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.0.0",
Expand All @@ -30,7 +31,9 @@
"framer-motion": "^10.12.8",
"postcss": "^8.4.23",
"react": "^18.2.0",
"react-avatar-editor": "^13.0.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.43.9",
"react-router-dom": "^6.11.0",
"tailwindcss": "^3.3.2",
Expand Down
109 changes: 77 additions & 32 deletions apps/web/src/components/imageField.tsx
Expand Up @@ -4,16 +4,23 @@ import { PhotoIcon } from "@heroicons/react/20/solid";
import { FieldError } from "react-hook-form";
import setRef from "../lib/setRef";
import FormField from "./formField";
import ImageModal from "./imageModal";
import TextInput from "./textInput";

type Props = Omit<React.ComponentProps<typeof TextInput>, "value"> & {
type Props = Omit<
React.ComponentProps<typeof TextInput>,
"value" | "onChange"
> & {
label?: string;
helpText?: string;
required?: boolean;
children?: ReactNode;
className?: string;
value?: string | File | undefined;
value?: string | null | undefined;
error?: FieldError;
onChange: (value: HTMLCanvasElement | null) => void;
imageWidth?: number;
imageHeight?: number;
};

const fileToDataUrl = (file: File): Promise<string> => {
Expand All @@ -35,36 +42,53 @@ const fileToDataUrl = (file: File): Promise<string> => {

export default forwardRef<HTMLInputElement, Props>(
(
{ name, label, helpText, required, className, value, error, onChange },
{
name,
label,
helpText,
required,
className,
value,
error,
onChange,
imageWidth = 250,
imageHeight = 250,
},
ref,
) => {
const fileRef = useRef<HTMLInputElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [_isHover, setHover] = useState(false);
const [imageSrc, setImageSrc] = useState<string | null>();
const [finalImage, setFinalImage] = useState<HTMLCanvasElement | null>();

const [editorOpen, setEditorOpen] = useState(false);

useEffect(() => {
(async () => {
if (value instanceof File) {
setImageSrc(await fileToDataUrl(value));
} else {
setImageSrc(value || "");
}
})();
setImageSrc(value || "");
setFinalImage(null);
}, [value]);

const updatePreview = () => {
const updateImageSrc = () => {
const file = Array.from(fileRef.current?.files || []).find(() => true);
if (file) {
(async () => {
setImageSrc(await fileToDataUrl(file));
const imageSrc = await fileToDataUrl(file);
setImageSrc(imageSrc);
setEditorOpen(true);
})();
} else {
if (imageRef.current) imageRef.current.src = "";
setImageSrc("");
setEditorOpen(true);
}
};

const onSave = (image: HTMLCanvasElement | null) => {
setFinalImage(image);
if (onChange) onChange(image);
};

return (
<FormField
label={label}
Expand Down Expand Up @@ -102,19 +126,29 @@ export default forwardRef<HTMLInputElement, Props>(
const dt = new DataTransfer();
Array.from(e.dataTransfer.files).forEach((f) => dt.items.add(f));
if (fileRef.current) fileRef.current.files = dt.files;
updatePreview();
updateImageSrc();
}}
>
<div className="col-span-full mt-2 flex min-w-full items-center gap-x-4">
<div className="flex max-h-[250px] min-w-full items-center justify-center overflow-hidden rounded bg-slate-900 object-cover">
{imageSrc ? (
<div className="flex min-w-full items-center justify-center overflow-hidden rounded bg-slate-900 object-contain">
{imageSrc || finalImage ? (
<img
src={imageSrc}
src={
(finalImage ? finalImage.toDataURL() : imageSrc) || undefined
}
ref={imageRef}
className="h-full rounded object-cover"
className={`"rounded object-contain`}
style={{
maxHeight: imageHeight,
}}
/>
) : (
<em className="text-light flex h-[250px] flex-col items-center justify-center text-sm">
<em
className={`text-light flex flex-col items-center justify-center text-sm`}
style={{
maxHeight: imageHeight,
}}
>
<PhotoIcon className="h-12 w-12" />
Tap to Upload an Image
</em>
Expand All @@ -129,26 +163,37 @@ export default forwardRef<HTMLInputElement, Props>(
ref={(node) => {
setRef(fileRef, node);
}}
onClick={(e: any) => {
// this forces onChange to fire
e.target.value = null;
}}
onChange={(e) => {
e.stopPropagation();
updatePreview();
if (onChange) onChange(e);
updateImageSrc();
}}
/>
{/* <div>
<Button
type="button"
color="primary"
onClick={(e) => {
e.stopPropagation();
fileRef.current?.click();
}}
>
{buttonLabel}
</Button>
<HelpText>JPG, GIF or PNG. 1MB max.</HelpText>
</div> */}
<Button
type="button"
color="primary"
onClick={(e) => {
e.stopPropagation();
fileRef.current?.click();
}}
>
Select Image
</Button>
<HelpText>JPG, GIF or PNG. 1MB max.</HelpText>
</div> */}
</div>
<ImageModal
image={imageSrc}
open={editorOpen}
setOpen={setEditorOpen}
onSave={onSave}
width={imageWidth}
height={imageHeight}
/>
</FormField>
);
},
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/components/imageModal.tsx
@@ -0,0 +1,74 @@
import { Dialog } from "@headlessui/react";
import { ArrowPathRoundedSquareIcon } from "@heroicons/react/20/solid";
import { useRef, useState } from "react";
import AvatarEditor from "react-avatar-editor";
import Button from "./button";

export default ({
image,
open,
setOpen,
onSave,
width = 250,
height = 250,
}: {
image: any;
open: boolean;
setOpen: (value: boolean) => void;
onSave: (newImage: any) => void;
width?: number;
height?: number;
}) => {
const ref = useRef<AvatarEditor>(null);
const [rotate, setRotate] = useState(0);
const [scale, setScale] = useState(1);

return (
<Dialog open={open} as="div" className="dialog" onClose={setOpen}>
<Dialog.Overlay className="fixed inset-0" />
<Dialog.Panel className="dialog-panel flex flex-col items-center justify-center px-4 pb-4 pt-5 sm:p-6">
<AvatarEditor
ref={ref}
image={image}
width={width}
height={height}
border={50}
scale={scale}
rotate={rotate}
/>
<div className="flex flex-col gap-x-2 gap-y-2">
<input
type="range"
min={1}
max={3}
step={0.01}
value={scale}
onInput={(e) => {
setScale(parseFloat((e as any).target.value));
}}
/>
<div className="flex gap-x-2">
<Button
onClick={() => {
const newRotate = rotate >= 270 ? 0 : rotate + 90;
setRotate(newRotate);
}}
icon={<ArrowPathRoundedSquareIcon className="h-6 w-6" />}
></Button>
<Button
color="highlight"
onClick={() => {
if (ref.current) {
onSave(ref.current.getImageScaledToCanvas());
setOpen(false);
}
}}
>
Save
</Button>
</div>
</div>
</Dialog.Panel>
</Dialog>
);
};
3 changes: 0 additions & 3 deletions apps/web/src/lib/api.tsx
Expand Up @@ -3,9 +3,6 @@ import config from "../config";
type ApiRequestOptions = {
method: "GET" | "POST" | "DELETE" | "PUT";
data?: { [name: string]: any } | undefined;
files?: {
[name: string]: Blob;
};
query?: any;
};

Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/lib/blobs.ts
@@ -0,0 +1,7 @@
export const toBlob = (canvas: HTMLCanvasElement) => {
return new Promise((resolve, reject) => {
canvas.toBlob(async (blob) => {
resolve(blob);
});
});
};
16 changes: 9 additions & 7 deletions apps/web/src/routes/addTasting.tsx
Expand Up @@ -19,6 +19,7 @@ import TextAreaField from "../components/textAreaField";
import TextField from "../components/textField";
import { useSuspenseQuery } from "../hooks/useSuspenseQuery";
import api, { ApiError } from "../lib/api";
import { toBlob } from "../lib/blobs";
import { toTitleCase } from "../lib/strings";
import type { Bottle, Paginated } from "../types";

Expand Down Expand Up @@ -47,7 +48,7 @@ export default function AddTasting() {
const navigate = useNavigate();

const [error, setError] = useState<string | undefined>();
const [image, setImage] = useState<string | File | undefined>();
const [picture, setPicture] = useState<HTMLCanvasElement | null>(null);

const {
control,
Expand Down Expand Up @@ -78,11 +79,13 @@ export default function AddTasting() {
setError("Internal error");
}
}
if (image) {

if (picture) {
const blob = await toBlob(picture);
try {
await api.post(`/tastings/${tasting.id}/image`, {
data: {
image,
image: blob,
},
});
} catch (err) {
Expand Down Expand Up @@ -154,10 +157,9 @@ export default function AddTasting() {
<ImageField
name="image"
label="Picture"
value={image}
onChange={(e) =>
setImage(e.target.files?.length ? e.target.files[0] : undefined)
}
onChange={(value) => setPicture(value)}
imageWidth={1024 / 2}
imageHeight={768 / 2}
/>

<div className="bg-highlight my-4 px-4 py-3 text-black">
Expand Down