diff --git a/package-lock.json b/package-lock.json index f9934b2..62927b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -2324,6 +2325,226 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.4.tgz", + "integrity": "sha512-Sch9idFJHJTMH9YNpxxESqABcAFweJG4tKv+0zo0m5XBvUSL8FM5xKcJLFLXononpePs8IclyX1KieL5SDUNgA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.3", + "@radix-ui/react-portal": "1.1.3", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-collection": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", + "integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz", + "integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-portal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz", + "integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz", + "integrity": "sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.4.tgz", diff --git a/package.json b/package.json index af676a7..fbb6e1e 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toast": "^1.2.4", "@radix-ui/react-tooltip": "^1.1.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -57,4 +58,4 @@ "tailwindcss": "^3.4.14", "vite": "^5.4.10" } -} \ No newline at end of file +} diff --git a/src/App.jsx b/src/App.jsx index 3802921..336a2be 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,6 +6,7 @@ import Header from './components/Layouts/Header'; import SheetRightPanel from './components/Layouts/SheetRightPanel'; import SheetLeftPanel from './components/Layouts/SheetLeftPanel'; import CanvasCapture from "./components/CanvasCapture"; +import { Toaster } from './components/ui/toaster'; function App() { useEffect(() => { @@ -68,6 +69,7 @@ function App() { return (
+
diff --git a/src/components/Canvas.jsx b/src/components/Canvas.jsx index 357e35f..27bb0af 100644 --- a/src/components/Canvas.jsx +++ b/src/components/Canvas.jsx @@ -76,7 +76,7 @@ const Canvas = () => { }; }, [removeSelected]); - // console.log(activeObject); + console.log(activeObject); return (
diff --git a/src/components/EachComponent/Customization/AddImageIntoShape.jsx b/src/components/EachComponent/Customization/AddImageIntoShape.jsx index 42f3949..d71b9eb 100644 --- a/src/components/EachComponent/Customization/AddImageIntoShape.jsx +++ b/src/components/EachComponent/Customization/AddImageIntoShape.jsx @@ -31,7 +31,7 @@ const AddImageIntoShape = () => { const [rotation, setRotation] = useState("0"); const [format, setFormat] = useState('JPEG'); const fileInputRef = useRef(null); - const [isOpen, setIsOpen] = useState(true); + const [isOpen, setIsOpen] = useState(false); const handleResize = (file, callback) => { Resizer.imageFileResizer( @@ -136,14 +136,35 @@ const AddImageIntoShape = () => { absolutePositioned: true, }); + // Calculate scale factors based on clip object size + let scaleX = activeObject.width / img.width; + let scaleY = activeObject.height / img.height; + if (activeObject?.width < 100) { + scaleX = 0.2; + } + + if (activeObject.height < 100) { + scaleY = 0.2; + } + // Create a fabric image object with scaling and clipPath const fabricImage = new fabric.Image(img, { - scaleX: 0.2, - scaleY: 0.2, + scaleX: scaleX, + scaleY: scaleY, left: activeObject.left, top: activeObject.top, clipPath: activeObject, // Apply clipPath to the image + originX: activeObject.originX, // Match origin point + originY: activeObject.originY // Match origin point }); + + // Adjust position based on the clipPath's transformations + fabricImage.set({ + left: activeObject.left, + top: activeObject.top, + angle: activeObject.angle // Match rotation if any + }); + canvas.add(fabricImage); canvas.setActiveObject(fabricImage); setActiveObject(fabricImage); diff --git a/src/components/EachComponent/Customization/LockObject.jsx b/src/components/EachComponent/Customization/LockObject.jsx new file mode 100644 index 0000000..44dc16b --- /dev/null +++ b/src/components/EachComponent/Customization/LockObject.jsx @@ -0,0 +1,68 @@ +import ActiveObjectContext from '@/components/Context/activeObject/ObjectContext'; +import CanvasContext from '@/components/Context/canvasContext/CanvasContext'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/hooks/use-toast'; +import { useContext, useEffect, useState } from 'react'; +import { Lock, Unlock } from "lucide-react"; +import { Card } from '@/components/ui/card'; + +const LockObject = () => { + const { canvas } = useContext(CanvasContext); + const { activeObject } = useContext(ActiveObjectContext); + const [isLocked, setIsLocked] = useState(false); + const { toast } = useToast(); + + useEffect(() => { + if (activeObject?.lockMovementX === false) { + setIsLocked(false); + } + else { + setIsLocked(true); + } + }, [activeObject]) + + const toggleLock = () => { + if (!canvas || !activeObject) { + toast({ + title: "No object selected", + description: "Please select an object to lock or unlock.", + variant: "destructive", + }) + return; + } + + const newLockState = !activeObject.lockMovementX; + activeObject.set({ + lockMovementX: newLockState, + lockMovementY: newLockState, + lockRotation: newLockState, + lockScalingX: newLockState, + lockScalingY: newLockState, + }); + + setIsLocked(newLockState); + canvas.requestRenderAll(); + + toast({ + title: newLockState ? "Object locked" : "Object unlocked", + description: newLockState ? "The object is now locked in place." : "The object can now be moved and resized.", + }) + } + + return ( + +

{!isLocked ? "Lock" : "Unlock"} Object

+ +
+ ) +} + +export default LockObject \ No newline at end of file diff --git a/src/components/EachComponent/Customization/SelectObjectFromGroup.jsx b/src/components/EachComponent/Customization/SelectObjectFromGroup.jsx index 277cb59..a771496 100644 --- a/src/components/EachComponent/Customization/SelectObjectFromGroup.jsx +++ b/src/components/EachComponent/Customization/SelectObjectFromGroup.jsx @@ -1,55 +1,80 @@ import ActiveObjectContext from '@/components/Context/activeObject/ObjectContext'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { useContext, useEffect, useState } from 'react' +import { useContext, useEffect, useRef, useState } from 'react' import { Card, } from '@/components/ui/card'; import CanvasContext from '@/components/Context/canvasContext/CanvasContext'; import { Separator } from '@/components/ui/separator'; +import { fabric } from 'fabric'; const SelectObjectFromGroup = () => { - const [groupObjects, setGroupObjects] = useState([]); - const [selectedObject, setSelectedObject] = useState(null); - const { setActiveObject } = useContext(ActiveObjectContext); - const { canvas } = useContext(CanvasContext); + const [groupObjects, setGroupObjects] = useState([]) + const [selectedObject, setSelectedObject] = useState(null) + const { setActiveObject } = useContext(ActiveObjectContext) + const { canvas } = useContext(CanvasContext) + const previewCanvasRef = useRef(null) - const activeObject = canvas?.getActiveObject(); + const activeObject = canvas?.getActiveObject() - // Update group objects when the active object changes useEffect(() => { if (activeObject?.type === 'group') { - setGroupObjects(activeObject._objects || []); - setSelectedObject(null); // Reset selection when group changes + setGroupObjects(activeObject._objects || []) + setSelectedObject(null) } else { - setGroupObjects([]); - setSelectedObject(null); + setGroupObjects([]) + setSelectedObject(null) } - }, [activeObject]); + }, [activeObject]) + + useEffect(() => { + if (selectedObject && previewCanvasRef.current) { + const previewCanvas = new fabric.StaticCanvas(previewCanvasRef.current); + + previewCanvas.setDimensions({ + width: 100, + height: 100 + }) + + // Clone the active object to create a true deep copy + selectedObject.clone((clonedObject) => { + // Add the cloned object to the canvas + clonedObject.set({ + left: 50, + top: 50, + originX: 'center', + originY: 'center', + selectable: false, + evented: false, + }) + previewCanvas.add(clonedObject); + previewCanvas.renderAll(); + }); + + return () => { + previewCanvas.dispose() + } + } + }, [selectedObject]) - // Handle object selection from dropdown const handleSelectObject = (value) => { - const selected = groupObjects[parseInt(value)]; - setSelectedObject(selected); - setActiveObject(selected); - }; - - // Don't render if there's no active group - if (!activeObject || activeObject.type !== 'group') { - return null; + const selected = groupObjects[parseInt(value)] + setSelectedObject(selected) + setActiveObject(selected) } - console.log(selectedObject); - console.log(selectedObject?.group); - console.log(selectedObject?.fill); + if (!activeObject || activeObject.type !== 'group') { + return null + } return (
- -

Group Objects

-
+ +

Group Objects

+
-
- {selectedObject && ( -
- - {selectedObject?.fill?.coords && selectedObject?.fill?.colorStops && ( - - - {selectedObject.fill.colorStops.map((stop, index) => ( - - ))} - - - )} - - -
- )} + {selectedObject && ( +
+ +
+ )} +
- +
- ); -}; + ) +} export default SelectObjectFromGroup; \ No newline at end of file diff --git a/src/components/EachComponent/Customization/ShadowCustomization.jsx b/src/components/EachComponent/Customization/ShadowCustomization.jsx index 0c3dafb..4018c31 100644 --- a/src/components/EachComponent/Customization/ShadowCustomization.jsx +++ b/src/components/EachComponent/Customization/ShadowCustomization.jsx @@ -22,7 +22,7 @@ const ShadowCustomization = () => { useEffect(() => { if (activeObject) { - setShadowColor(activeObject?.shadow?.color || "#ffffff"); + setShadowColor(activeObject?.shadow?.color || "#db1d62"); setOffsetX(activeObject?.shadow?.offsetX || 5); setOffsetY(activeObject?.shadow?.offsetY || 5); setBlur(activeObject?.shadow?.blur || 0.5); diff --git a/src/components/EachComponent/CustomizeShape.jsx b/src/components/EachComponent/CustomizeShape.jsx index 3ff8d36..500749e 100644 --- a/src/components/EachComponent/CustomizeShape.jsx +++ b/src/components/EachComponent/CustomizeShape.jsx @@ -16,6 +16,7 @@ import PositionCustomization from './Customization/PositionCustomization'; import CollapsibleComponent from './Customization/CollapsibleComponent'; import ImageCustomization from './Customization/ImageCustomization'; import SelectObjectFromGroup from './Customization/SelectObjectFromGroup'; +import LockObject from './Customization/LockObject'; const CustomizeShape = () => { const { canvas } = useContext(CanvasContext); @@ -29,11 +30,14 @@ const CustomizeShape = () => { return

No active object found

; } - console.log(activeObject?.type); + // console.log(activeObject?.type); return (
+ + + {/* Apply fill and background color */} diff --git a/src/components/EachComponent/Icons/AllIcons.jsx b/src/components/EachComponent/Icons/AllIcons.jsx index b7a8a87..aaaad89 100644 --- a/src/components/EachComponent/Icons/AllIcons.jsx +++ b/src/components/EachComponent/Icons/AllIcons.jsx @@ -6,12 +6,15 @@ import * as lucideIcons from "lucide-react"; import CanvasContext from "@/components/Context/canvasContext/CanvasContext"; import { fabric } from 'fabric'; import ActiveObjectContext from "@/components/Context/activeObject/ObjectContext"; +import { useToast } from "@/hooks/use-toast"; const AllIconsPage = () => { const [search, setSearch] = useState(""); const { canvas } = useContext(CanvasContext); const { setActiveObject } = useContext(ActiveObjectContext); + const { toast } = useToast(); + // Assume icons is already defined as shown previously, and filtered is created based on the search query const icons = Object.entries(lucideIcons)?.filter(([name, Icon]) => !name.includes("Icon") && Icon?.$$typeof @@ -32,7 +35,11 @@ const AllIconsPage = () => { const svgString = new XMLSerializer().serializeToString(e.target); handleAddIcon(svgString); } else { - window.alert('The target is a path element! Select the full icon.'); + toast({ + title: "Invalid Choice", + description: "The target is a path element! Select the full icon.", + variant: "destructive", + }) } }; @@ -66,8 +73,8 @@ const AllIconsPage = () => { const iconGroup = fabric.util.groupSVGElements(objects, options); iconGroup.set({ - left: 100, - top: 100, + left: canvas.width / 2, + top: canvas.height / 2, originX: 'center', originY: 'center', scaleX: 6, diff --git a/src/components/ui/toast.jsx b/src/components/ui/toast.jsx new file mode 100644 index 0000000..2065882 --- /dev/null +++ b/src/components/ui/toast.jsx @@ -0,0 +1,85 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva } from "class-variance-authority"; +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef(({ className, variant, ...props }, ref) => { + return ( + () + ); +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +export { ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction }; diff --git a/src/components/ui/toaster.jsx b/src/components/ui/toaster.jsx new file mode 100644 index 0000000..a29c5db --- /dev/null +++ b/src/components/ui/toaster.jsx @@ -0,0 +1,33 @@ +import { useToast } from "@/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + ( + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + ( +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
) + ); + })} + +
) + ); +} diff --git a/src/hooks/use-toast.js b/src/hooks/use-toast.js new file mode 100644 index 0000000..03accc0 --- /dev/null +++ b/src/hooks/use-toast.js @@ -0,0 +1,155 @@ +"use client"; +// Inspired by react-hot-toast library +import * as React from "react" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST" +} + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString(); +} + +const toastTimeouts = new Map() + +const addToRemoveQueue = (toastId) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state, action) => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t), + }; + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +} + +const listeners = [] + +let memoryState = { toasts: [] } + +function dispatch(action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +function toast({ + ...props +}) { + const id = genId() + + const update = (props) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + }; + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { useToast, toast }