Skip to content

Commit

Permalink
Merge pull request #25 from dcramer/feat/jank-image-editor
Browse files Browse the repository at this point in the history
Add draft image editor
  • Loading branch information
dcramer committed May 22, 2023
2 parents d610255 + bda81da commit b665837
Show file tree
Hide file tree
Showing 8 changed files with 265 additions and 58 deletions.
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

0 comments on commit b665837

Please sign in to comment.