-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
aeb4013
commit 839e786
Showing
5 changed files
with
2,380 additions
and
1,657 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
import { cn } from "@ui/lib/utils"; | ||
import { ImageIcon } from "lucide-react"; | ||
import { useEffect, useRef, useState } from "react"; | ||
|
||
export default function ImageDragAndDrop({ | ||
onDrop, | ||
onDragOver, | ||
onDragLeave, | ||
className, | ||
dropAreaStyles, | ||
}: { | ||
onDrop: (files: FileList) => void; | ||
className?: string; | ||
dropAreaStyles?: string; | ||
onDragOver?: (e: React.DragEvent) => void; | ||
onDragLeave?: (e: React.DragEvent) => void; | ||
}) { | ||
const drop = useRef(null); | ||
const [isDropping, setIsDropping] = useState(false); | ||
|
||
const handleDragOver = (e) => { | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
setIsDropping(true); | ||
}; | ||
|
||
const handleDragLeave = (e) => { | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
setIsDropping(false); | ||
}; | ||
|
||
const handleDrop = (e) => { | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
setIsDropping(false); | ||
const { files } = e.dataTransfer; | ||
|
||
if (files && files.length) { | ||
onDrop(files); | ||
} | ||
}; | ||
useEffect(() => { | ||
drop?.current?.addEventListener("dragover", handleDragOver); | ||
drop?.current?.addEventListener("drop", handleDrop); | ||
drop?.current?.addEventListener("dragleave", handleDragLeave); | ||
|
||
return () => { | ||
drop?.current?.removeEventListener("dragover", handleDragOver); | ||
drop?.current?.removeEventListener("drop", handleDrop); | ||
drop?.current?.removeEventListener("dragleave", handleDragLeave); | ||
}; | ||
}, []); | ||
|
||
return ( | ||
<div | ||
className={cn( | ||
"flex w-full items-center justify-center rounded-lg border border-dashed border-border/75 px-6 py-10", | ||
isDropping && "border-primary bg-primary/40", | ||
className, | ||
)} | ||
ref={drop} | ||
> | ||
<div className={cn("rounded px-6 py-4 text-center", dropAreaStyles)}> | ||
<ImageIcon | ||
className="drop-icon mx-auto h-12 w-12 text-muted-foreground/75" | ||
aria-hidden="true" | ||
/> | ||
<div className="flex justify-center text-sm leading-6 text-muted-foreground/75"> | ||
<label | ||
htmlFor="file-upload" | ||
className="relative cursor-pointer rounded-md bg-muted px-2 font-semibold text-foreground focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-primary" | ||
> | ||
<span className="inline"> | ||
Upload | ||
<span className="sr-only sm:not-sr-only">an image</span> | ||
</span> | ||
<input | ||
id="file-upload" | ||
name="file-upload" | ||
type="file" | ||
className="sr-only" | ||
accept="image/*" | ||
onChange={(e) => { | ||
if (e.target.files && e.target.files.length) { | ||
onDrop(e.target.files); | ||
} | ||
}} | ||
/> | ||
</label> | ||
<p className="sr-only sm:not-sr-only sm:pl-1">or drag and drop</p> | ||
</div> | ||
<p | ||
className={cn("text-xs leading-5 text-muted-foreground/75", { | ||
"opacity-0": isDropping, | ||
})} | ||
> | ||
PNG, JPG, GIF up to 10MB | ||
</p> | ||
</div> | ||
</div> | ||
); | ||
} |
248 changes: 248 additions & 0 deletions
248
apps/web/components/toolbar/plugin/upload-image.plugin.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,248 @@ | ||
"use client"; | ||
|
||
import { Button } from "ui/components/ui/button"; | ||
import React, { Fragment, useState } from "react"; | ||
import { cn } from "ui/lib/utils"; | ||
import { useToast } from "ui/components/ui/use-toast"; | ||
import { motion, AnimatePresence } from "framer-motion"; | ||
import { extractColors } from "extract-colors"; | ||
import ImageDragAndDrop from "@/components/core/image-drag-and-drop"; | ||
import { useColorStore } from "@/store/store"; | ||
import { colorHelper } from "@/lib/colorHelper"; | ||
import { BaseColorTypes } from "@/types/app"; | ||
import { FinalColor } from "extract-colors/lib/types/Color"; | ||
|
||
function UploadImaagePlugin({ setOpen }) { | ||
const { updateBaseColor, generatePalette } = useColorStore(); | ||
const [colors, setColors] = useState([]); | ||
const [imageBaseColors, setImageBaseColors] = useState([]); // [primary, secondary, accent] | ||
const [activeColorIndex, setActiveColorIndex] = useState(0); | ||
const [imgPreview, setImgPreview] = useState(null); | ||
const { toast } = useToast(); | ||
|
||
const handleDropImage = async (files: FileList) => { | ||
const file = files[0]; | ||
if (!file) return; | ||
|
||
// add file preview | ||
|
||
setImgPreview(URL.createObjectURL(file)); | ||
|
||
if (file.size > 10000000) { | ||
toast({ | ||
title: "File too large", | ||
description: "Please upload a file less than 10MB", | ||
variant: "destructive", | ||
}); | ||
return; | ||
} | ||
|
||
let colors = []; | ||
const reader = new FileReader(); | ||
|
||
reader.onload = (e) => { | ||
const img = new Image(); | ||
img.src = e.target.result as string; | ||
img.onload = async () => { | ||
// <-- This function is already async | ||
const canvas = document.createElement("canvas"); | ||
const ctx = canvas.getContext("2d"); | ||
canvas.width = img.width; | ||
canvas.height = img.height; | ||
ctx.drawImage(img, 0, 0); | ||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height); | ||
|
||
const extractedColors = await extractColors(data); // <-- Await the Promise | ||
extractedColors.forEach((color) => { | ||
// filter out colors that are too | ||
// muted (< 0.02 saturation), too dark (<0.03 lightness), or too light (>0.97 lightness) | ||
if ( | ||
color.saturation < 0.02 || | ||
color.lightness < 0.03 || | ||
color.lightness > 0.97 | ||
) | ||
return; | ||
colors.push(color); | ||
}); | ||
// if there are less than 3 colors, add white to the array | ||
if (colors.length < 3) { | ||
colors = [...colors, ...Array(3 - colors.length).fill("#fff")]; | ||
} | ||
updateBaseColor("primary", colorHelper.toRaw(colors[0].hex)); | ||
updateBaseColor("secondary", colorHelper.toRaw(colors[1].hex)); | ||
updateBaseColor("accent", colorHelper.toRaw(colors[2].hex)); | ||
generatePalette(true); | ||
setImageBaseColors(colors.slice(0, 3)); | ||
setColors(colors.slice(3)); | ||
}; | ||
}; | ||
|
||
reader.readAsDataURL(file); | ||
}; | ||
|
||
const handleUpdateNewBaseColor = (c: FinalColor, i) => { | ||
updateBaseColor( | ||
["primary", "secondary", "accent"][activeColorIndex] as BaseColorTypes, | ||
colorHelper.toRaw(c.hex), | ||
); | ||
generatePalette(true); | ||
// remove the selected color from the colors array and add the previous base color to the colors array | ||
const prevColors = [...colors]; | ||
prevColors.splice(i, 1); | ||
prevColors.push(imageBaseColors[activeColorIndex]); | ||
setColors(prevColors); | ||
|
||
// replace the color with the new one at the same index | ||
const prevBaseColors = [...imageBaseColors]; | ||
prevBaseColors[activeColorIndex] = c; | ||
setImageBaseColors(prevBaseColors); | ||
// set the next color as active | ||
activeColorIndex === 2 | ||
? setActiveColorIndex(0) | ||
: setActiveColorIndex(activeColorIndex + 1); | ||
}; | ||
console.log(colors.length); | ||
return ( | ||
<div | ||
className={cn( | ||
"mt-4 grid gap-4 sm:gap-6 lg:gap-8", | ||
"max-h-[calc(100dvh-5.5rem)] overflow-y-auto overflow-x-visible px-4 pb-6 sm:px-6 lg:px-8", | ||
!!imgPreview | ||
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3" | ||
: "grid-cols-1", | ||
)} | ||
> | ||
<div | ||
className={cn( | ||
"min-h-64 relative isolate h-[min(35vh,32rem)] w-full sm:col-span-2 lg:col-span-1", | ||
)} | ||
> | ||
<AnimatePresence mode="popLayout"> | ||
{imgPreview && ( | ||
<motion.img | ||
key={imgPreview} | ||
initial={{ opacity: 0, scale: 0.97 }} | ||
animate={{ opacity: 1, scale: 1 }} | ||
exit={{ opacity: 0, scale: 0.97 }} | ||
src={imgPreview} | ||
className="pointer-events-none absolute inset-0 z-[-2] h-full w-full rounded-md object-cover" | ||
/> | ||
)} | ||
</AnimatePresence> | ||
<ImageDragAndDrop | ||
onDrop={handleDropImage} | ||
className="h-full min-h-[16rem] w-full" | ||
dropAreaStyles={cn( | ||
"space-y-4", | ||
imgPreview && [ | ||
"bg-background/70 backdrop-blur-md backdrop-brightness-110 backdrop-saturate-150", | ||
"absolute bottom-0 inset-x-0 flex justify-center items-center flex flex-row", | ||
"[&_.drop-icon]:w-6 [&_.drop-icon]:h-6 [&_.drop-icon]:m-0", | ||
"space-y-0 space-x-2", | ||
], | ||
)} | ||
/> | ||
</div> | ||
{!!imgPreview && ( | ||
<Fragment> | ||
<motion.div | ||
initial={{ opacity: 0 }} | ||
animate={{ opacity: 1 }} | ||
exit={{ opacity: 0 }} | ||
> | ||
<h3 className="text-lg font-semibold">Base Colors</h3> | ||
<div className="mt-2 flex flex-wrap"> | ||
{imageBaseColors.map((color, index) => ( | ||
<motion.button | ||
layoutId={`color-${color.hex}`} | ||
key={`${color.hex}-${index}`} | ||
className={cn( | ||
"mb-2 flex h-12 w-full items-center justify-center", | ||
activeColorIndex === index | ||
? "ring-2 ring-primary ring-offset-2" | ||
: "ring-0", | ||
)} | ||
style={{ backgroundColor: color.hex, borderRadius: 24 }} | ||
onClick={() => setActiveColorIndex(index)} | ||
> | ||
<motion.span | ||
layoutId={`color-name-${color.hex}`} | ||
className={cn( | ||
"inline font-mono text-xs", | ||
color.lightness < 0.5 | ||
? "text-background" | ||
: "text-foreground", | ||
)} | ||
> | ||
{color.hex} | ||
</motion.span> | ||
</motion.button> | ||
))} | ||
</div> | ||
</motion.div> | ||
<motion.div | ||
initial={{ opacity: 0 }} | ||
animate={{ opacity: 1 }} | ||
exit={{ opacity: 0 }} | ||
> | ||
<div className="flex items-center justify-between"> | ||
<h3 className="text-lg font-semibold">Colors</h3> | ||
<Button | ||
variant="ghost" | ||
size="sm" | ||
className="-mr-1" | ||
onClick={() => { | ||
setColors([]); | ||
setImageBaseColors([]); | ||
setImgPreview(null); | ||
}} | ||
> | ||
Clear | ||
</Button> | ||
</div> | ||
|
||
{!!imgPreview && colors.length === 0 && ( | ||
<div className="mt-2"> | ||
<p className="text-sm text-foreground/50"> | ||
There are no colors to display. Upload a new image to get | ||
started. | ||
</p> | ||
</div> | ||
)} | ||
{colors.length > 0 && ( | ||
<div className="mt-2 grid grid-cols-4 flex-wrap gap-2"> | ||
{colors.map((color, index) => ( | ||
<motion.button | ||
layoutId={`color-${color.hex}`} | ||
key={`${color.hex}`} | ||
className="flex h-12 w-full items-center justify-center" | ||
style={{ backgroundColor: color.hex, borderRadius: 8 }} | ||
onClick={() => handleUpdateNewBaseColor(color, index)} | ||
> | ||
<motion.span | ||
layoutId={`color-name-${color.hex}`} | ||
className={cn( | ||
"inline font-mono text-xs", | ||
color.lightness < 0.5 | ||
? "text-background" | ||
: "text-foreground", | ||
)} | ||
> | ||
{color.hex} | ||
</motion.span> | ||
</motion.button> | ||
))} | ||
</div> | ||
)} | ||
</motion.div> | ||
</Fragment> | ||
)} | ||
</div> | ||
); | ||
} | ||
|
||
function ImageToolbar() { | ||
return <motion.div>Toolbar</motion.div>; | ||
} | ||
|
||
export { UploadImaagePlugin, ImageToolbar }; |
Oops, something went wrong.