├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── Room.tsx ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── LeftSidebar.tsx ├── Live.tsx ├── Loader.tsx ├── Navbar.tsx ├── RightSidebar.tsx ├── ShapesMenu.tsx ├── comments │ ├── Comments.tsx │ ├── CommentsOverlay.tsx │ ├── NewThread.tsx │ ├── NewThreadCursor.tsx │ ├── PinnedComposer.tsx │ └── PinnedThread.tsx ├── cursor │ ├── Cursor.tsx │ ├── CursorChat.tsx │ └── LiveCursors.tsx ├── reaction │ ├── FlyingReaction.module.css │ ├── FlyingReaction.tsx │ └── ReactionButton.tsx ├── settings │ ├── Color.tsx │ ├── Dimensions.tsx │ ├── Export.tsx │ └── Text.tsx ├── ui │ ├── button.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ └── select.tsx └── users │ ├── ActiveUsers.tsx │ ├── Avatar.module.css │ ├── Avatar.tsx │ └── index.module.css ├── constants └── index.ts ├── hooks └── useInterval.ts ├── lib ├── canvas.ts ├── key-events.ts ├── shapes.ts ├── useMaxZIndex.ts └── utils.ts ├── liveblocks.config.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── assets │ ├── CursorSVG.tsx │ ├── align-bottom.svg │ ├── align-horizontal-center.svg │ ├── align-left.svg │ ├── align-right.svg │ ├── align-top.svg │ ├── align-vertical-center.svg │ ├── back.svg │ ├── circle.svg │ ├── comments.svg │ ├── delete.svg │ ├── favicon.ico │ ├── freeform.svg │ ├── front.svg │ ├── group.svg │ ├── hash.svg │ ├── image.svg │ ├── line.svg │ ├── loader.gif │ ├── logo.svg │ ├── polygon.svg │ ├── rectangle.svg │ ├── reset.svg │ ├── select.svg │ ├── text.svg │ ├── triangle.svg │ └── ungroup.svg ├── next.svg └── vercel.svg ├── tailwind.config.ts ├── tsconfig.json └── types ├── declaration.d.ts └── type.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Real-time Figma Clone 🎨 2 | 3 | Welcome to the real-time Figma Clone project! This project aims to replicate the functionality of Figma with real-time collaboration features using a stack of popular technologies including Next.js, TypeScript, Liveblocks, Fabric.js, Shadcn, and Tailwind CSS. 🚀 4 | 5 |
6 | typescript 7 | nextdotjs 8 | tailwindcss 9 |
10 | 11 | ## Features ✨ 12 | 13 | - **Multi Cursors**: See where other users are editing in real-time. 14 | - **Cursor Chat**: Communicate with collaborators through cursor chat. 15 | - **Reactions**: React to changes and collaborate with expressive reactions. 16 | - **Active Users**: See who else is currently editing the canvas. 17 | - **Comment Bubbles**: Leave comments and annotations directly on the canvas. 18 | - **Creating Different Shapes**: Draw and create various shapes on the canvas. 19 | - **Uploading Images**: Easily upload images to the canvas for reference or design elements. 20 | - **Customization**: Customize elements and shapes according to your design needs. 21 | - **Freeform Drawing**: Sketch and draw freely on the canvas. 22 | - **Undo/Redo**: Easily undo or redo actions to fine-tune your designs. 23 | - **Keyboard Actions**: Perform actions quickly using keyboard shortcuts. 24 | - **History**: View the history of changes made on the canvas. 25 | - **Deleting**: Remove unwanted elements from the canvas. 26 | - **Scaling**: Scale elements for precise adjustments. 27 | - **Moving**: Easily move elements around the canvas. 28 | - **Clearing**: Clear the canvas to start fresh. 29 | - **Exporting Canvas**: Export your designs for further use or sharing. 30 | 31 | ## Hotkeys 🎹 32 | 33 | - **/**: Open live cursor chat 34 | - **e**: Open reactions 35 | - **esc**: Close 36 | - **ctrl+z**: Undo 37 | - **ctrl+y**: Redo 38 | - **ctrl+c**: Copy 39 | - **ctrl+v**: Paste 40 | 41 | 42 | ## Quick Start 🚀 43 | 44 | To get started with the real-time Figma Clone project, follow these steps: 45 | 46 | 1. Clone the repository to your local machine: 47 | 48 | ```bash 49 | git clone https://github.com/your-username/figma-clone.git 50 | ``` 51 | 52 | 2. Navigate to the project directory: 53 | 54 | ```bash 55 | cd figma-clone 56 | ``` 57 | 58 | 3. Install dependencies using npm: 59 | 60 | ```bash 61 | npm install 62 | ``` 63 | 64 | 4. Set up environment variables: 65 | 66 | ```bash 67 | NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=your-liveblocks-public-key 68 | ``` 69 | 70 | 5. Run the development server: 71 | 72 | ```bash 73 | npm run dev 74 | ``` 75 | 76 | 6. Access the application by visiting [http://localhost:3000](http://localhost:3000) in your web browser. 77 | 78 | **Note:** Ensure you have obtained a Liveblocks public key from the Liveblocks dashboard and replace `your-liveblocks-public-key` with your actual key. 79 | 80 | ## Project Structure 📂 81 | 82 | - `pages/`: Contains Next.js pages. 83 | - `components/`: Reusable React components. 84 | - `styles/`: Styling files using Tailwind CSS. 85 | - `public/`: Static assets. 86 | - `utils/`: Utility functions and configuration files. 87 | - `lib/`: Library files and integrations (e.g., Liveblocks). 88 | 89 | ## Credits 🙌 90 | 91 | A heartfelt thank you to [Adrian Hajdin](https://github.com/adrianhajdin) for inspiring and guiding us in the creation of this fantastic and fun project! 92 | 93 | ## Contributions 🌟 94 | 95 | Contributions to the real-time Figma Clone project are welcome! If you find any issues or have suggestions for improvements, please open an issue or submit a pull request on GitHub. 96 | 97 | Thank you for using and contributing to the project! Happy designing! 🎉 -------------------------------------------------------------------------------- /app/Room.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | import { RoomProvider } from "../liveblocks.config"; 5 | import { ClientSideSuspense } from "@liveblocks/react"; 6 | import { LiveMap } from "@liveblocks/client"; 7 | import Loader from "@/components/Loader"; 8 | 9 | export function Room({ children }: { children: ReactNode }) { 10 | return ( 11 | 20 | }> 21 | {() => children} 22 | 23 | 24 | ); 25 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elmurodvokhidov/FIGMA-CLONE/3bd1f0bc0d8bd543c474f2729267274163fb18cd/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import "@liveblocks/react-comments/styles.css"; 6 | 7 | * { 8 | font-family: 9 | work sans, 10 | sans-serif; 11 | } 12 | 13 | body { 14 | overflow: hidden; 15 | } 16 | 17 | @layer utilities { 18 | .no-ring { 19 | @apply outline-none ring-0 ring-offset-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-offset-0 !important; 20 | } 21 | 22 | .input-ring { 23 | @apply h-8 rounded-none border-none bg-transparent outline-none ring-offset-0 focus:ring-1 focus:ring-primary-green focus:ring-offset-0 focus-visible:ring-offset-0 !important; 24 | } 25 | 26 | .right-menu-content { 27 | @apply flex w-80 flex-col gap-y-1 border-none bg-primary-black py-4 text-white !important; 28 | } 29 | 30 | .right-menu-item { 31 | @apply flex justify-between px-3 py-2 hover:bg-primary-grey-200 !important; 32 | } 33 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Work_Sans } from "next/font/google"; 3 | import "./globals.css"; 4 | import { Room } from "./Room"; 5 | 6 | const worksans = Work_Sans({ 7 | subsets: ["latin"], 8 | variable: "--font-work-sans", 9 | weight: ["400", "600", "700"] 10 | }); 11 | 12 | export const metadata: Metadata = { 13 | title: "Figma Clone", 14 | description: "A minimalist Figma clone using Fabris.js and Liveblocks for real-time collaboration", 15 | }; 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode; 21 | }>) { 22 | return ( 23 | 24 | 25 | 26 | {children} 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { fabric } from "fabric"; 4 | import LeftSidebar from "@/components/LeftSidebar"; 5 | import Live from "@/components/Live"; 6 | import Navbar from "@/components/Navbar"; 7 | import RightSidebar from "@/components/RightSidebar"; 8 | import { useEffect, useRef, useState } from "react"; 9 | import { 10 | handleCanvasMouseDown, 11 | handleCanvasMouseUp, 12 | handleCanvasObjectModified, 13 | handleCanvasObjectScaling, 14 | handleCanvasSelectionCreated, 15 | handleCanvaseMouseMove, 16 | handleResize, 17 | initializeFabric, 18 | renderCanvas 19 | } from "@/lib/canvas"; 20 | import { ActiveElement, Attributes } from "@/types/type"; 21 | import { 22 | useMutation, 23 | useRedo, 24 | useStorage, 25 | useUndo 26 | } from "@/liveblocks.config"; 27 | import { defaultNavElement } from "@/constants"; 28 | import { handleDelete, handleKeyDown } from "@/lib/key-events"; 29 | import { handleImageUpload } from "@/lib/shapes"; 30 | 31 | export default function Page() { 32 | const undo = useUndo(); 33 | const redo = useRedo(); 34 | 35 | const canvasRef = useRef(null); 36 | const fabricRef = useRef(null); 37 | const isDrawing = useRef(false); 38 | const shapeRef = useRef(null); 39 | const selectedShapeRef = useRef(null); 40 | const activeObjectRef = useRef(null); 41 | const imageInputRef = useRef(null); 42 | const isEditingRef = useRef(false); 43 | 44 | const [elementAttributes, setElementAttributes] = useState({ 45 | width: "", 46 | height: "", 47 | fontSize: "", 48 | fontFamily: "", 49 | fontWeight: "", 50 | fill: "#aabbcc", 51 | stroke: "#aabbcc", 52 | }); 53 | 54 | const canvasObjects = useStorage((root) => root.canvasObjects); 55 | const syncShapeInStorage = useMutation(({ storage }, object) => { 56 | if (!object) return; 57 | 58 | const { objectId } = object; 59 | 60 | const shapeData = object.toJSON(); 61 | shapeData.objectId = objectId; 62 | 63 | const canvasObjects = storage.get("canvasObjects"); 64 | 65 | canvasObjects.set(objectId, shapeData); 66 | }, []); 67 | 68 | const [activeElement, setActiveElement] = useState({ 69 | name: "", 70 | value: "", 71 | icon: "", 72 | }); 73 | 74 | const deleteAllShapes = useMutation(({ storage }) => { 75 | const canvasObjects = storage.get("canvasObjects"); 76 | if (!canvasObjects || canvasObjects.size === 0) return true; 77 | 78 | for (const [key, value] of canvasObjects.entries()) { 79 | canvasObjects.delete(key); 80 | } 81 | 82 | return canvasObjects.size === 0; 83 | }, []); 84 | 85 | const deleteShapeFromStorage = useMutation(({ storage }, objectId) => { 86 | const canvasObjects = storage.get("canvasObjects"); 87 | 88 | canvasObjects.delete(objectId); 89 | }, []); 90 | 91 | const handleActiveElement = (elem: ActiveElement) => { 92 | setActiveElement(elem); 93 | 94 | switch (elem?.value) { 95 | case "reset": 96 | deleteAllShapes(); 97 | fabricRef.current?.clear(); 98 | setActiveElement(defaultNavElement); 99 | break; 100 | 101 | case "delete": 102 | handleDelete(fabricRef.current as any, deleteShapeFromStorage); 103 | setActiveElement(defaultNavElement); 104 | break; 105 | 106 | case "image": 107 | imageInputRef.current?.click(); 108 | isDrawing.current = false; 109 | 110 | if (fabricRef.current) { 111 | fabricRef.current.isDrawingMode = false; 112 | } 113 | break; 114 | 115 | default: 116 | break; 117 | } 118 | 119 | selectedShapeRef.current = elem?.value as string; 120 | } 121 | 122 | useEffect(() => { 123 | const canvas = initializeFabric({ 124 | canvasRef, 125 | fabricRef, 126 | }); 127 | 128 | canvas.on("mouse:down", (options: any) => { 129 | handleCanvasMouseDown({ 130 | options, 131 | canvas, 132 | selectedShapeRef, 133 | isDrawing, 134 | shapeRef, 135 | }); 136 | }); 137 | 138 | canvas.on("mouse:move", (options: any) => { 139 | handleCanvaseMouseMove({ 140 | options, 141 | canvas, 142 | selectedShapeRef, 143 | isDrawing, 144 | shapeRef, 145 | syncShapeInStorage, 146 | }); 147 | }); 148 | 149 | canvas.on("mouse:up", () => { 150 | handleCanvasMouseUp({ 151 | canvas, 152 | selectedShapeRef, 153 | isDrawing, 154 | shapeRef, 155 | syncShapeInStorage, 156 | setActiveElement, 157 | activeObjectRef, 158 | }); 159 | }); 160 | 161 | canvas.on("object:modified", (options: any) => { 162 | handleCanvasObjectModified({ 163 | options, 164 | syncShapeInStorage, 165 | }); 166 | }); 167 | 168 | canvas.on("selection:created", (options: any) => { 169 | handleCanvasSelectionCreated({ 170 | options, 171 | isEditingRef, 172 | setElementAttributes, 173 | }) 174 | }); 175 | 176 | canvas.on("object:scaling", (options: any) => { 177 | handleCanvasObjectScaling({ 178 | options, 179 | setElementAttributes 180 | }) 181 | }); 182 | 183 | window.addEventListener("resize", () => { 184 | handleResize({ fabricRef }) 185 | }); 186 | 187 | window.addEventListener("keydown", (e: any) => { 188 | handleKeyDown({ 189 | e, 190 | canvas: fabricRef.current, 191 | undo, 192 | redo, 193 | syncShapeInStorage, 194 | deleteShapeFromStorage, 195 | }) 196 | }); 197 | 198 | return () => { 199 | canvas.dispose(); 200 | }; 201 | }, []); 202 | 203 | useEffect(() => { 204 | renderCanvas({ 205 | fabricRef, 206 | canvasObjects, 207 | activeObjectRef, 208 | }); 209 | }, [canvasObjects]); 210 | 211 | return ( 212 |
213 | { 218 | e.stopPropagation(); 219 | handleImageUpload({ 220 | file: e.target.files[0], 221 | canvas: fabricRef as any, 222 | shapeRef, 223 | syncShapeInStorage, 224 | }) 225 | }} 226 | /> 227 | 228 |
229 | 230 | 231 | 239 |
240 |
241 | ); 242 | } -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/LeftSidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMemo } from "react"; 4 | import Image from "next/image"; 5 | 6 | import { getShapeInfo } from "@/lib/utils"; 7 | 8 | const LeftSidebar = ({ allShapes }: { allShapes: Array }) => { 9 | // memoize the result of this function so that it doesn't change on every render but only when there are new shapes 10 | const memoizedShapes = useMemo( 11 | () => ( 12 |
13 |

Layers

14 |
15 | {allShapes?.map((shape: any) => { 16 | const info = getShapeInfo(shape[1]?.type); 17 | 18 | return ( 19 |
23 | Layer 30 |

{info.name}

31 |
32 | ); 33 | })} 34 |
35 |
36 | ), 37 | [allShapes?.length] 38 | ); 39 | 40 | return memoizedShapes; 41 | }; 42 | 43 | export default LeftSidebar; 44 | -------------------------------------------------------------------------------- /components/Live.tsx: -------------------------------------------------------------------------------- 1 | import { useBroadcastEvent, useEventListener, useMyPresence, useOthers } from "@/liveblocks.config" 2 | import LiveCursors from "./cursor/LiveCursors" 3 | import { useCallback, useEffect, useState } from "react"; 4 | import CursorChat from "./cursor/CursorChat"; 5 | import { CursorMode, CursorState, Reaction, ReactionEvent } from "@/types/type"; 6 | import ReactionSelector from "./reaction/ReactionButton"; 7 | import FlyingReaction from "./reaction/FlyingReaction"; 8 | import useInterval from "@/hooks/useInterval"; 9 | import { Comments } from "./comments/Comments"; 10 | 11 | type Props = { 12 | canvasRef: React.MutableRefObject 13 | } 14 | 15 | const Live = ({ canvasRef }: Props) => { 16 | const others = useOthers(); 17 | const [{ cursor }, updateMyPresence] = useMyPresence() as any; 18 | 19 | const [cursorState, setCursorState] = useState({ 20 | mode: CursorMode.Hidden, 21 | }); 22 | 23 | const [reactions, setReactions] = useState([]); 24 | 25 | const broadcast = useBroadcastEvent(); 26 | 27 | useInterval(() => { 28 | setReactions((reactions) => reactions.filter((reaction) => reaction.timestamp > Date.now() - 500)); 29 | }, 500); 30 | 31 | useInterval(() => { 32 | if (cursorState.mode === CursorMode.Reaction && cursorState.isPressed && cursor) { 33 | setReactions((reactions) => reactions.concat([ 34 | { 35 | point: { x: cursor.x, y: cursor.y }, 36 | value: cursorState.reaction, 37 | timestamp: Date.now(), 38 | } 39 | ])); 40 | broadcast({ 41 | x: cursor.x, 42 | y: cursor.y, 43 | value: cursorState.reaction, 44 | }); 45 | } 46 | }, 100); 47 | 48 | useEventListener((eventData) => { 49 | const event = eventData.event as ReactionEvent; 50 | setReactions((reactions) => reactions.concat([ 51 | { 52 | point: { x: event.x, y: event.y }, 53 | value: event.value, 54 | timestamp: Date.now(), 55 | } 56 | ])); 57 | }) 58 | 59 | const handlePointerMove = useCallback((event: React.PointerEvent) => { 60 | event.preventDefault(); 61 | 62 | if (cursor === null || cursorState.mode !== CursorMode.ReactionSelector) { 63 | const x = event.clientX - event.currentTarget.getBoundingClientRect().x; 64 | const y = event.clientY - event.currentTarget.getBoundingClientRect().y; 65 | 66 | updateMyPresence({ cursor: { x, y } }); 67 | } 68 | }, []); 69 | 70 | const handlePointerLeave = useCallback((event: React.PointerEvent) => { 71 | setCursorState({ mode: CursorMode.Hidden }); 72 | 73 | updateMyPresence({ cursor: null, message: null }); 74 | }, []); 75 | 76 | const handlePointerDown = useCallback((event: React.PointerEvent) => { 77 | const x = event.clientX - event.currentTarget.getBoundingClientRect().x; 78 | const y = event.clientY - event.currentTarget.getBoundingClientRect().y; 79 | 80 | updateMyPresence({ cursor: { x, y } }); 81 | 82 | setCursorState((state: CursorState) => cursorState.mode === CursorMode.Reaction ? { ...state, isPressed: true } : state); 83 | }, [cursorState.mode, setCursorState]); 84 | 85 | const handlePointerUp = useCallback((event: React.PointerEvent) => { 86 | setCursorState((state: CursorState) => cursorState.mode === CursorMode.Reaction ? { ...state, isPressed: true } : state); 87 | }, [cursorState.mode, setCursorState]) 88 | 89 | useEffect(() => { 90 | const onKeyUp = (e: KeyboardEvent) => { 91 | if (e.key === "/") { 92 | setCursorState({ 93 | mode: CursorMode.Chat, 94 | previousMessage: null, 95 | message: "" 96 | }); 97 | } else if (e.key === "Escape") { 98 | updateMyPresence({ message: "" }); 99 | setCursorState({ mode: CursorMode.Hidden }); 100 | } else if (e.key === "e") { 101 | setCursorState({ mode: CursorMode.ReactionSelector }); 102 | } 103 | }; 104 | 105 | const onKeyDown = (e: KeyboardEvent) => { 106 | if (e.key === "/") { 107 | e.preventDefault(); 108 | } 109 | }; 110 | 111 | window.addEventListener("keyup", onKeyUp); 112 | window.addEventListener("keydown", onKeyDown); 113 | 114 | return () => { 115 | window.removeEventListener("keyup", onKeyUp); 116 | window.removeEventListener("keydown", onKeyDown); 117 | } 118 | }, [updateMyPresence]); 119 | 120 | const setReaction = useCallback((reaction: string) => { 121 | setCursorState({ mode: CursorMode.Reaction, reaction, isPressed: false }); 122 | }, []); 123 | 124 | return ( 125 |
133 | 134 | 135 | {reactions.map((reaction) => ( 136 | 143 | ))} 144 | 145 | {cursor && ( 146 | 152 | )} 153 | 154 | {cursorState.mode === CursorMode.ReactionSelector && ( 155 | 158 | )} 159 | 160 | 161 | 162 | 163 |
164 | ) 165 | } 166 | 167 | export default Live -------------------------------------------------------------------------------- /components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | const Loader = () => ( 4 |
5 | loader 12 |

Loading...

13 |
14 | ); 15 | 16 | export default Loader; -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { memo } from "react"; 5 | 6 | import { navElements } from "@/constants"; 7 | import { ActiveElement, NavbarProps } from "@/types/type"; 8 | 9 | import { Button } from "./ui/button"; 10 | import ShapesMenu from "./ShapesMenu"; 11 | import ActiveUsers from "./users/ActiveUsers"; 12 | import { NewThread } from "./comments/NewThread"; 13 | 14 | const Navbar = ({ activeElement, imageInputRef, handleImageUpload, handleActiveElement }: NavbarProps) => { 15 | const isActive = (value: string | Array) => 16 | (activeElement && activeElement.value === value) || 17 | (Array.isArray(value) && value.some((val) => val?.value === activeElement?.value)); 18 | 19 | return ( 20 | 72 | ); 73 | }; 74 | 75 | export default memo(Navbar, (prevProps, nextProps) => prevProps.activeElement === nextProps.activeElement); 76 | -------------------------------------------------------------------------------- /components/RightSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import Dimensions from './settings/Dimensions' 3 | import Text from './settings/Text' 4 | import Color from './settings/Color' 5 | import Export from './settings/Export' 6 | import { RightSidebarProps } from '@/types/type' 7 | import { modifyShape } from '@/lib/shapes' 8 | 9 | const RightSidebar = ({ elementAttributes, setElementAttributes, fabricRef, activeObjectRef, isEditingRef, syncShapeInStorage }: RightSidebarProps) => { 10 | const colorInputRef = useRef(null); 11 | const strokeInputRef = useRef(null); 12 | 13 | const handleInputChange = (property: string, value: string) => { 14 | if (!isEditingRef.current) isEditingRef.current = true; 15 | 16 | setElementAttributes(prev => ({ 17 | ...prev, [property]: value 18 | })); 19 | 20 | modifyShape({ 21 | canvas: fabricRef.current as fabric.Canvas, 22 | property, 23 | value, 24 | activeObjectRef, 25 | syncShapeInStorage 26 | }) 27 | } 28 | 29 | return ( 30 |
31 |

Design

32 | Make changes to canvas as you like 33 | 34 | 40 | 46 | 53 | 60 | 61 |
62 | ) 63 | } 64 | 65 | export default RightSidebar -------------------------------------------------------------------------------- /components/ShapesMenu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | 5 | import { ShapesMenuProps } from "@/types/type"; 6 | 7 | import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "./ui/dropdown-menu"; 8 | import { Button } from "./ui/button"; 9 | 10 | const ShapesMenu = ({ 11 | item, 12 | activeElement, 13 | handleActiveElement, 14 | handleImageUpload, 15 | imageInputRef, 16 | }: ShapesMenuProps) => { 17 | const isDropdownElem = item.value.some((elem) => elem?.value === activeElement.value); 18 | 19 | return ( 20 | <> 21 | 22 | 23 | 31 | 32 | 33 | 34 | {item.value.map((elem) => ( 35 | 61 | ))} 62 | 63 | 64 | 65 | 72 | 73 | ); 74 | }; 75 | 76 | export default ShapesMenu; 77 | -------------------------------------------------------------------------------- /components/comments/Comments.tsx: -------------------------------------------------------------------------------- 1 | import { ClientSideSuspense } from "@liveblocks/react"; 2 | import { CommentsOverlay } from "./CommentsOverlay"; 3 | 4 | export function Comments() { 5 | return ( 6 | 7 | {() => } 8 | 9 | ) 10 | } -------------------------------------------------------------------------------- /components/comments/CommentsOverlay.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useRef } from "react"; 4 | import { ThreadData } from "@liveblocks/client"; 5 | import { ThreadMetadata, useEditThreadMetadata, useThreads, useUser } from "@/liveblocks.config"; 6 | import { useMaxZIndex } from "@/lib/useMaxZIndex"; 7 | import { PinnedThread } from "./PinnedThread"; 8 | 9 | type OverlayThreadProps = { 10 | thread: ThreadData; 11 | maxZIndex: number; 12 | }; 13 | 14 | export const CommentsOverlay = () => { 15 | const { threads } = useThreads(); 16 | const maxZIndex = useMaxZIndex(); 17 | 18 | return ( 19 |
20 | {/* {threads 21 | .filter((thread) => !thread.metadata.resolved) 22 | .map((thread) => ( 23 | 24 | ))} */} 25 |
26 | ); 27 | }; 28 | 29 | const OverlayThread = ({ thread, maxZIndex }: OverlayThreadProps) => { 30 | const editThreadMetadata = useEditThreadMetadata(); 31 | const { isLoading } = useUser(thread.comments[0].userId); 32 | const threadRef = useRef(null); 33 | 34 | const handleIncreaseZIndex = useCallback(() => { 35 | if (maxZIndex === thread.metadata.zIndex) { 36 | return; 37 | } 38 | 39 | editThreadMetadata({ 40 | threadId: thread.id, 41 | metadata: { 42 | zIndex: maxZIndex + 1, 43 | }, 44 | }); 45 | }, [thread, editThreadMetadata, maxZIndex]); 46 | 47 | if (isLoading) { 48 | return null; 49 | } 50 | 51 | return ( 52 |
60 | 61 |
62 | ); 63 | }; -------------------------------------------------------------------------------- /components/comments/NewThread.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | FormEvent, 5 | ReactNode, 6 | useCallback, 7 | useEffect, 8 | useRef, 9 | useState, 10 | } from "react"; 11 | import { Slot } from "@radix-ui/react-slot"; 12 | import * as Portal from "@radix-ui/react-portal"; 13 | import { ComposerSubmitComment } from "@liveblocks/react-comments/primitives"; 14 | 15 | import { useCreateThread } from "@/liveblocks.config"; 16 | import { useMaxZIndex } from "@/lib/useMaxZIndex"; 17 | 18 | import PinnedComposer from "./PinnedComposer"; 19 | import NewThreadCursor from "./NewThreadCursor"; 20 | 21 | type ComposerCoords = null | { x: number; y: number }; 22 | 23 | type Props = { 24 | children: ReactNode; 25 | }; 26 | 27 | export const NewThread = ({ children }: Props) => { 28 | // set state to track if we're placing a new comment or not 29 | const [creatingCommentState, setCreatingCommentState] = useState< 30 | "placing" | "placed" | "complete" 31 | >("complete"); 32 | 33 | /** 34 | * We're using the useCreateThread hook to create a new thread. 35 | * 36 | * useCreateThread: https://liveblocks.io/docs/api-reference/liveblocks-react#useCreateThread 37 | */ 38 | const createThread = useCreateThread(); 39 | 40 | // get the max z-index of a thread 41 | const maxZIndex = useMaxZIndex(); 42 | 43 | // set state to track the coordinates of the composer (liveblocks comment editor) 44 | const [composerCoords, setComposerCoords] = useState(null); 45 | 46 | // set state to track the last pointer event 47 | const lastPointerEvent = useRef(); 48 | 49 | // set state to track if user is allowed to use the composer 50 | const [allowUseComposer, setAllowUseComposer] = useState(false); 51 | const allowComposerRef = useRef(allowUseComposer); 52 | allowComposerRef.current = allowUseComposer; 53 | 54 | useEffect(() => { 55 | // If composer is already placed, don't do anything 56 | if (creatingCommentState === "complete") { 57 | return; 58 | } 59 | 60 | // Place a composer on the screen 61 | const newComment = (e: MouseEvent) => { 62 | e.preventDefault(); 63 | 64 | // If already placed, click outside to close composer 65 | if (creatingCommentState === "placed") { 66 | // check if the click event is on/inside the composer 67 | const isClickOnComposer = ((e as any)._savedComposedPath = e 68 | .composedPath() 69 | .some((el: any) => { 70 | return el.classList?.contains("lb-composer-editor-actions"); 71 | })); 72 | 73 | // if click is inisde/on composer, don't do anything 74 | if (isClickOnComposer) { 75 | return; 76 | } 77 | 78 | // if click is outside composer, close composer 79 | if (!isClickOnComposer) { 80 | setCreatingCommentState("complete"); 81 | return; 82 | } 83 | } 84 | 85 | // First click sets composer down 86 | setCreatingCommentState("placed"); 87 | setComposerCoords({ 88 | x: e.clientX, 89 | y: e.clientY, 90 | }); 91 | }; 92 | 93 | document.documentElement.addEventListener("click", newComment); 94 | 95 | return () => { 96 | document.documentElement.removeEventListener("click", newComment); 97 | }; 98 | }, [creatingCommentState]); 99 | 100 | useEffect(() => { 101 | // If dragging composer, update position 102 | const handlePointerMove = (e: PointerEvent) => { 103 | // Prevents issue with composedPath getting removed 104 | (e as any)._savedComposedPath = e.composedPath(); 105 | lastPointerEvent.current = e; 106 | }; 107 | 108 | document.documentElement.addEventListener("pointermove", handlePointerMove); 109 | 110 | return () => { 111 | document.documentElement.removeEventListener( 112 | "pointermove", 113 | handlePointerMove 114 | ); 115 | }; 116 | }, []); 117 | 118 | // Set pointer event from last click on body for use later 119 | useEffect(() => { 120 | if (creatingCommentState !== "placing") { 121 | return; 122 | } 123 | 124 | const handlePointerDown = (e: PointerEvent) => { 125 | // if composer is already placed, don't do anything 126 | if (allowComposerRef.current) { 127 | return; 128 | } 129 | 130 | // Prevents issue with composedPath getting removed 131 | (e as any)._savedComposedPath = e.composedPath(); 132 | lastPointerEvent.current = e; 133 | setAllowUseComposer(true); 134 | }; 135 | 136 | // Right click to cancel placing 137 | const handleContextMenu = (e: Event) => { 138 | if (creatingCommentState === "placing") { 139 | e.preventDefault(); 140 | setCreatingCommentState("complete"); 141 | } 142 | }; 143 | 144 | document.documentElement.addEventListener("pointerdown", handlePointerDown); 145 | document.documentElement.addEventListener("contextmenu", handleContextMenu); 146 | 147 | return () => { 148 | document.documentElement.removeEventListener( 149 | "pointerdown", 150 | handlePointerDown 151 | ); 152 | document.documentElement.removeEventListener( 153 | "contextmenu", 154 | handleContextMenu 155 | ); 156 | }; 157 | }, [creatingCommentState]); 158 | 159 | // On composer submit, create thread and reset state 160 | const handleComposerSubmit = useCallback( 161 | ({ body }: ComposerSubmitComment, event: FormEvent) => { 162 | event.preventDefault(); 163 | event.stopPropagation(); 164 | 165 | // Get your canvas element 166 | const overlayPanel = document.querySelector("#canvas"); 167 | 168 | // if there's no composer coords or last pointer event, meaning the user hasn't clicked yet, don't do anything 169 | if (!composerCoords || !lastPointerEvent.current || !overlayPanel) { 170 | return; 171 | } 172 | 173 | // Set coords relative to the top left of your canvas 174 | const { top, left } = overlayPanel.getBoundingClientRect(); 175 | const x = composerCoords.x - left; 176 | const y = composerCoords.y - top; 177 | 178 | // create a new thread with the composer coords and cursor selectors 179 | createThread({ 180 | body, 181 | metadata: { 182 | x, 183 | y, 184 | resolved: false, 185 | zIndex: maxZIndex + 1, 186 | }, 187 | }); 188 | 189 | setComposerCoords(null); 190 | setCreatingCommentState("complete"); 191 | setAllowUseComposer(false); 192 | }, 193 | [createThread, composerCoords, maxZIndex] 194 | ); 195 | 196 | return ( 197 | <> 198 | {/** 199 | * Slot is used to wrap the children of the NewThread component 200 | * to allow us to add a click event listener to the children 201 | * 202 | * Slot: https://www.radix-ui.com/primitives/docs/utilities/slot 203 | * 204 | * Disclaimer: We don't have to download this package specifically, 205 | * it's already included when we install Shadcn 206 | */} 207 | 209 | setCreatingCommentState( 210 | creatingCommentState !== "complete" ? "complete" : "placing" 211 | ) 212 | } 213 | style={{ opacity: creatingCommentState !== "complete" ? 0.7 : 1 }} 214 | > 215 | {children} 216 | 217 | 218 | {/* if composer coords exist and we're placing a comment, render the composer */} 219 | {composerCoords && creatingCommentState === "placed" ? ( 220 | /** 221 | * Portal.Root is used to render the composer outside of the NewThread component to avoid z-index issuess 222 | * 223 | * Portal.Root: https://www.radix-ui.com/primitives/docs/utilities/portal 224 | */ 225 | 233 | 234 | 235 | ) : null} 236 | 237 | {/* Show the customizing cursor when placing a comment. The one with comment shape */} 238 | 239 | 240 | ); 241 | }; -------------------------------------------------------------------------------- /components/comments/NewThreadCursor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import * as Portal from "@radix-ui/react-portal"; 5 | 6 | const DEFAULT_CURSOR_POSITION = -10000; 7 | 8 | // display a custom cursor when placing a new thread 9 | const NewThreadCursor = ({ display }: { display: boolean }) => { 10 | const [coords, setCoords] = useState({ 11 | x: DEFAULT_CURSOR_POSITION, 12 | y: DEFAULT_CURSOR_POSITION, 13 | }); 14 | 15 | useEffect(() => { 16 | const updatePosition = (e: MouseEvent) => { 17 | // get canvas element 18 | const canvas = document.getElementById("canvas"); 19 | 20 | if (canvas) { 21 | /** 22 | * getBoundingClientRect returns the size of an element and its position relative to the viewport 23 | * 24 | * getBoundingClientRect: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect 25 | */ 26 | const canvasRect = canvas.getBoundingClientRect(); 27 | 28 | // check if the mouse is outside the canvas 29 | // if so, hide the custom comment cursor 30 | if ( 31 | e.clientX < canvasRect.left || 32 | e.clientX > canvasRect.right || 33 | e.clientY < canvasRect.top || 34 | e.clientY > canvasRect.bottom 35 | ) { 36 | setCoords({ 37 | x: DEFAULT_CURSOR_POSITION, 38 | y: DEFAULT_CURSOR_POSITION, 39 | }); 40 | return; 41 | } 42 | } 43 | 44 | // set the coordinates of the cursor 45 | setCoords({ 46 | x: e.clientX, 47 | y: e.clientY, 48 | }); 49 | }; 50 | 51 | document.addEventListener("mousemove", updatePosition, false); 52 | document.addEventListener("mouseenter", updatePosition, false); 53 | 54 | return () => { 55 | document.removeEventListener("mousemove", updatePosition); 56 | document.removeEventListener("mouseenter", updatePosition); 57 | }; 58 | }, []); 59 | 60 | useEffect(() => { 61 | if (display) { 62 | document.documentElement.classList.add("hide-cursor"); 63 | } else { 64 | document.documentElement.classList.remove("hide-cursor"); 65 | } 66 | }, [display]); 67 | 68 | if (!display) { 69 | return null; 70 | } 71 | 72 | return ( 73 | // Portal.Root is used to render a component outside of its parent component 74 | 75 |
81 | 82 | ); 83 | }; 84 | 85 | export default NewThreadCursor; -------------------------------------------------------------------------------- /components/comments/PinnedComposer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { Composer, ComposerProps } from "@liveblocks/react-comments"; 5 | 6 | type Props = { 7 | onComposerSubmit: ComposerProps["onComposerSubmit"]; 8 | }; 9 | 10 | const PinnedComposer = ({ onComposerSubmit, ...props }: Props) => { 11 | return ( 12 |
13 |
14 | someone 21 |
22 |
23 | {/** 24 | * We're using the Composer component to create a new comment. 25 | * Liveblocks provides a Composer component that allows to 26 | * create/edit/delete comments. 27 | * 28 | * Composer: https://liveblocks.io/docs/api-reference/liveblocks-react-comments#Composer 29 | */} 30 | { 34 | e.stopPropagation() 35 | }} 36 | /> 37 |
38 |
39 | ); 40 | }; 41 | 42 | export default PinnedComposer; -------------------------------------------------------------------------------- /components/comments/PinnedThread.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useMemo, useState } from "react"; 5 | import { ThreadData } from "@liveblocks/client"; 6 | import { Thread } from "@liveblocks/react-comments"; 7 | 8 | import { ThreadMetadata } from "@/liveblocks.config"; 9 | 10 | type Props = { 11 | thread: ThreadData; 12 | onFocus: (threadId: string) => void; 13 | }; 14 | 15 | export const PinnedThread = ({ thread, onFocus, ...props }: Props) => { 16 | // Open pinned threads that have just been created 17 | const startMinimized = useMemo( 18 | () => Number(new Date()) - Number(new Date(thread.createdAt)) > 100, 19 | [thread] 20 | ); 21 | 22 | const [minimized, setMinimized] = useState(startMinimized); 23 | 24 | /** 25 | * memoize the result of this function so that it doesn't change on every render but only when the thread changes 26 | * Memo is used to optimize performance and avoid unnecessary re-renders. 27 | * 28 | * useMemo: https://react.dev/reference/react/useMemo 29 | */ 30 | 31 | const memoizedContent = useMemo( 32 | () => ( 33 |
{ 37 | onFocus(thread.id); 38 | 39 | // check if click is on/in the composer 40 | if ( 41 | e.target && 42 | e.target.classList.contains("lb-icon") && 43 | e.target.classList.contains("lb-button-icon") 44 | ) { 45 | return; 46 | } 47 | 48 | setMinimized(!minimized); 49 | }} 50 | > 51 |
55 | Dummy Name 63 |
64 | {!minimized ? ( 65 |
66 | { 70 | e.stopPropagation(); 71 | }} 72 | /> 73 |
74 | ) : null} 75 |
76 | ), 77 | [thread.comments.length, minimized] 78 | ); 79 | 80 | return <>{memoizedContent}; 81 | }; -------------------------------------------------------------------------------- /components/cursor/Cursor.tsx: -------------------------------------------------------------------------------- 1 | import CursorSVG from "@/public/assets/CursorSVG"; 2 | 3 | type Props = { 4 | color: string; 5 | x: number; 6 | y: number; 7 | message: string; 8 | } 9 | 10 | const Cursor = ({ color, x, y, message }: Props) => { 11 | return ( 12 |
14 | 15 | 16 | {/* MESSAGE */} 17 | {message && ( 18 |
20 |

{message}

21 |
22 | )} 23 |
24 | ) 25 | } 26 | 27 | export default Cursor -------------------------------------------------------------------------------- /components/cursor/CursorChat.tsx: -------------------------------------------------------------------------------- 1 | import CursorSVG from "@/public/assets/CursorSVG" 2 | import { CursorChatProps, CursorMode } from "@/types/type" 3 | 4 | const CursorChat = ({ cursor, cursorState, setCursorState, updateMyPresence }: CursorChatProps) => { 5 | const handleChange = (e: React.ChangeEvent) => { 6 | updateMyPresence({ message: e.target.value }); 7 | setCursorState({ 8 | mode: CursorMode.Chat, 9 | previousMessage: null, 10 | message: e.target.value 11 | }); 12 | }; 13 | 14 | const handleKeyDown = (e: React.KeyboardEvent) => { 15 | if (e.key === "Enter") { 16 | setCursorState({ 17 | mode: CursorMode.Chat, 18 | previousMessage: cursorState.message, 19 | message: "" 20 | }); 21 | } else if (e.key === "Escape") { 22 | setCursorState({ 23 | mode: CursorMode.Hidden 24 | }); 25 | } 26 | }; 27 | return ( 28 |
30 | {cursorState.mode === CursorMode.Chat && ( 31 | <> 32 | 33 | 34 |
e.stopPropagation()} 36 | className="absolute top-5 left-2 bg-blue-500 px-4 py-2 text-sm leading-relaxed text-white rounded-[20px]"> 37 | {cursorState.previousMessage && ( 38 |
{cursorState.previousMessage}
39 | )} 40 | 49 |
50 | 51 | )} 52 |
53 | ) 54 | } 55 | 56 | export default CursorChat -------------------------------------------------------------------------------- /components/cursor/LiveCursors.tsx: -------------------------------------------------------------------------------- 1 | import { LiveCursorProps } from "@/types/type" 2 | import Cursor from "./Cursor"; 3 | import { COLORS } from "@/constants"; 4 | 5 | const LiveCursors = ({ others }: LiveCursorProps) => { 6 | return others.map(({ connectionId, presence }) => { 7 | if (!presence?.cursor) return null; 8 | 9 | return ( 10 | 17 | ) 18 | }) 19 | } 20 | 21 | export default LiveCursors -------------------------------------------------------------------------------- /components/reaction/FlyingReaction.module.css: -------------------------------------------------------------------------------- 1 | .goUp0 { 2 | opacity: 0; 3 | animation: 4 | goUpAnimation0 2s, 5 | fadeOut 2s; 6 | } 7 | 8 | @keyframes goUpAnimation0 { 9 | from { 10 | transform: translate(0px, 0px); 11 | } 12 | 13 | to { 14 | transform: translate(0px, -400px); 15 | } 16 | } 17 | 18 | .goUp1 { 19 | opacity: 0; 20 | animation: 21 | goUpAnimation1 2s, 22 | fadeOut 2s; 23 | } 24 | 25 | @keyframes goUpAnimation1 { 26 | from { 27 | transform: translate(0px, 0px); 28 | } 29 | 30 | to { 31 | transform: translate(0px, -300px); 32 | } 33 | } 34 | 35 | .goUp2 { 36 | opacity: 0; 37 | animation: 38 | goUpAnimation2 2s, 39 | fadeOut 2s; 40 | } 41 | 42 | @keyframes goUpAnimation2 { 43 | from { 44 | transform: translate(0px, 0px); 45 | } 46 | 47 | to { 48 | transform: translate(0px, -200px); 49 | } 50 | } 51 | 52 | .leftRight0 { 53 | animation: leftRightAnimation0 0.3s alternate infinite ease-in-out; 54 | } 55 | 56 | @keyframes leftRightAnimation0 { 57 | from { 58 | transform: translate(0px, 0px); 59 | } 60 | 61 | to { 62 | transform: translate(50px, 0px); 63 | } 64 | } 65 | 66 | .leftRight1 { 67 | animation: leftRightAnimation1 0.3s alternate infinite ease-in-out; 68 | } 69 | 70 | @keyframes leftRightAnimation1 { 71 | from { 72 | transform: translate(0px, 0px); 73 | } 74 | 75 | to { 76 | transform: translate(100px, 0px); 77 | } 78 | } 79 | 80 | .leftRight2 { 81 | animation: leftRightAnimation2 0.3s alternate infinite ease-in-out; 82 | } 83 | 84 | @keyframes leftRightAnimation2 { 85 | from { 86 | transform: translate(0px, 0px); 87 | } 88 | 89 | to { 90 | transform: translate(-50px, 0px); 91 | } 92 | } 93 | 94 | @keyframes fadeOut { 95 | from { 96 | opacity: 1; 97 | } 98 | 99 | to { 100 | opacity: 0; 101 | } 102 | } -------------------------------------------------------------------------------- /components/reaction/FlyingReaction.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./FlyingReaction.module.css"; 2 | 3 | type Props = { 4 | x: number; 5 | y: number; 6 | timestamp: number; 7 | value: string; 8 | }; 9 | 10 | export default function FlyingReaction({ x, y, timestamp, value }: Props) { 11 | return ( 12 |
17 |
18 |
19 | {value} 20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/reaction/ReactionButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | setReaction: (reaction: string) => void; 5 | }; 6 | 7 | export default function ReactionSelector({ setReaction }: Props) { 8 | return ( 9 |
e.stopPropagation()} 12 | > 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | ); 21 | } 22 | 23 | function ReactionButton( 24 | { 25 | reaction, 26 | onSelect, 27 | }: { 28 | reaction: string; 29 | onSelect: (reaction: string) => void; 30 | } 31 | ) { 32 | return ( 33 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /components/settings/Color.tsx: -------------------------------------------------------------------------------- 1 | import { Label } from "../ui/label"; 2 | 3 | type Props = { 4 | inputRef: any; 5 | attribute: string; 6 | placeholder: string; 7 | attributeType: string; 8 | handleInputChange: (property: string, value: string) => void; 9 | }; 10 | 11 | const Color = ({ 12 | inputRef, 13 | attribute, 14 | placeholder, 15 | attributeType, 16 | handleInputChange, 17 | }: Props) => ( 18 |
19 |

{placeholder}

20 |
inputRef.current.click()} 23 | > 24 | handleInputChange(attributeType, e.target.value)} 29 | /> 30 | 31 | 34 |
35 |
36 | ); 37 | 38 | export default Color; 39 | -------------------------------------------------------------------------------- /components/settings/Dimensions.tsx: -------------------------------------------------------------------------------- 1 | import { Label } from "../ui/label"; 2 | import { Input } from "../ui/input"; 3 | 4 | const dimensionsOptions = [ 5 | { label: "W", property: "width" }, 6 | { label: "H", property: "height" }, 7 | ]; 8 | 9 | type Props = { 10 | width: string; 11 | height: string; 12 | isEditingRef: React.MutableRefObject; 13 | handleInputChange: (property: string, value: string) => void; 14 | }; 15 | 16 | const Dimensions = ({ width, height, isEditingRef, handleInputChange }: Props) => ( 17 |
18 |
19 | {dimensionsOptions.map((item) => ( 20 |
24 | 27 | handleInputChange(item.property, e.target.value)} 35 | onBlur={(e) => { 36 | isEditingRef.current = false 37 | }} 38 | /> 39 |
40 | ))} 41 |
42 |
43 | ); 44 | 45 | export default Dimensions; 46 | -------------------------------------------------------------------------------- /components/settings/Export.tsx: -------------------------------------------------------------------------------- 1 | import { exportToPdf } from "@/lib/utils"; 2 | 3 | import { Button } from "../ui/button"; 4 | 5 | const Export = () => ( 6 |
7 |

Export

8 | 15 |
16 | ); 17 | 18 | export default Export; 19 | -------------------------------------------------------------------------------- /components/settings/Text.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | fontFamilyOptions, 3 | fontSizeOptions, 4 | fontWeightOptions, 5 | } from "@/constants"; 6 | 7 | import { 8 | Select, 9 | SelectContent, 10 | SelectItem, 11 | SelectTrigger, 12 | SelectValue, 13 | } from "../ui/select"; 14 | 15 | const selectConfigs = [ 16 | { 17 | property: "fontFamily", 18 | placeholder: "Choose a font", 19 | options: fontFamilyOptions, 20 | }, 21 | { property: "fontSize", placeholder: "30", options: fontSizeOptions }, 22 | { 23 | property: "fontWeight", 24 | placeholder: "Semibold", 25 | options: fontWeightOptions, 26 | }, 27 | ]; 28 | 29 | type TextProps = { 30 | fontFamily: string; 31 | fontSize: string; 32 | fontWeight: string; 33 | handleInputChange: (property: string, value: string) => void; 34 | }; 35 | 36 | const Text = ({ 37 | fontFamily, 38 | fontSize, 39 | fontWeight, 40 | handleInputChange, 41 | }: TextProps) => ( 42 |
43 |

Text

44 | 45 |
46 | {RenderSelect({ 47 | config: selectConfigs[0], 48 | fontSize, 49 | fontWeight, 50 | fontFamily, 51 | handleInputChange, 52 | })} 53 | 54 |
55 | {selectConfigs.slice(1).map((config) => 56 | RenderSelect({ 57 | config, 58 | fontSize, 59 | fontWeight, 60 | fontFamily, 61 | handleInputChange, 62 | }) 63 | )} 64 |
65 |
66 |
67 | ); 68 | 69 | type Props = { 70 | config: { 71 | property: string; 72 | placeholder: string; 73 | options: { label: string; value: string }[]; 74 | }; 75 | fontSize: string; 76 | fontWeight: string; 77 | fontFamily: string; 78 | handleInputChange: (property: string, value: string) => void; 79 | }; 80 | 81 | const RenderSelect = ({ 82 | config, 83 | fontSize, 84 | fontWeight, 85 | fontFamily, 86 | handleInputChange, 87 | }: Props) => ( 88 | 122 | ); 123 | 124 | export default Text; 125 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | span]:line-clamp-1", 23 | className 24 | )} 25 | {...props} 26 | > 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectScrollUpButton = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | 48 | 49 | )) 50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 51 | 52 | const SelectScrollDownButton = React.forwardRef< 53 | React.ElementRef, 54 | React.ComponentPropsWithoutRef 55 | >(({ className, ...props }, ref) => ( 56 | 64 | 65 | 66 | )) 67 | SelectScrollDownButton.displayName = 68 | SelectPrimitive.ScrollDownButton.displayName 69 | 70 | const SelectContent = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >(({ className, children, position = "popper", ...props }, ref) => ( 74 | 75 | 86 | 87 | 94 | {children} 95 | 96 | 97 | 98 | 99 | )) 100 | SelectContent.displayName = SelectPrimitive.Content.displayName 101 | 102 | const SelectLabel = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )) 112 | SelectLabel.displayName = SelectPrimitive.Label.displayName 113 | 114 | const SelectItem = React.forwardRef< 115 | React.ElementRef, 116 | React.ComponentPropsWithoutRef 117 | >(({ className, children, ...props }, ref) => ( 118 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | {children} 133 | 134 | )) 135 | SelectItem.displayName = SelectPrimitive.Item.displayName 136 | 137 | const SelectSeparator = React.forwardRef< 138 | React.ElementRef, 139 | React.ComponentPropsWithoutRef 140 | >(({ className, ...props }, ref) => ( 141 | 146 | )) 147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 148 | 149 | export { 150 | Select, 151 | SelectGroup, 152 | SelectValue, 153 | SelectTrigger, 154 | SelectContent, 155 | SelectLabel, 156 | SelectItem, 157 | SelectSeparator, 158 | SelectScrollUpButton, 159 | SelectScrollDownButton, 160 | } 161 | -------------------------------------------------------------------------------- /components/users/ActiveUsers.tsx: -------------------------------------------------------------------------------- 1 | import { useOthers, useSelf } from "@/liveblocks.config"; 2 | import { Avatar } from "./Avatar"; 3 | import styles from "./index.module.css"; 4 | import { generateRandomName } from "@/lib/utils"; 5 | import { useMemo } from "react"; 6 | 7 | const ActiveUsers = () => { 8 | const users = useOthers(); 9 | const currentUser = useSelf(); 10 | const hasMoreUsers = users.length > 3; 11 | 12 | const memoUsers = useMemo(() => { 13 | return ( 14 |
15 |
16 | {currentUser && ( 17 | 18 | )} 19 | 20 | {users.slice(0, 3).map(({ connectionId }) => { 21 | return ( 22 | 23 | ); 24 | })} 25 | 26 | {hasMoreUsers &&
+{users.length - 3}
} 27 |
28 |
29 | ) 30 | }, [users.length]) 31 | 32 | return memoUsers 33 | } 34 | 35 | export default ActiveUsers -------------------------------------------------------------------------------- /components/users/Avatar.module.css: -------------------------------------------------------------------------------- 1 | .avatar { 2 | display: flex; 3 | place-content: center; 4 | position: relative; 5 | border: 4px solid #fff; 6 | border-radius: 9999px; 7 | width: 40px; 8 | height: 40px; 9 | background-color: #9ca3af; 10 | margin-left: -0.75rem; 11 | } 12 | 13 | .avatar:before { 14 | content: attr(data-tooltip); 15 | position: absolute; 16 | bottom: 100%; 17 | opacity: 0; 18 | transition: opacity 0.15s ease; 19 | padding: 5px 10px; 20 | color: white; 21 | font-size: 0.75rem; 22 | border-radius: 8px; 23 | margin-bottom: 10px; 24 | z-index: 1; 25 | background: black; 26 | white-space: nowrap; 27 | } 28 | 29 | .avatar:hover:before { 30 | opacity: 1; 31 | } 32 | 33 | .avatar_picture { 34 | width: 100%; 35 | height: 100%; 36 | border-radius: 9999px; 37 | } -------------------------------------------------------------------------------- /components/users/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styles from "./Avatar.module.css"; 3 | import Image from "next/image"; 4 | 5 | export function Avatar({ name, otherStyles }: { otherStyles: string; name: string }) { 6 | return ( 7 |
8 | {name} 14 |
15 | ); 16 | } -------------------------------------------------------------------------------- /components/users/index.module.css: -------------------------------------------------------------------------------- 1 | .more { 2 | border-width: 4px; 3 | border-radius: 9999px; 4 | border-color: white; 5 | background-color: #9ca3af; 6 | min-width: 56px; 7 | width: 56px; 8 | height: 56px; 9 | margin-left: -0.75rem; 10 | display: flex; 11 | justify-content: center; 12 | align-items: center; 13 | color: white; 14 | } -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | export const COLORS = ["#DC2626", "#D97706", "#059669", "#7C3AED", "#DB2777"]; 2 | 3 | export const shapeElements = [ 4 | { 5 | icon: "/assets/rectangle.svg", 6 | name: "Rectangle", 7 | value: "rectangle", 8 | }, 9 | { 10 | icon: "/assets/circle.svg", 11 | name: "Circle", 12 | value: "circle", 13 | }, 14 | { 15 | icon: "/assets/triangle.svg", 16 | name: "Triangle", 17 | value: "triangle", 18 | }, 19 | { 20 | icon: "/assets/line.svg", 21 | name: "Line", 22 | value: "line", 23 | }, 24 | { 25 | icon: "/assets/image.svg", 26 | name: "Image", 27 | value: "image", 28 | }, 29 | { 30 | icon: "/assets/freeform.svg", 31 | name: "Free Drawing", 32 | value: "freeform", 33 | }, 34 | ]; 35 | 36 | export const navElements = [ 37 | { 38 | icon: "/assets/select.svg", 39 | name: "Select", 40 | value: "select", 41 | }, 42 | { 43 | icon: "/assets/rectangle.svg", 44 | name: "Rectangle", 45 | value: shapeElements, 46 | }, 47 | { 48 | icon: "/assets/text.svg", 49 | value: "text", 50 | name: "Text", 51 | }, 52 | { 53 | icon: "/assets/delete.svg", 54 | value: "delete", 55 | name: "Delete", 56 | }, 57 | { 58 | icon: "/assets/reset.svg", 59 | value: "reset", 60 | name: "Reset", 61 | }, 62 | { 63 | icon: "/assets/comments.svg", 64 | value: "comments", 65 | name: "Comments", 66 | }, 67 | ]; 68 | 69 | export const defaultNavElement = { 70 | icon: "/assets/select.svg", 71 | name: "Select", 72 | value: "select", 73 | }; 74 | 75 | export const directionOptions = [ 76 | { label: "Bring to Front", value: "front", icon: "/assets/front.svg" }, 77 | { label: "Send to Back", value: "back", icon: "/assets/back.svg" }, 78 | ]; 79 | 80 | export const fontFamilyOptions = [ 81 | { value: "Helvetica", label: "Helvetica" }, 82 | { value: "Times New Roman", label: "Times New Roman" }, 83 | { value: "Comic Sans MS", label: "Comic Sans MS" }, 84 | { value: "Brush Script MT", label: "Brush Script MT" }, 85 | ]; 86 | 87 | export const fontSizeOptions = [ 88 | { 89 | value: "10", 90 | label: "10", 91 | }, 92 | { 93 | value: "12", 94 | label: "12", 95 | }, 96 | { 97 | value: "14", 98 | label: "14", 99 | }, 100 | { 101 | value: "16", 102 | label: "16", 103 | }, 104 | { 105 | value: "18", 106 | label: "18", 107 | }, 108 | { 109 | value: "20", 110 | label: "20", 111 | }, 112 | { 113 | value: "22", 114 | label: "22", 115 | }, 116 | { 117 | value: "24", 118 | label: "24", 119 | }, 120 | { 121 | value: "26", 122 | label: "26", 123 | }, 124 | { 125 | value: "28", 126 | label: "28", 127 | }, 128 | { 129 | value: "30", 130 | label: "30", 131 | }, 132 | { 133 | value: "32", 134 | label: "32", 135 | }, 136 | { 137 | value: "34", 138 | label: "34", 139 | }, 140 | { 141 | value: "36", 142 | label: "36", 143 | }, 144 | ]; 145 | 146 | export const fontWeightOptions = [ 147 | { 148 | value: "400", 149 | label: "Normal", 150 | }, 151 | { 152 | value: "500", 153 | label: "Semibold", 154 | }, 155 | { 156 | value: "600", 157 | label: "Bold", 158 | }, 159 | ]; 160 | 161 | export const alignmentOptions = [ 162 | { value: "left", label: "Align Left", icon: "/assets/align-left.svg" }, 163 | { 164 | value: "horizontalCenter", 165 | label: "Align Horizontal Center", 166 | icon: "/assets/align-horizontal-center.svg", 167 | }, 168 | { value: "right", label: "Align Right", icon: "/assets/align-right.svg" }, 169 | { value: "top", label: "Align Top", icon: "/assets/align-top.svg" }, 170 | { 171 | value: "verticalCenter", 172 | label: "Align Vertical Center", 173 | icon: "/assets/align-vertical-center.svg", 174 | }, 175 | { value: "bottom", label: "Align Bottom", icon: "/assets/align-bottom.svg" }, 176 | ]; 177 | 178 | export const shortcuts = [ 179 | { 180 | key: "1", 181 | name: "Chat", 182 | shortcut: "/", 183 | }, 184 | { 185 | key: "2", 186 | name: "Undo", 187 | shortcut: "⌘ + Z", 188 | }, 189 | { 190 | key: "3", 191 | name: "Redo", 192 | shortcut: "⌘ + Y", 193 | }, 194 | { 195 | key: "4", 196 | name: "Reactions", 197 | shortcut: "E", 198 | }, 199 | ]; 200 | -------------------------------------------------------------------------------- /hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | // From Dan Abramov's blog: https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 4 | 5 | export default function useInterval(callback: () => void, delay: number) { 6 | const savedCallback = useRef<() => void>(callback); 7 | 8 | // Remember the latest callback. 9 | useEffect(() => { 10 | savedCallback.current = callback; 11 | }, [callback]); 12 | 13 | // Set up the interval. 14 | useEffect(() => { 15 | const tick = () => { 16 | savedCallback.current(); 17 | }; 18 | 19 | if (delay !== null) { 20 | let id = setInterval(tick, delay); 21 | return () => clearInterval(id); 22 | } 23 | }, [delay]); 24 | } 25 | -------------------------------------------------------------------------------- /lib/canvas.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import { v4 as uuid4 } from "uuid"; 3 | 4 | import { 5 | CanvasMouseDown, 6 | CanvasMouseMove, 7 | CanvasMouseUp, 8 | CanvasObjectModified, 9 | CanvasObjectScaling, 10 | CanvasPathCreated, 11 | CanvasSelectionCreated, 12 | RenderCanvas, 13 | } from "@/types/type"; 14 | import { defaultNavElement } from "@/constants"; 15 | import { createSpecificShape } from "./shapes"; 16 | 17 | // initialize fabric canvas 18 | export const initializeFabric = ({ 19 | fabricRef, 20 | canvasRef, 21 | }: { 22 | fabricRef: React.MutableRefObject; 23 | canvasRef: React.MutableRefObject; 24 | }) => { 25 | // get canvas element 26 | const canvasElement = document.getElementById("canvas"); 27 | 28 | // create fabric canvas 29 | const canvas = new fabric.Canvas(canvasRef.current, { 30 | width: canvasElement?.clientWidth, 31 | height: canvasElement?.clientHeight, 32 | }); 33 | 34 | // set canvas reference to fabricRef so we can use it later anywhere outside canvas listener 35 | fabricRef.current = canvas; 36 | 37 | return canvas; 38 | }; 39 | 40 | // instantiate creation of custom fabric object/shape and add it to canvas 41 | export const handleCanvasMouseDown = ({ 42 | options, 43 | canvas, 44 | selectedShapeRef, 45 | isDrawing, 46 | shapeRef, 47 | }: CanvasMouseDown) => { 48 | // get pointer coordinates 49 | const pointer = canvas.getPointer(options.e); 50 | 51 | /** 52 | * get target object i.e., the object that is clicked 53 | * findtarget() returns the object that is clicked 54 | * 55 | * findTarget: http://fabricjs.com/docs/fabric.Canvas.html#findTarget 56 | */ 57 | const target = canvas.findTarget(options.e, false); 58 | 59 | // set canvas drawing mode to false 60 | canvas.isDrawingMode = false; 61 | 62 | // if selected shape is freeform, set drawing mode to true and return 63 | if (selectedShapeRef.current === "freeform") { 64 | isDrawing.current = true; 65 | canvas.isDrawingMode = true; 66 | canvas.freeDrawingBrush.width = 5; 67 | return; 68 | } 69 | 70 | canvas.isDrawingMode = false; 71 | 72 | // if target is the selected shape or active selection, set isDrawing to false 73 | if ( 74 | target && 75 | (target.type === selectedShapeRef.current || 76 | target.type === "activeSelection") 77 | ) { 78 | isDrawing.current = false; 79 | 80 | // set active object to target 81 | canvas.setActiveObject(target); 82 | 83 | /** 84 | * setCoords() is used to update the controls of the object 85 | * setCoords: http://fabricjs.com/docs/fabric.Object.html#setCoords 86 | */ 87 | target.setCoords(); 88 | } else { 89 | isDrawing.current = true; 90 | 91 | // create custom fabric object/shape and set it to shapeRef 92 | shapeRef.current = createSpecificShape( 93 | selectedShapeRef.current, 94 | pointer as any 95 | ); 96 | 97 | // if shapeRef is not null, add it to canvas 98 | if (shapeRef.current) { 99 | // add: http://fabricjs.com/docs/fabric.Canvas.html#add 100 | canvas.add(shapeRef.current); 101 | } 102 | } 103 | }; 104 | 105 | // handle mouse move event on canvas to draw shapes with different dimensions 106 | export const handleCanvaseMouseMove = ({ 107 | options, 108 | canvas, 109 | isDrawing, 110 | selectedShapeRef, 111 | shapeRef, 112 | syncShapeInStorage, 113 | }: CanvasMouseMove) => { 114 | // if selected shape is freeform, return 115 | if (!isDrawing.current) return; 116 | if (selectedShapeRef.current === "freeform") return; 117 | 118 | canvas.isDrawingMode = false; 119 | 120 | // get pointer coordinates 121 | const pointer = canvas.getPointer(options.e); 122 | 123 | // depending on the selected shape, set the dimensions of the shape stored in shapeRef in previous step of handelCanvasMouseDown 124 | // calculate shape dimensions based on pointer coordinates 125 | switch (selectedShapeRef?.current) { 126 | case "rectangle": 127 | shapeRef.current?.set({ 128 | width: pointer.x - (shapeRef.current?.left || 0), 129 | height: pointer.y - (shapeRef.current?.top || 0), 130 | }); 131 | break; 132 | 133 | case "circle": 134 | shapeRef.current.set({ 135 | radius: Math.abs(pointer.x - (shapeRef.current?.left || 0)) / 2, 136 | }); 137 | break; 138 | 139 | case "triangle": 140 | shapeRef.current?.set({ 141 | width: pointer.x - (shapeRef.current?.left || 0), 142 | height: pointer.y - (shapeRef.current?.top || 0), 143 | }); 144 | break; 145 | 146 | case "line": 147 | shapeRef.current?.set({ 148 | x2: pointer.x, 149 | y2: pointer.y, 150 | }); 151 | break; 152 | 153 | case "image": 154 | shapeRef.current?.set({ 155 | width: pointer.x - (shapeRef.current?.left || 0), 156 | height: pointer.y - (shapeRef.current?.top || 0), 157 | }); 158 | 159 | default: 160 | break; 161 | } 162 | 163 | // render objects on canvas 164 | // renderAll: http://fabricjs.com/docs/fabric.Canvas.html#renderAll 165 | canvas.renderAll(); 166 | 167 | // sync shape in storage 168 | if (shapeRef.current?.objectId) { 169 | syncShapeInStorage(shapeRef.current); 170 | } 171 | }; 172 | 173 | // handle mouse up event on canvas to stop drawing shapes 174 | export const handleCanvasMouseUp = ({ 175 | canvas, 176 | isDrawing, 177 | shapeRef, 178 | activeObjectRef, 179 | selectedShapeRef, 180 | syncShapeInStorage, 181 | setActiveElement, 182 | }: CanvasMouseUp) => { 183 | isDrawing.current = false; 184 | if (selectedShapeRef.current === "freeform") return; 185 | 186 | // sync shape in storage as drawing is stopped 187 | syncShapeInStorage(shapeRef.current); 188 | 189 | // set everything to null 190 | shapeRef.current = null; 191 | activeObjectRef.current = null; 192 | selectedShapeRef.current = null; 193 | 194 | // if canvas is not in drawing mode, set active element to default nav element after 700ms 195 | if (!canvas.isDrawingMode) { 196 | setTimeout(() => { 197 | setActiveElement(defaultNavElement); 198 | }, 700); 199 | } 200 | }; 201 | 202 | // update shape in storage when object is modified 203 | export const handleCanvasObjectModified = ({ 204 | options, 205 | syncShapeInStorage, 206 | }: CanvasObjectModified) => { 207 | const target = options.target; 208 | if (!target) return; 209 | 210 | if (target?.type == "activeSelection") { 211 | // fix this 212 | } else { 213 | syncShapeInStorage(target); 214 | } 215 | }; 216 | 217 | // update shape in storage when path is created when in freeform mode 218 | export const handlePathCreated = ({ 219 | options, 220 | syncShapeInStorage, 221 | }: CanvasPathCreated) => { 222 | // get path object 223 | const path = options.path; 224 | if (!path) return; 225 | 226 | // set unique id to path object 227 | path.set({ 228 | objectId: uuid4(), 229 | }); 230 | 231 | // sync shape in storage 232 | syncShapeInStorage(path); 233 | }; 234 | 235 | // check how object is moving on canvas and restrict it to canvas boundaries 236 | export const handleCanvasObjectMoving = ({ 237 | options, 238 | }: { 239 | options: fabric.IEvent; 240 | }) => { 241 | // get target object which is moving 242 | const target = options.target as fabric.Object; 243 | 244 | // target.canvas is the canvas on which the object is moving 245 | const canvas = target.canvas as fabric.Canvas; 246 | 247 | // set coordinates of target object 248 | target.setCoords(); 249 | 250 | // restrict object to canvas boundaries (horizontal) 251 | if (target && target.left) { 252 | target.left = Math.max( 253 | 0, 254 | Math.min( 255 | target.left, 256 | (canvas.width || 0) - (target.getScaledWidth() || target.width || 0) 257 | ) 258 | ); 259 | } 260 | 261 | // restrict object to canvas boundaries (vertical) 262 | if (target && target.top) { 263 | target.top = Math.max( 264 | 0, 265 | Math.min( 266 | target.top, 267 | (canvas.height || 0) - (target.getScaledHeight() || target.height || 0) 268 | ) 269 | ); 270 | } 271 | }; 272 | 273 | // set element attributes when element is selected 274 | export const handleCanvasSelectionCreated = ({ 275 | options, 276 | isEditingRef, 277 | setElementAttributes, 278 | }: CanvasSelectionCreated) => { 279 | // if user is editing manually, return 280 | if (isEditingRef.current) return; 281 | 282 | // if no element is selected, return 283 | if (!options?.selected) return; 284 | 285 | // get the selected element 286 | const selectedElement = options?.selected[0] as fabric.Object; 287 | 288 | // if only one element is selected, set element attributes 289 | if (selectedElement && options.selected.length === 1) { 290 | // calculate scaled dimensions of the object 291 | const scaledWidth = selectedElement?.scaleX 292 | ? selectedElement?.width! * selectedElement?.scaleX 293 | : selectedElement?.width; 294 | 295 | const scaledHeight = selectedElement?.scaleY 296 | ? selectedElement?.height! * selectedElement?.scaleY 297 | : selectedElement?.height; 298 | 299 | setElementAttributes({ 300 | width: scaledWidth?.toFixed(0).toString() || "", 301 | height: scaledHeight?.toFixed(0).toString() || "", 302 | fill: selectedElement?.fill?.toString() || "", 303 | stroke: selectedElement?.stroke || "", 304 | // @ts-ignore 305 | fontSize: selectedElement?.fontSize || "", 306 | // @ts-ignore 307 | fontFamily: selectedElement?.fontFamily || "", 308 | // @ts-ignore 309 | fontWeight: selectedElement?.fontWeight || "", 310 | }); 311 | } 312 | }; 313 | 314 | // update element attributes when element is scaled 315 | export const handleCanvasObjectScaling = ({ 316 | options, 317 | setElementAttributes, 318 | }: CanvasObjectScaling) => { 319 | const selectedElement = options.target; 320 | 321 | // calculate scaled dimensions of the object 322 | const scaledWidth = selectedElement?.scaleX 323 | ? selectedElement?.width! * selectedElement?.scaleX 324 | : selectedElement?.width; 325 | 326 | const scaledHeight = selectedElement?.scaleY 327 | ? selectedElement?.height! * selectedElement?.scaleY 328 | : selectedElement?.height; 329 | 330 | setElementAttributes((prev) => ({ 331 | ...prev, 332 | width: scaledWidth?.toFixed(0).toString() || "", 333 | height: scaledHeight?.toFixed(0).toString() || "", 334 | })); 335 | }; 336 | 337 | // render canvas objects coming from storage on canvas 338 | export const renderCanvas = ({ 339 | fabricRef, 340 | canvasObjects, 341 | activeObjectRef, 342 | }: RenderCanvas) => { 343 | // clear canvas 344 | fabricRef.current?.clear(); 345 | 346 | // render all objects on canvas 347 | Array.from(canvasObjects, ([objectId, objectData]) => { 348 | /** 349 | * enlivenObjects() is used to render objects on canvas. 350 | * It takes two arguments: 351 | * 1. objectData: object data to render on canvas 352 | * 2. callback: callback function to execute after rendering objects 353 | * on canvas 354 | * 355 | * enlivenObjects: http://fabricjs.com/docs/fabric.util.html#.enlivenObjectEnlivables 356 | */ 357 | fabric.util.enlivenObjects( 358 | [objectData], 359 | (enlivenedObjects: fabric.Object[]) => { 360 | enlivenedObjects.forEach((enlivenedObj) => { 361 | // if element is active, keep it in active state so that it can be edited further 362 | if (activeObjectRef.current?.objectId === objectId) { 363 | fabricRef.current?.setActiveObject(enlivenedObj); 364 | } 365 | 366 | // add object to canvas 367 | fabricRef.current?.add(enlivenedObj); 368 | }); 369 | }, 370 | /** 371 | * specify namespace of the object for fabric to render it on canvas 372 | * A namespace is a string that is used to identify the type of 373 | * object. 374 | * 375 | * Fabric Namespace: http://fabricjs.com/docs/fabric.html 376 | */ 377 | "fabric" 378 | ); 379 | }); 380 | 381 | fabricRef.current?.renderAll(); 382 | }; 383 | 384 | // resize canvas dimensions on window resize 385 | export const handleResize = ({ canvas }: { canvas: fabric.Canvas | null }) => { 386 | const canvasElement = document.getElementById("canvas"); 387 | if (!canvasElement) return; 388 | 389 | if (!canvas) return; 390 | 391 | canvas.setDimensions({ 392 | width: canvasElement.clientWidth, 393 | height: canvasElement.clientHeight, 394 | }); 395 | }; 396 | 397 | // zoom canvas on mouse scroll 398 | export const handleCanvasZoom = ({ 399 | options, 400 | canvas, 401 | }: { 402 | options: fabric.IEvent & { e: WheelEvent }; 403 | canvas: fabric.Canvas; 404 | }) => { 405 | const delta = options.e?.deltaY; 406 | let zoom = canvas.getZoom(); 407 | 408 | // allow zooming to min 20% and max 100% 409 | const minZoom = 0.2; 410 | const maxZoom = 1; 411 | const zoomStep = 0.001; 412 | 413 | // calculate zoom based on mouse scroll wheel with min and max zoom 414 | zoom = Math.min(Math.max(minZoom, zoom + delta * zoomStep), maxZoom); 415 | 416 | // set zoom to canvas 417 | // zoomToPoint: http://fabricjs.com/docs/fabric.Canvas.html#zoomToPoint 418 | canvas.zoomToPoint({ x: options.e.offsetX, y: options.e.offsetY }, zoom); 419 | 420 | options.e.preventDefault(); 421 | options.e.stopPropagation(); 422 | }; -------------------------------------------------------------------------------- /lib/key-events.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | import { CustomFabricObject } from "@/types/type"; 5 | 6 | export const handleCopy = (canvas: fabric.Canvas) => { 7 | const activeObjects = canvas.getActiveObjects(); 8 | if (activeObjects.length > 0) { 9 | // Serialize the selected objects 10 | const serializedObjects = activeObjects.map((obj) => obj.toObject()); 11 | // Store the serialized objects in the clipboard 12 | localStorage.setItem("clipboard", JSON.stringify(serializedObjects)); 13 | } 14 | 15 | return activeObjects; 16 | }; 17 | 18 | export const handlePaste = ( 19 | canvas: fabric.Canvas, 20 | syncShapeInStorage: (shape: fabric.Object) => void 21 | ) => { 22 | if (!canvas || !(canvas instanceof fabric.Canvas)) { 23 | console.error("Invalid canvas object. Aborting paste operation."); 24 | return; 25 | } 26 | 27 | // Retrieve serialized objects from the clipboard 28 | const clipboardData = localStorage.getItem("clipboard"); 29 | 30 | if (clipboardData) { 31 | try { 32 | const parsedObjects = JSON.parse(clipboardData); 33 | parsedObjects.forEach((objData: fabric.Object) => { 34 | // convert the plain javascript objects retrieved from localStorage into fabricjs objects (deserialization) 35 | fabric.util.enlivenObjects( 36 | [objData], 37 | (enlivenedObjects: fabric.Object[]) => { 38 | enlivenedObjects.forEach((enlivenedObj) => { 39 | // Offset the pasted objects to avoid overlap with existing objects 40 | enlivenedObj.set({ 41 | left: enlivenedObj.left || 0 + 20, 42 | top: enlivenedObj.top || 0 + 20, 43 | objectId: uuidv4(), 44 | fill: "#aabbcc", 45 | } as CustomFabricObject); 46 | 47 | canvas.add(enlivenedObj); 48 | syncShapeInStorage(enlivenedObj); 49 | }); 50 | canvas.renderAll(); 51 | }, 52 | "fabric" 53 | ); 54 | }); 55 | } catch (error) { 56 | console.error("Error parsing clipboard data:", error); 57 | } 58 | } 59 | }; 60 | 61 | export const handleDelete = ( 62 | canvas: fabric.Canvas, 63 | deleteShapeFromStorage: (id: string) => void 64 | ) => { 65 | const activeObjects = canvas.getActiveObjects(); 66 | if (!activeObjects || activeObjects.length === 0) return; 67 | 68 | if (activeObjects.length > 0) { 69 | activeObjects.forEach((obj: CustomFabricObject) => { 70 | if (!obj.objectId) return; 71 | canvas.remove(obj); 72 | deleteShapeFromStorage(obj.objectId); 73 | }); 74 | } 75 | 76 | canvas.discardActiveObject(); 77 | canvas.requestRenderAll(); 78 | }; 79 | 80 | // create a handleKeyDown function that listen to different keydown events 81 | export const handleKeyDown = ({ 82 | e, 83 | canvas, 84 | undo, 85 | redo, 86 | syncShapeInStorage, 87 | deleteShapeFromStorage, 88 | }: { 89 | e: KeyboardEvent; 90 | canvas: fabric.Canvas | any; 91 | undo: () => void; 92 | redo: () => void; 93 | syncShapeInStorage: (shape: fabric.Object) => void; 94 | deleteShapeFromStorage: (id: string) => void; 95 | }) => { 96 | // Check if the key pressed is ctrl/cmd + c (copy) 97 | if ((e?.ctrlKey || e?.metaKey) && e.keyCode === 67) { 98 | handleCopy(canvas); 99 | } 100 | 101 | // Check if the key pressed is ctrl/cmd + v (paste) 102 | if ((e?.ctrlKey || e?.metaKey) && e.keyCode === 86) { 103 | handlePaste(canvas, syncShapeInStorage); 104 | } 105 | 106 | // Check if the key pressed is delete/backspace (delete) 107 | // if (e.keyCode === 8 || e.keyCode === 46) { 108 | // handleDelete(canvas, deleteShapeFromStorage); 109 | // } 110 | 111 | // check if the key pressed is ctrl/cmd + x (cut) 112 | if ((e?.ctrlKey || e?.metaKey) && e.keyCode === 88) { 113 | handleCopy(canvas); 114 | handleDelete(canvas, deleteShapeFromStorage); 115 | } 116 | 117 | // check if the key pressed is ctrl/cmd + z (undo) 118 | if ((e?.ctrlKey || e?.metaKey) && e.keyCode === 90) { 119 | undo(); 120 | } 121 | 122 | // check if the key pressed is ctrl/cmd + y (redo) 123 | if ((e?.ctrlKey || e?.metaKey) && e.keyCode === 89) { 124 | redo(); 125 | } 126 | 127 | if (e.keyCode === 191 && !e.shiftKey) { 128 | e.preventDefault(); 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /lib/shapes.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | import { 5 | CustomFabricObject, 6 | ElementDirection, 7 | ImageUpload, 8 | ModifyShape, 9 | } from "@/types/type"; 10 | 11 | export const createRectangle = (pointer: PointerEvent) => { 12 | const rect = new fabric.Rect({ 13 | left: pointer.x, 14 | top: pointer.y, 15 | width: 100, 16 | height: 100, 17 | fill: "#aabbcc", 18 | objectId: uuidv4(), 19 | } as CustomFabricObject); 20 | 21 | return rect; 22 | }; 23 | 24 | export const createTriangle = (pointer: PointerEvent) => { 25 | return new fabric.Triangle({ 26 | left: pointer.x, 27 | top: pointer.y, 28 | width: 100, 29 | height: 100, 30 | fill: "#aabbcc", 31 | objectId: uuidv4(), 32 | } as CustomFabricObject); 33 | }; 34 | 35 | export const createCircle = (pointer: PointerEvent) => { 36 | return new fabric.Circle({ 37 | left: pointer.x, 38 | top: pointer.y, 39 | radius: 100, 40 | fill: "#aabbcc", 41 | objectId: uuidv4(), 42 | } as any); 43 | }; 44 | 45 | export const createLine = (pointer: PointerEvent) => { 46 | return new fabric.Line( 47 | [pointer.x, pointer.y, pointer.x + 100, pointer.y + 100], 48 | { 49 | stroke: "#aabbcc", 50 | strokeWidth: 2, 51 | objectId: uuidv4(), 52 | } as CustomFabricObject 53 | ); 54 | }; 55 | 56 | export const createText = (pointer: PointerEvent, text: string) => { 57 | return new fabric.IText(text, { 58 | left: pointer.x, 59 | top: pointer.y, 60 | fill: "#aabbcc", 61 | fontFamily: "Helvetica", 62 | fontSize: 36, 63 | fontWeight: "400", 64 | objectId: uuidv4() 65 | } as fabric.ITextOptions); 66 | }; 67 | 68 | export const createSpecificShape = ( 69 | shapeType: string, 70 | pointer: PointerEvent 71 | ) => { 72 | switch (shapeType) { 73 | case "rectangle": 74 | return createRectangle(pointer); 75 | 76 | case "triangle": 77 | return createTriangle(pointer); 78 | 79 | case "circle": 80 | return createCircle(pointer); 81 | 82 | case "line": 83 | return createLine(pointer); 84 | 85 | case "text": 86 | return createText(pointer, "Tap to Type"); 87 | 88 | default: 89 | return null; 90 | } 91 | }; 92 | 93 | export const handleImageUpload = ({ 94 | file, 95 | canvas, 96 | shapeRef, 97 | syncShapeInStorage, 98 | }: ImageUpload) => { 99 | const reader = new FileReader(); 100 | 101 | reader.onload = () => { 102 | fabric.Image.fromURL(reader.result as string, (img) => { 103 | img.scaleToWidth(200); 104 | img.scaleToHeight(200); 105 | 106 | canvas.current.add(img); 107 | 108 | // @ts-ignore 109 | img.objectId = uuidv4(); 110 | 111 | shapeRef.current = img; 112 | 113 | syncShapeInStorage(img); 114 | canvas.current.requestRenderAll(); 115 | }); 116 | }; 117 | 118 | reader.readAsDataURL(file); 119 | }; 120 | 121 | export const createShape = ( 122 | canvas: fabric.Canvas, 123 | pointer: PointerEvent, 124 | shapeType: string 125 | ) => { 126 | if (shapeType === "freeform") { 127 | canvas.isDrawingMode = true; 128 | return null; 129 | } 130 | 131 | return createSpecificShape(shapeType, pointer); 132 | }; 133 | 134 | export const modifyShape = ({ 135 | canvas, 136 | property, 137 | value, 138 | activeObjectRef, 139 | syncShapeInStorage, 140 | }: ModifyShape) => { 141 | const selectedElement = canvas.getActiveObject(); 142 | 143 | if (!selectedElement || selectedElement?.type === "activeSelection") return; 144 | 145 | // if property is width or height, set the scale of the selected element 146 | if (property === "width") { 147 | selectedElement.set("scaleX", 1); 148 | selectedElement.set("width", value); 149 | } else if (property === "height") { 150 | selectedElement.set("scaleY", 1); 151 | selectedElement.set("height", value); 152 | } else { 153 | if (selectedElement[property as keyof object] === value) return; 154 | selectedElement.set(property as keyof object, value); 155 | } 156 | 157 | // set selectedElement to activeObjectRef 158 | activeObjectRef.current = selectedElement; 159 | 160 | syncShapeInStorage(selectedElement); 161 | }; 162 | 163 | export const bringElement = ({ 164 | canvas, 165 | direction, 166 | syncShapeInStorage, 167 | }: ElementDirection) => { 168 | if (!canvas) return; 169 | 170 | // get the selected element. If there is no selected element or there are more than one selected element, return 171 | const selectedElement = canvas.getActiveObject(); 172 | 173 | if (!selectedElement || selectedElement?.type === "activeSelection") return; 174 | 175 | // bring the selected element to the front 176 | if (direction === "front") { 177 | canvas.bringToFront(selectedElement); 178 | } else if (direction === "back") { 179 | canvas.sendToBack(selectedElement); 180 | } 181 | 182 | // canvas.renderAll(); 183 | syncShapeInStorage(selectedElement); 184 | 185 | // re-render all objects on the canvas 186 | }; -------------------------------------------------------------------------------- /lib/useMaxZIndex.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { useThreads } from "@/liveblocks.config"; 4 | 5 | // Returns the highest z-index of all threads 6 | export const useMaxZIndex = () => { 7 | // get all threads 8 | const { threads } = useThreads(); 9 | 10 | // calculate the max z-index 11 | return useMemo(() => { 12 | let max = 0; 13 | for (const thread of threads) { 14 | // @ts-ignore 15 | if (thread.metadata.zIndex > max) { 16 | // @ts-ignore 17 | max = thread.metadata.zIndex; 18 | } 19 | } 20 | return max; 21 | }, [threads]); 22 | }; 23 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import jsPDF from "jspdf"; 2 | import { twMerge } from "tailwind-merge"; 3 | import { type ClassValue, clsx } from "clsx"; 4 | 5 | const adjectives = [ 6 | "Happy", 7 | "Creative", 8 | "Energetic", 9 | "Lively", 10 | "Dynamic", 11 | "Radiant", 12 | "Joyful", 13 | "Vibrant", 14 | "Cheerful", 15 | "Sunny", 16 | "Sparkling", 17 | "Bright", 18 | "Shining", 19 | ]; 20 | 21 | const animals = [ 22 | "Dolphin", 23 | "Tiger", 24 | "Elephant", 25 | "Penguin", 26 | "Kangaroo", 27 | "Panther", 28 | "Lion", 29 | "Cheetah", 30 | "Giraffe", 31 | "Hippopotamus", 32 | "Monkey", 33 | "Panda", 34 | "Crocodile", 35 | ]; 36 | 37 | export function cn(...inputs: ClassValue[]) { 38 | return twMerge(clsx(inputs)); 39 | } 40 | 41 | export function generateRandomName(): string { 42 | const randomAdjective = 43 | adjectives[Math.floor(Math.random() * adjectives.length)]; 44 | const randomAnimal = animals[Math.floor(Math.random() * animals.length)]; 45 | 46 | return `${randomAdjective} ${randomAnimal}`; 47 | } 48 | 49 | export const getShapeInfo = (shapeType: string) => { 50 | switch (shapeType) { 51 | case "rect": 52 | return { 53 | icon: "/assets/rectangle.svg", 54 | name: "Rectangle", 55 | }; 56 | 57 | case "circle": 58 | return { 59 | icon: "/assets/circle.svg", 60 | name: "Circle", 61 | }; 62 | 63 | case "triangle": 64 | return { 65 | icon: "/assets/triangle.svg", 66 | name: "Triangle", 67 | }; 68 | 69 | case "line": 70 | return { 71 | icon: "/assets/line.svg", 72 | name: "Line", 73 | }; 74 | 75 | case "i-text": 76 | return { 77 | icon: "/assets/text.svg", 78 | name: "Text", 79 | }; 80 | 81 | case "image": 82 | return { 83 | icon: "/assets/image.svg", 84 | name: "Image", 85 | }; 86 | 87 | case "freeform": 88 | return { 89 | icon: "/assets/freeform.svg", 90 | name: "Free Drawing", 91 | }; 92 | 93 | default: 94 | return { 95 | icon: "/assets/rectangle.svg", 96 | name: shapeType, 97 | }; 98 | } 99 | }; 100 | 101 | export const exportToPdf = () => { 102 | const canvas = document.querySelector("canvas"); 103 | 104 | if (!canvas) return; 105 | 106 | // use jspdf 107 | const doc = new jsPDF({ 108 | orientation: "landscape", 109 | unit: "px", 110 | format: [canvas.width, canvas.height], 111 | }); 112 | 113 | // get the canvas data url 114 | const data = canvas.toDataURL(); 115 | 116 | // add the image to the pdf 117 | doc.addImage(data, "PNG", 0, 0, canvas.width, canvas.height); 118 | 119 | // download the pdf 120 | doc.save("canvas.pdf"); 121 | }; 122 | -------------------------------------------------------------------------------- /liveblocks.config.ts: -------------------------------------------------------------------------------- 1 | import { LiveMap, createClient } from "@liveblocks/client"; 2 | import { createRoomContext, createLiveblocksContext } from "@liveblocks/react"; 3 | 4 | const client = createClient({ 5 | publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!, 6 | async resolveUsers({ userIds }) { 7 | // Used only for Comments and Notifications. Return a list of user information 8 | // retrieved from `userIds`. This info is used in comments, mentions etc. 9 | 10 | // const usersData = await __fetchUsersFromDB__(userIds); 11 | // 12 | // return usersData.map((userData) => ({ 13 | // name: userData.name, 14 | // avatar: userData.avatar.src, 15 | // })); 16 | 17 | return []; 18 | }, 19 | async resolveMentionSuggestions({ text }) { 20 | // Used only for Comments. Return a list of userIds that match `text`. 21 | // These userIds are used to create a mention list when typing in the 22 | // composer. 23 | // 24 | // For example when you type "@jo", `text` will be `"jo"`, and 25 | // you should to return an array with John and Joanna's userIds: 26 | // ["john@example.com", "joanna@example.com"] 27 | 28 | // const users = await getUsers({ search: text }); 29 | // return users.map((user) => user.id); 30 | 31 | return []; 32 | }, 33 | async resolveRoomsInfo({ roomIds }) { 34 | // Used only for Comments and Notifications. Return a list of room information 35 | // retrieved from `roomIds`. 36 | 37 | // const roomsData = await __fetchRoomsFromDB__(roomIds); 38 | // 39 | // return roomsData.map((roomData) => ({ 40 | // name: roomData.name, 41 | // url: roomData.url, 42 | // })); 43 | 44 | return []; 45 | }, 46 | }); 47 | 48 | // Presence represents the properties that exist on every user in the Room 49 | // and that will automatically be kept in sync. Accessible through the 50 | // `user.presence` property. Must be JSON-serializable. 51 | type Presence = { 52 | // cursor: { x: number, y: number } | null, 53 | // ... 54 | }; 55 | 56 | // Optionally, Storage represents the shared document that persists in the 57 | // Room, even after all users leave. Fields under Storage typically are 58 | // LiveList, LiveMap, LiveObject instances, for which updates are 59 | // automatically persisted and synced to all connected clients. 60 | type Storage = { 61 | // author: LiveObject<{ firstName: string, lastName: string }>, 62 | // ... 63 | canvasObjects: LiveMap; 64 | }; 65 | 66 | // Optionally, UserMeta represents static/readonly metadata on each user, as 67 | // provided by your own custom auth back end (if used). Useful for data that 68 | // will not change during a session, like a user's name or avatar. 69 | type UserMeta = { 70 | // id?: string, // Accessible through `user.id` 71 | // info?: Json, // Accessible through `user.info` 72 | }; 73 | 74 | // Optionally, the type of custom events broadcast and listened to in this 75 | // room. Use a union for multiple events. Must be JSON-serializable. 76 | type RoomEvent = { 77 | // type: "NOTIFICATION", 78 | // ... 79 | }; 80 | 81 | // Optionally, when using Comments, ThreadMetadata represents metadata on 82 | // each thread. Can only contain booleans, strings, and numbers. 83 | export type ThreadMetadata = { 84 | resolved: boolean; 85 | zIndex: number; 86 | time?: number; 87 | x: number; 88 | y: number; 89 | }; 90 | 91 | // Room-level hooks, use inside `RoomProvider` 92 | export const { 93 | suspense: { 94 | RoomProvider, 95 | useRoom, 96 | useMyPresence, 97 | useUpdateMyPresence, 98 | useSelf, 99 | useOthers, 100 | useOthersMapped, 101 | useOthersListener, 102 | useOthersConnectionIds, 103 | useOther, 104 | useBroadcastEvent, 105 | useEventListener, 106 | useErrorListener, 107 | useStorage, 108 | useObject, 109 | useMap, 110 | useList, 111 | useBatch, 112 | useHistory, 113 | useUndo, 114 | useRedo, 115 | useCanUndo, 116 | useCanRedo, 117 | useMutation, 118 | useStatus, 119 | useLostConnectionListener, 120 | useThreads, 121 | useCreateThread, 122 | useEditThreadMetadata, 123 | useCreateComment, 124 | useEditComment, 125 | useDeleteComment, 126 | useAddReaction, 127 | useRemoveReaction, 128 | useThreadSubscription, 129 | useMarkThreadAsRead, 130 | useRoomNotificationSettings, 131 | useUpdateRoomNotificationSettings, 132 | 133 | // These hooks can be exported from either context 134 | // useUser, 135 | // useRoomInfo 136 | } 137 | } = createRoomContext(client); 138 | 139 | // Project-level hooks, use inside `LiveblocksProvider` 140 | export const { 141 | suspense: { 142 | LiveblocksProvider, 143 | useMarkInboxNotificationAsRead, 144 | useMarkAllInboxNotificationsAsRead, 145 | useInboxNotifications, 146 | useUnreadInboxNotificationsCount, 147 | 148 | // These hooks can be exported from either context 149 | useUser, 150 | useRoomInfo, 151 | } 152 | } = createLiveblocksContext(client); 153 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | webpack: (config) => { 4 | config.externals.push({ 5 | "utf-8-validate": "commonjs utf-8-validate", 6 | bufferutil: "commonjs bufferutil", 7 | canvas: "commonjs canvas", 8 | }); 9 | // config.infrastructureLogging = { debug: /PackFileCache/ }; 10 | return config; 11 | }, 12 | images: { 13 | remotePatterns: [ 14 | { 15 | protocol: "https", 16 | hostname: "liveblocks.io", 17 | port: "", 18 | }, 19 | ], 20 | }, 21 | typescript: { 22 | ignoreBuildErrors: true, 23 | }, 24 | }; 25 | 26 | export default nextConfig; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@liveblocks/client": "^1.10.0", 13 | "@liveblocks/react": "^1.10.0", 14 | "@liveblocks/react-comments": "^1.10.2", 15 | "@radix-ui/react-dropdown-menu": "^2.0.6", 16 | "@radix-ui/react-label": "^2.0.2", 17 | "@radix-ui/react-select": "^2.0.0", 18 | "@radix-ui/react-slot": "^1.0.2", 19 | "class-variance-authority": "^0.7.0", 20 | "clsx": "^2.1.0", 21 | "fabric": "^5.3.0", 22 | "jspdf": "^2.5.1", 23 | "lucide-react": "^0.341.0", 24 | "next": "14.1.0", 25 | "react": "^18", 26 | "react-dom": "^18", 27 | "tailwind-merge": "^2.2.1", 28 | "tailwindcss-animate": "^1.0.7", 29 | "uuid": "^9.0.1" 30 | }, 31 | "devDependencies": { 32 | "@types/fabric": "^5.3.7", 33 | "@types/node": "^20", 34 | "@types/react": "^18", 35 | "@types/react-dom": "^18", 36 | "@types/uuid": "^9.0.8", 37 | "autoprefixer": "^10.0.1", 38 | "eslint": "^8", 39 | "eslint-config-next": "14.1.0", 40 | "postcss": "^8", 41 | "tailwindcss": "^3.3.0", 42 | "typescript": "^5" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/assets/CursorSVG.tsx: -------------------------------------------------------------------------------- 1 | function CursorSVG({ color }: { color: string }) { 2 | return ( 3 | 12 | 16 | 17 | ); 18 | } 19 | 20 | export default CursorSVG; 21 | -------------------------------------------------------------------------------- /public/assets/align-bottom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/align-horizontal-center.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/align-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/align-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/align-top.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/align-vertical-center.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/assets/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/comments.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/assets/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elmurodvokhidov/FIGMA-CLONE/3bd1f0bc0d8bd543c474f2729267274163fb18cd/public/assets/favicon.ico -------------------------------------------------------------------------------- /public/assets/freeform.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/assets/front.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/assets/group.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/assets/hash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/assets/line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elmurodvokhidov/FIGMA-CLONE/3bd1f0bc0d8bd543c474f2729267274163fb18cd/public/assets/loader.gif -------------------------------------------------------------------------------- /public/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/assets/polygon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/assets/rectangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/reset.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/assets/text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/assets/triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/ungroup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{ts,tsx}", 7 | "./components/**/*.{ts,tsx}", 8 | "./app/**/*.{ts,tsx}", 9 | "./src/**/*.{ts,tsx}", 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | primary: { 23 | black: "#14181F", 24 | green: "#56FFA6", 25 | grey: { 26 | 100: "#2B303B", 27 | 200: "#202731", 28 | 300: "#C4D3ED", 29 | }, 30 | }, 31 | }, 32 | keyframes: { 33 | "accordion-down": { 34 | from: { height: "0" }, 35 | to: { height: "var(--radix-accordion-content-height)" }, 36 | }, 37 | "accordion-up": { 38 | from: { height: "var(--radix-accordion-content-height)" }, 39 | to: { height: "0" }, 40 | }, 41 | }, 42 | animation: { 43 | "accordion-down": "accordion-down 0.2s ease-out", 44 | "accordion-up": "accordion-up 0.2s ease-out", 45 | }, 46 | }, 47 | }, 48 | plugins: [require("tailwindcss-animate")], 49 | } satisfies Config; 50 | 51 | export default config; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | }, 24 | "target": "es2018", 25 | "typeRoots": ["./types"] 26 | }, 27 | "include": [ 28 | "next-env.d.ts", 29 | "**/*.ts", 30 | "**/*.tsx", 31 | ".next/types/**/*.ts", 32 | ".next/types/**/*.tsx", 33 | "./types/**/*.d.ts" 34 | ], 35 | "exclude": ["node_modules"] 36 | } -------------------------------------------------------------------------------- /types/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.css" { 2 | const classes: { [key: string]: string }; 3 | export default classes; 4 | } 5 | -------------------------------------------------------------------------------- /types/type.ts: -------------------------------------------------------------------------------- 1 | import { BaseUserMeta, User } from "@liveblocks/client"; 2 | import { Gradient, Pattern } from "fabric/fabric-impl"; 3 | 4 | export enum CursorMode { 5 | Hidden, 6 | Chat, 7 | ReactionSelector, 8 | Reaction, 9 | } 10 | 11 | export type CursorState = 12 | | { 13 | mode: CursorMode.Hidden; 14 | } 15 | | { 16 | mode: CursorMode.Chat; 17 | message: string; 18 | previousMessage: string | null; 19 | } 20 | | { 21 | mode: CursorMode.ReactionSelector; 22 | } 23 | | { 24 | mode: CursorMode.Reaction; 25 | reaction: string; 26 | isPressed: boolean; 27 | }; 28 | 29 | export type Reaction = { 30 | value: string; 31 | timestamp: number; 32 | point: { x: number; y: number }; 33 | }; 34 | 35 | export type ReactionEvent = { 36 | x: number; 37 | y: number; 38 | value: string; 39 | }; 40 | 41 | export type ShapeData = { 42 | type: string; 43 | width: number; 44 | height: number; 45 | fill: string | Pattern | Gradient; 46 | left: number; 47 | top: number; 48 | objectId: string | undefined; 49 | }; 50 | 51 | export type Attributes = { 52 | width: string; 53 | height: string; 54 | fontSize: string; 55 | fontFamily: string; 56 | fontWeight: string; 57 | fill: string; 58 | stroke: string; 59 | }; 60 | 61 | export type ActiveElement = { 62 | name: string; 63 | value: string; 64 | icon: string; 65 | } | null; 66 | 67 | export interface CustomFabricObject 68 | extends fabric.Object { 69 | objectId?: string; 70 | } 71 | 72 | export type ModifyShape = { 73 | canvas: fabric.Canvas; 74 | property: string; 75 | value: any; 76 | activeObjectRef: React.MutableRefObject; 77 | syncShapeInStorage: (shape: fabric.Object) => void; 78 | }; 79 | 80 | export type ElementDirection = { 81 | canvas: fabric.Canvas; 82 | direction: string; 83 | syncShapeInStorage: (shape: fabric.Object) => void; 84 | }; 85 | 86 | export type ImageUpload = { 87 | file: File; 88 | canvas: React.MutableRefObject; 89 | shapeRef: React.MutableRefObject; 90 | syncShapeInStorage: (shape: fabric.Object) => void; 91 | }; 92 | 93 | export type RightSidebarProps = { 94 | elementAttributes: Attributes; 95 | setElementAttributes: React.Dispatch>; 96 | fabricRef: React.RefObject; 97 | activeObjectRef: React.RefObject; 98 | isEditingRef: React.MutableRefObject; 99 | syncShapeInStorage: (obj: any) => void; 100 | }; 101 | 102 | export type NavbarProps = { 103 | activeElement: ActiveElement; 104 | imageInputRef: React.MutableRefObject; 105 | handleImageUpload: (e: React.ChangeEvent) => void; 106 | handleActiveElement: (element: ActiveElement) => void; 107 | }; 108 | 109 | export type ShapesMenuProps = { 110 | item: { 111 | name: string; 112 | icon: string; 113 | value: Array; 114 | }; 115 | activeElement: any; 116 | handleActiveElement: any; 117 | handleImageUpload: any; 118 | imageInputRef: any; 119 | }; 120 | 121 | export type Presence = any; 122 | 123 | export type LiveCursorProps = { 124 | others: readonly User[]; 125 | }; 126 | 127 | export type CanvasMouseDown = { 128 | options: fabric.IEvent; 129 | canvas: fabric.Canvas; 130 | selectedShapeRef: any; 131 | isDrawing: React.MutableRefObject; 132 | shapeRef: React.MutableRefObject; 133 | }; 134 | 135 | export type CanvasMouseMove = { 136 | options: fabric.IEvent; 137 | canvas: fabric.Canvas; 138 | isDrawing: React.MutableRefObject; 139 | selectedShapeRef: any; 140 | shapeRef: any; 141 | syncShapeInStorage: (shape: fabric.Object) => void; 142 | }; 143 | 144 | export type CanvasMouseUp = { 145 | canvas: fabric.Canvas; 146 | isDrawing: React.MutableRefObject; 147 | shapeRef: any; 148 | activeObjectRef: React.MutableRefObject; 149 | selectedShapeRef: any; 150 | syncShapeInStorage: (shape: fabric.Object) => void; 151 | setActiveElement: any; 152 | }; 153 | 154 | export type CanvasObjectModified = { 155 | options: fabric.IEvent; 156 | syncShapeInStorage: (shape: fabric.Object) => void; 157 | }; 158 | 159 | export type CanvasPathCreated = { 160 | options: (fabric.IEvent & { path: CustomFabricObject }) | any; 161 | syncShapeInStorage: (shape: fabric.Object) => void; 162 | }; 163 | 164 | export type CanvasSelectionCreated = { 165 | options: fabric.IEvent; 166 | isEditingRef: React.MutableRefObject; 167 | setElementAttributes: React.Dispatch>; 168 | }; 169 | 170 | export type CanvasObjectScaling = { 171 | options: fabric.IEvent; 172 | setElementAttributes: React.Dispatch>; 173 | }; 174 | 175 | export type RenderCanvas = { 176 | fabricRef: React.MutableRefObject; 177 | canvasObjects: any; 178 | activeObjectRef: any; 179 | }; 180 | 181 | export type CursorChatProps = { 182 | cursor: { x: number; y: number }; 183 | cursorState: CursorState; 184 | setCursorState: (cursorState: CursorState) => void; 185 | updateMyPresence: ( 186 | presence: Partial<{ 187 | cursor: { x: number; y: number }; 188 | cursorColor: string; 189 | message: string; 190 | }> 191 | ) => void; 192 | }; 193 | --------------------------------------------------------------------------------