├── bun.lockb ├── favicon.ico ├── app ├── icon1.png ├── icon2.png ├── favicon.ico ├── apple-icon.png ├── page.tsx ├── room.tsx ├── layout.tsx ├── globals.css └── app.tsx ├── public ├── loader.gif ├── line.svg ├── hash.svg ├── delete.svg ├── rectangle.svg ├── circle.svg ├── polygon.svg ├── select.svg ├── text.svg ├── cursor-svg.tsx ├── reset.svg ├── back.svg ├── front.svg ├── comments.svg ├── image.svg ├── triangle.svg ├── github.svg ├── group.svg ├── ungroup.svg ├── align-top.svg ├── align-bottom.svg ├── align-vertical-center.svg ├── align-left.svg ├── align-right.svg ├── align-horizontal-center.svg ├── logo.svg └── freeform.svg ├── .github ├── images │ ├── img1.png │ ├── img2.png │ ├── img3.png │ ├── img_main.png │ └── stats.svg ├── FUNDING.yml ├── dependabot.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── types ├── declaration.d.ts └── type.ts ├── .eslintrc.json ├── postcss.config.mjs ├── .prettierrc.json ├── .env.example ├── environment.d.ts ├── components ├── comments │ ├── comments.tsx │ ├── pinned-composer.tsx │ ├── new-thread-cursor.tsx │ ├── comments-overlay.tsx │ ├── pinned-thread.tsx │ └── new-thread.tsx ├── loader.tsx ├── settings │ ├── export.tsx │ ├── color.tsx │ ├── dimensions.tsx │ └── text.tsx ├── cursor │ ├── live-cursors.tsx │ ├── cursor.tsx │ └── cursor-chat.tsx ├── users │ ├── avatar.tsx │ ├── index.module.css │ └── active-users.tsx ├── ui │ ├── label.tsx │ ├── input.tsx │ ├── button.tsx │ ├── select.tsx │ ├── context-menu.tsx │ └── dropdown-menu.tsx ├── reaction │ ├── flying-reaction.tsx │ ├── reaction-button.tsx │ └── index.module.css ├── left-sidebar.tsx ├── right-sidebar.tsx ├── shapes-menu.tsx ├── navbar.tsx └── live.tsx ├── components.json ├── next.config.mjs ├── .gitignore ├── lib ├── use-max-zindex.ts ├── utils.ts ├── key-events.ts ├── shapes.ts └── canvas.ts ├── SECURITY.md ├── tsconfig.json ├── hooks └── use-interval.ts ├── LICENSE ├── config └── index.ts ├── tailwind.config.ts ├── liveblocks.config.ts ├── package.json ├── constants └── index.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── README.md /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/bun.lockb -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/favicon.ico -------------------------------------------------------------------------------- /app/icon1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/app/icon1.png -------------------------------------------------------------------------------- /app/icon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/app/icon2.png -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/app/apple-icon.png -------------------------------------------------------------------------------- /public/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/public/loader.gif -------------------------------------------------------------------------------- /.github/images/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/.github/images/img1.png -------------------------------------------------------------------------------- /.github/images/img2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/.github/images/img2.png -------------------------------------------------------------------------------- /.github/images/img3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/.github/images/img3.png -------------------------------------------------------------------------------- /.github/images/img_main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/.github/images/img_main.png -------------------------------------------------------------------------------- /types/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.css" { 2 | const classes: { [key: string]: string }; 3 | export default classes; 4 | } 5 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | 3 | const App = dynamic(() => import("./app"), { ssr: false }); 4 | 5 | export default App; 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [sanidhyy] 4 | patreon: sanidhy 5 | custom: https://www.buymeacoffee.com/sanidhy 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "plugins": ["unused-imports"], 4 | "rules": { 5 | "unused-imports/no-unused-imports": 2 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": true, 4 | "tabWidth": 2, 5 | "singleQuote": false, 6 | "jsxSingleQuote": false, 7 | "plugins": ["prettier-plugin-tailwindcss"] 8 | } 9 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # .env.local 2 | 3 | # disable next.js telemetry 4 | NEXT_TELEMETRY_DISABLED=1 5 | 6 | # liveblocks api key 7 | NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_dev_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 8 | -------------------------------------------------------------------------------- /public/line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/hash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /environment.d.ts: -------------------------------------------------------------------------------- 1 | // This file is needed to support autocomplete for process.env 2 | export {}; 3 | 4 | declare global { 5 | namespace NodeJS { 6 | interface ProcessEnv { 7 | // liveblocks api key 8 | NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY: string; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /components/comments/comments.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ClientSideSuspense } from "@liveblocks/react"; 4 | 5 | import { CommentsOverlay } from "@/components/comments/comments-overlay"; 6 | 7 | export const Comments = () => ( 8 | 9 | {() => } 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /public/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/rectangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 | } 18 | -------------------------------------------------------------------------------- /public/polygon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /components/loader.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export const Loader = () => ( 4 |
5 | loader 13 |

Loading...

14 |
15 | ); 16 | -------------------------------------------------------------------------------- /public/select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /components/settings/export.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { exportToPdf } from "@/lib/utils"; 3 | 4 | export const Export = () => ( 5 |
6 |

Export

7 | 14 |
15 | ); 16 | -------------------------------------------------------------------------------- /public/text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/cursor-svg.tsx: -------------------------------------------------------------------------------- 1 | const CursorSVG = ({ color }: { color: string }) => { 2 | return ( 3 | 12 | 16 | 17 | ); 18 | }; 19 | 20 | export default CursorSVG; 21 | -------------------------------------------------------------------------------- /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 | 10 | return config; 11 | }, 12 | images: { 13 | remotePatterns: [ 14 | { 15 | protocol: "https", 16 | hostname: "liveblocks.io", 17 | port: "", 18 | }, 19 | ], 20 | }, 21 | }; 22 | 23 | export default nextConfig; 24 | -------------------------------------------------------------------------------- /public/reset.svg: -------------------------------------------------------------------------------- 1 | 2 | 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 | 38 | # Local Netlify folder 39 | .netlify 40 | -------------------------------------------------------------------------------- /lib/use-max-zindex.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 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bun" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | versioning-strategy: increase 13 | -------------------------------------------------------------------------------- /components/cursor/live-cursors.tsx: -------------------------------------------------------------------------------- 1 | import { COLORS } from "@/constants"; 2 | import { useOthers } from "@/liveblocks.config"; 3 | 4 | import { Cursor } from "./cursor"; 5 | 6 | export const LiveCursors = () => { 7 | const others = useOthers(); 8 | 9 | return others.map(({ connectionId, presence }) => { 10 | if (!presence || !presence?.cursor) return; 11 | 12 | return ( 13 | 20 | ); 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /public/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /public/front.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /components/users/avatar.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | import styles from "./index.module.css"; 6 | 7 | type AvatarProps = { name: string; otherStyles: string }; 8 | 9 | export const Avatar = ({ name, otherStyles }: AvatarProps) => { 10 | return ( 11 |
15 | {`${name}'s 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /hooks/use-interval.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 const 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 | -------------------------------------------------------------------------------- /public/comments.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/room.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LiveMap } from "@liveblocks/client"; 4 | import { ClientSideSuspense } from "@liveblocks/react"; 5 | import type { PropsWithChildren } from "react"; 6 | 7 | import { Loader } from "@/components/loader"; 8 | import { RoomProvider } from "@/liveblocks.config"; 9 | 10 | export const Room = ({ children }: PropsWithChildren) => { 11 | return ( 12 | 22 | }> 23 | {() => children} 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /public/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /components/users/index.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 | } 38 | -------------------------------------------------------------------------------- /public/triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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/reaction/flying-reaction.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import styles from "./index.module.css"; 3 | 4 | type FlyingReactionProps = { 5 | x: number; 6 | y: number; 7 | timestamp: number; 8 | value: string; 9 | }; 10 | 11 | export const FlyingReaction = ({ 12 | x, 13 | y, 14 | timestamp, 15 | value, 16 | }: FlyingReactionProps) => { 17 | return ( 18 |
27 |
28 |
29 | {value} 30 |
31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /components/cursor/cursor.tsx: -------------------------------------------------------------------------------- 1 | import CursorSVG from "@/public/cursor-svg"; 2 | 3 | type CursorProps = { 4 | color: string; 5 | x: number; 6 | y: number; 7 | message: string; 8 | }; 9 | 10 | export const Cursor = ({ color, x, y, message }: CursorProps) => { 11 | return ( 12 |
18 | 19 | 20 | {message && ( 21 |
27 |

28 | {message} 29 |

30 |
31 | )} 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata, Viewport } from "next"; 2 | import { Work_Sans } from "next/font/google"; 3 | import type { PropsWithChildren } from "react"; 4 | 5 | import { siteConfig } from "@/config"; 6 | import { cn } from "@/lib/utils"; 7 | 8 | import { Room } from "./room"; 9 | 10 | import "./globals.css"; 11 | 12 | const workSans = Work_Sans({ 13 | subsets: ["latin"], 14 | variable: "--font-work-sans", 15 | weight: ["400", "600", "700"], 16 | }); 17 | 18 | export const viewport: Viewport = { 19 | themeColor: "#14181F", 20 | }; 21 | 22 | export const metadata: Metadata = siteConfig; 23 | 24 | const RootLayout = ({ children }: Readonly) => { 25 | return ( 26 | 27 | 28 | {children} 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default RootLayout; 35 | -------------------------------------------------------------------------------- /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::-webkit-scrollbar { 14 | width: 0; 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 | } 34 | -------------------------------------------------------------------------------- /public/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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/settings/color.tsx: -------------------------------------------------------------------------------- 1 | import { Label } from "@/components/ui/label"; 2 | 3 | type ColorProps = { 4 | inputRef: any; 5 | attribute: string; 6 | placeholder: string; 7 | attributeType: string; 8 | handleInputChange: (property: string, value: string) => void; 9 | }; 10 | 11 | export const Color = ({ 12 | inputRef, 13 | attribute, 14 | placeholder, 15 | attributeType, 16 | handleInputChange, 17 | }: ColorProps) => ( 18 |
19 |

{placeholder}

20 |
inputRef.current.click()} 23 | > 24 | handleInputChange(attributeType, e.target.value)} 29 | /> 30 | 31 |
32 |
33 | ); 34 | -------------------------------------------------------------------------------- /public/group.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /public/ungroup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sanidhya Kumar Verma 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/index.ts: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | export const siteConfig: Metadata = { 4 | title: "Figma Clone", 5 | description: 6 | "A minimalist Figma clone using Fabric.js and Liveblocks for real-time collaboration.", 7 | keywords: [ 8 | "reactjs", 9 | "nextjs", 10 | "vercel", 11 | "react", 12 | "liveblocks", 13 | "livekit", 14 | "shadcn", 15 | "shadcn-ui", 16 | "radix-ui", 17 | "cn", 18 | "clsx", 19 | "figma-clone", 20 | "realtime-drawing", 21 | "live-chat", 22 | "live-reactions", 23 | "live-updates", 24 | "sonner", 25 | "zustand", 26 | "zod", 27 | "lucide-react", 28 | "next-themes", 29 | "postcss", 30 | "prettier", 31 | "react-dom", 32 | "tailwindcss", 33 | "tailwindcss-animate", 34 | "ui/ux", 35 | "js", 36 | "javascript", 37 | "typescript", 38 | "eslint", 39 | "html", 40 | "css", 41 | ] as Array, 42 | authors: { 43 | name: "Sanidhya Kumar Verma", 44 | url: "https://github.com/sanidhyy", 45 | }, 46 | } as const; 47 | 48 | export const links = { 49 | sourceCode: "https://github.com/sanidhyy/figma-clone", 50 | } as const; 51 | -------------------------------------------------------------------------------- /components/reaction/reaction-button.tsx: -------------------------------------------------------------------------------- 1 | type ReactionSelectorProps = { 2 | setReaction: (reaction: string) => void; 3 | }; 4 | 5 | export const ReactionSelector = ({ setReaction }: ReactionSelectorProps) => { 6 | return ( 7 |
e.stopPropagation()} 10 | > 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | ); 19 | }; 20 | 21 | const ReactionButton = ({ 22 | reaction, 23 | onSelect, 24 | }: { 25 | reaction: string; 26 | onSelect: (reaction: string) => void; 27 | }) => { 28 | return ( 29 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /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; 52 | -------------------------------------------------------------------------------- /public/align-top.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/align-bottom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/align-vertical-center.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/align-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /components/users/active-users.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { generateRandomName } from "@/lib/utils"; 4 | import { useOthers, useSelf } from "@/liveblocks.config"; 5 | 6 | import { Avatar } from "./avatar"; 7 | 8 | export const ActiveUsers = () => { 9 | const users = useOthers(); 10 | const currentUser = useSelf(); 11 | const hasMoreUsers = users.length > 3; 12 | 13 | const memorizedUsers = useMemo( 14 | () => ( 15 |
16 |
17 | {currentUser && ( 18 | 22 | )} 23 | 24 | {users.slice(0, 3).map(({ connectionId }) => { 25 | return ( 26 | 31 | ); 32 | })} 33 | 34 | {hasMoreUsers && ( 35 |
36 | +{users.length - 3} 37 |
38 | )} 39 |
40 |
41 | ), 42 | // eslint-disable-next-line react-hooks/exhaustive-deps 43 | [users.length] 44 | ); 45 | 46 | return memorizedUsers; 47 | }; 48 | -------------------------------------------------------------------------------- /public/align-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/align-horizontal-center.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /components/settings/dimensions.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@/components/ui/input"; 2 | import { Label } from "@/components/ui/label"; 3 | 4 | const dimensionsOptions = [ 5 | { label: "W", property: "width" }, 6 | { label: "H", property: "height" }, 7 | ]; 8 | 9 | type DimensionsProps = { 10 | width: string; 11 | height: string; 12 | isEditingRef: React.MutableRefObject; 13 | handleInputChange: (property: string, value: string) => void; 14 | }; 15 | 16 | export const Dimensions = ({ 17 | width, 18 | height, 19 | isEditingRef, 20 | handleInputChange, 21 | }: DimensionsProps) => ( 22 |
23 |
24 | {dimensionsOptions.map((item) => ( 25 |
29 | 32 | handleInputChange(item.property, e.target.value)} 40 | onBlur={(e) => { 41 | isEditingRef.current = false; 42 | }} 43 | /> 44 |
45 | ))} 46 |
47 |
48 | ); 49 | -------------------------------------------------------------------------------- /components/comments/pinned-composer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Composer, ComposerProps } from "@liveblocks/react-comments"; 4 | import Image from "next/image"; 5 | 6 | type PinnedComposerProps = { 7 | onComposerSubmit: ComposerProps["onComposerSubmit"]; 8 | }; 9 | 10 | export const PinnedComposer = ({ 11 | onComposerSubmit, 12 | ...props 13 | }: PinnedComposerProps) => { 14 | return ( 15 |
16 |
17 | someone 26 |
27 |
28 | {/** 29 | * We're using the Composer component to create a new comment. 30 | * Liveblocks provides a Composer component that allows to 31 | * create/edit/delete comments. 32 | * 33 | * Composer: https://liveblocks.io/docs/api-reference/liveblocks-react-comments#Composer 34 | */} 35 | { 39 | e.stopPropagation(); 40 | }} 41 | /> 42 |
43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /components/left-sidebar.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 | export 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 |

14 | Layers 15 |

16 |
17 | {allShapes?.map((shape: any) => { 18 | const info = getShapeInfo(shape[1]?.type); 19 | 20 | return ( 21 |
25 | Layer 32 |

33 | {info.name} 34 |

35 |
36 | ); 37 | })} 38 |
39 |
40 | ), 41 | [allShapes] 42 | ); 43 | 44 | return memoizedShapes; 45 | }; 46 | -------------------------------------------------------------------------------- /components/reaction/index.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 | } 103 | -------------------------------------------------------------------------------- /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/cursor/cursor-chat.tsx: -------------------------------------------------------------------------------- 1 | import CursorSVG from "@/public/cursor-svg"; 2 | import { CursorMode, type CursorChatProps } from "@/types/type"; 3 | 4 | export const CursorChat = ({ 5 | cursor, 6 | cursorState, 7 | setCursorState, 8 | updateMyPresence, 9 | }: CursorChatProps) => { 10 | const handleChange = (e: React.ChangeEvent) => { 11 | updateMyPresence({ message: e.target.value }); 12 | setCursorState({ 13 | mode: CursorMode.Chat, 14 | previousMessage: null, 15 | message: e.target.value, 16 | }); 17 | }; 18 | const handleKeyDown = (e: React.KeyboardEvent) => { 19 | if (e.key === "Enter") { 20 | if (cursorState.mode === CursorMode.Chat) { 21 | setCursorState({ 22 | mode: CursorMode.Chat, 23 | previousMessage: cursorState.message, 24 | message: "", 25 | }); 26 | } 27 | } 28 | 29 | if (e.key === "Escape") { 30 | setCursorState({ 31 | mode: CursorMode.Hidden, 32 | }); 33 | } 34 | }; 35 | 36 | return ( 37 |
e.stopPropagation()} 43 | > 44 | {cursorState.mode === CursorMode.Chat && ( 45 | <> 46 | 47 | 48 |
49 | {cursorState.previousMessage && ( 50 |
{cursorState.previousMessage}
51 | )} 52 | 53 | 64 |
65 | 66 | )} 67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /liveblocks.config.ts: -------------------------------------------------------------------------------- 1 | import { type LiveMap, createClient } from "@liveblocks/client"; 2 | import { createRoomContext } from "@liveblocks/react"; 3 | 4 | import { ReactionEvent } from "@/types/type"; 5 | 6 | const client = createClient({ 7 | throttle: 16, 8 | publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!, 9 | }); 10 | 11 | // Presence represents the properties that exist on every user in the Room 12 | // and that will automatically be kept in sync. Accessible through the 13 | // `user.presence` property. Must be JSON-serializable. 14 | export type Presence = { 15 | cursor: { x: number; y: number } | null; 16 | message: string | null; 17 | }; 18 | 19 | // Optionally, Storage represents the shared document that persists in the 20 | // Room, even after all users leave. Fields under Storage typically are 21 | // LiveList, LiveMap, LiveObject instances, for which updates are 22 | // automatically persisted and synced to all connected clients. 23 | type Storage = { 24 | // author: LiveObject<{ firstName: string, lastName: string }>, 25 | // ... 26 | canvasObjects: LiveMap; 27 | }; 28 | 29 | // Optionally, the type of custom events broadcast and listened to in this 30 | // room. Use a union for multiple events. Must be JSON-serializable. 31 | type RoomEvent = ReactionEvent; 32 | 33 | // Optionally, when using Comments, ThreadMetadata represents metadata on 34 | // each thread. Can only contain booleans, strings, and numbers. 35 | export type ThreadMetadata = { 36 | resolved: boolean; 37 | zIndex: number; 38 | time?: number; 39 | x: number; 40 | y: number; 41 | }; 42 | 43 | export const { 44 | suspense: { 45 | RoomProvider, 46 | useRoom, 47 | useMyPresence, 48 | useUpdateMyPresence, 49 | useSelf, 50 | useOthers, 51 | useOthersMapped, 52 | useOthersConnectionIds, 53 | useOther, 54 | useBroadcastEvent, 55 | useEventListener, 56 | useErrorListener, 57 | useStorage, 58 | useBatch, 59 | useHistory, 60 | useUndo, 61 | useRedo, 62 | useCanUndo, 63 | useCanRedo, 64 | useMutation, 65 | useStatus, 66 | useLostConnectionListener, 67 | useThreads, 68 | useUser, 69 | useCreateThread, 70 | useEditThreadMetadata, 71 | useCreateComment, 72 | useEditComment, 73 | useDeleteComment, 74 | useAddReaction, 75 | useRemoveReaction, 76 | }, 77 | } = createRoomContext( 78 | client, 79 | {} 80 | ); 81 | -------------------------------------------------------------------------------- /components/right-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import { useRef } from "react"; 3 | 4 | import { Color } from "@/components/settings/color"; 5 | import { Dimensions } from "@/components/settings/dimensions"; 6 | import { Export } from "@/components/settings/export"; 7 | import { Text } from "@/components/settings/text"; 8 | import { modifyShape } from "@/lib/shapes"; 9 | import type { RightSidebarProps } from "@/types/type"; 10 | 11 | export const RightSidebar = ({ 12 | activeObjectRef, 13 | elementAttributes, 14 | fabricRef, 15 | isEditingRef, 16 | setElementAttributes, 17 | syncShapeInStorage, 18 | }: RightSidebarProps) => { 19 | const colorInputRef = useRef(null); 20 | const strokeInputRef = useRef(null); 21 | const handleInputChange = (property: string, value: string) => { 22 | if (!isEditingRef?.current) isEditingRef.current = true; 23 | 24 | setElementAttributes((prevAttributes) => ({ 25 | ...prevAttributes, 26 | [property]: value, 27 | })); 28 | 29 | modifyShape({ 30 | canvas: fabricRef.current as fabric.Canvas, 31 | property, 32 | value, 33 | activeObjectRef, 34 | syncShapeInStorage, 35 | }); 36 | }; 37 | 38 | return ( 39 |
40 |

Design

41 | 42 | 43 | Make changes to canvas as you like 44 | 45 | 46 | 52 | 53 | 59 | 60 | 67 | 74 | 75 | 76 |
77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /components/comments/new-thread-cursor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Portal from "@radix-ui/react-portal"; 4 | import { useEffect, useState } from "react"; 5 | 6 | const DEFAULT_CURSOR_POSITION = -10000; 7 | 8 | // display a custom cursor when placing a new thread 9 | export 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 | -------------------------------------------------------------------------------- /components/comments/comments-overlay.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThreadData } from "@liveblocks/client"; 4 | import { useCallback, useRef } from "react"; 5 | 6 | import { useMaxZIndex } from "@/lib/use-max-zindex"; 7 | import { 8 | ThreadMetadata, 9 | useEditThreadMetadata, 10 | useThreads, 11 | } from "@/liveblocks.config"; 12 | 13 | import { PinnedThread } from "./pinned-thread"; 14 | 15 | type OverlayThreadProps = { 16 | thread: ThreadData; 17 | maxZIndex: number; 18 | }; 19 | 20 | export const CommentsOverlay = () => { 21 | /** 22 | * We're using the useThreads hook to get the list of threads 23 | * in the room. 24 | * 25 | * useThreads: https://liveblocks.io/docs/api-reference/liveblocks-react#useThreads 26 | */ 27 | const { threads } = useThreads(); 28 | 29 | // get the max z-index of a thread 30 | const maxZIndex = useMaxZIndex(); 31 | 32 | return ( 33 |
34 | {threads 35 | .filter((thread) => !thread.metadata.resolved) 36 | .map((thread) => ( 37 | 42 | ))} 43 |
44 | ); 45 | }; 46 | 47 | const OverlayThread = ({ thread, maxZIndex }: OverlayThreadProps) => { 48 | /** 49 | * We're using the useEditThreadMetadata hook to edit the metadata 50 | * of a thread. 51 | * 52 | * useEditThreadMetadata: https://liveblocks.io/docs/api-reference/liveblocks-react#useEditThreadMetadata 53 | */ 54 | const editThreadMetadata = useEditThreadMetadata(); 55 | 56 | /** 57 | * We're using the useUser hook to get the user of the thread. 58 | * 59 | * useUser: https://liveblocks.io/docs/api-reference/liveblocks-react#useUser 60 | */ 61 | 62 | // We're using a ref to get the thread element to position it 63 | const threadRef = useRef(null); 64 | 65 | // If other thread(s) above, increase z-index on last element updated 66 | const handleIncreaseZIndex = useCallback(() => { 67 | if (maxZIndex === thread.metadata.zIndex) { 68 | return; 69 | } 70 | 71 | // Update the z-index of the thread in the room 72 | editThreadMetadata({ 73 | threadId: thread.id, 74 | metadata: { 75 | zIndex: maxZIndex + 1, 76 | }, 77 | }); 78 | }, [thread, editThreadMetadata, maxZIndex]); 79 | 80 | return ( 81 |
89 | {/* render the thread */} 90 | 91 |
92 | ); 93 | }; 94 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import jsPDF from "jspdf"; 3 | import { twMerge } from "tailwind-merge"; 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: "/rectangle.svg", 54 | name: "Rectangle", 55 | }; 56 | 57 | case "circle": 58 | return { 59 | icon: "/circle.svg", 60 | name: "Circle", 61 | }; 62 | 63 | case "triangle": 64 | return { 65 | icon: "/triangle.svg", 66 | name: "Triangle", 67 | }; 68 | 69 | case "line": 70 | return { 71 | icon: "/line.svg", 72 | name: "Line", 73 | }; 74 | 75 | case "i-text": 76 | return { 77 | icon: "/text.svg", 78 | name: "Text", 79 | }; 80 | 81 | case "image": 82 | return { 83 | icon: "/image.svg", 84 | name: "Image", 85 | }; 86 | 87 | case "freeform": 88 | return { 89 | icon: "/freeform.svg", 90 | name: "Free Drawing", 91 | }; 92 | 93 | default: 94 | return { 95 | icon: "/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 | -------------------------------------------------------------------------------- /components/comments/pinned-thread.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThreadData } from "@liveblocks/client"; 4 | import { Thread } from "@liveblocks/react-comments"; 5 | import Image from "next/image"; 6 | import { useMemo, useState } from "react"; 7 | 8 | import { ThreadMetadata } from "@/liveblocks.config"; 9 | 10 | type PinnedThreadProps = { 11 | thread: ThreadData; 12 | onFocus: (threadId: string) => void; 13 | }; 14 | 15 | export const PinnedThread = ({ 16 | thread, 17 | onFocus, 18 | ...props 19 | }: PinnedThreadProps) => { 20 | // Open pinned threads that have just been created 21 | const startMinimized = useMemo( 22 | () => Number(new Date()) - Number(new Date(thread.createdAt)) > 100, 23 | [thread] 24 | ); 25 | 26 | const [minimized, setMinimized] = useState(startMinimized); 27 | 28 | /** 29 | * memoize the result of this function so that it doesn't change on every render but only when the thread changes 30 | * Memo is used to optimize performance and avoid unnecessary re-renders. 31 | * 32 | * useMemo: https://react.dev/reference/react/useMemo 33 | */ 34 | 35 | const memoizedContent = useMemo( 36 | () => ( 37 |
{ 41 | onFocus(thread.id); 42 | 43 | // check if click is on/in the composer 44 | if ( 45 | e.target && 46 | e.target.classList.contains("lb-icon") && 47 | e.target.classList.contains("lb-button-icon") 48 | ) { 49 | return; 50 | } 51 | 52 | setMinimized(!minimized); 53 | }} 54 | > 55 |
59 | Dummy Name 69 |
70 | {!minimized ? ( 71 |
72 | { 76 | e.stopPropagation(); 77 | }} 78 | /> 79 |
80 | ) : null} 81 |
82 | ), 83 | // eslint-disable-next-line react-hooks/exhaustive-deps 84 | [thread.comments.length, minimized] 85 | ); 86 | 87 | return <>{memoizedContent}; 88 | }; 89 | -------------------------------------------------------------------------------- /components/shapes-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu"; 11 | import type { ShapesMenuProps } from "@/types/type"; 12 | 13 | export const ShapesMenu = ({ 14 | item, 15 | activeElement, 16 | handleActiveElement, 17 | handleImageUpload, 18 | imageInputRef, 19 | }: ShapesMenuProps) => { 20 | const isDropdownElem = item.value.some( 21 | (elem) => elem?.value === activeElement.value 22 | ); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 40 | 41 | 42 | 43 | {item.value.map((elem) => ( 44 | 76 | ))} 77 | 78 | 79 | 80 | 87 | 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-clone", 3 | "version": "1.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "author": { 12 | "name": "Sanidhya Kumar Verma", 13 | "email": "sanidhya.verma12345@gmail.com", 14 | "url": "https://github.com/sanidhyy" 15 | }, 16 | "description": "A minimalist Figma clone using Fabric.js and Liveblocks for real-time collaboration.", 17 | "keywords": [ 18 | "reactjs", 19 | "nextjs", 20 | "vercel", 21 | "react", 22 | "liveblocks", 23 | "livekit", 24 | "shadcn", 25 | "shadcn-ui", 26 | "radix-ui", 27 | "cn", 28 | "clsx", 29 | "figma-clone", 30 | "realtime-drawing", 31 | "live-chat", 32 | "live-reactions", 33 | "live-updates", 34 | "sonner", 35 | "zustand", 36 | "zod", 37 | "lucide-react", 38 | "next-themes", 39 | "postcss", 40 | "prettier", 41 | "react-dom", 42 | "tailwindcss", 43 | "tailwindcss-animate", 44 | "ui/ux", 45 | "js", 46 | "javascript", 47 | "typescript", 48 | "eslint", 49 | "html", 50 | "css" 51 | ], 52 | "license": "MIT", 53 | "repository": { 54 | "type": "git", 55 | "url": "https://github.com/sanidhyy/figma-clone.git" 56 | }, 57 | "homepage": "https://github.com/sanidhyy/figma-clone#readme", 58 | "bugs": { 59 | "url": "https://github.com/sanidhyy/figma-clone/issues", 60 | "email": "sanidhya.verma12345@gmail.com" 61 | }, 62 | "funding": [ 63 | { 64 | "type": "patreon", 65 | "url": "https://www.patreon.com/sanidhy" 66 | }, 67 | { 68 | "type": "Buy me a coffee", 69 | "url": "https://www.buymeacoffee.com/sanidhy" 70 | } 71 | ], 72 | "dependencies": { 73 | "@liveblocks/client": "^1.12.0", 74 | "@liveblocks/react": "^1.12.0", 75 | "@liveblocks/react-comments": "^1.12.0", 76 | "@radix-ui/react-context-menu": "^2.2.16", 77 | "@radix-ui/react-dropdown-menu": "^2.1.16", 78 | "@radix-ui/react-label": "^2.1.7", 79 | "@radix-ui/react-select": "^2.2.5", 80 | "@radix-ui/react-slot": "^1.2.4", 81 | "class-variance-authority": "^0.7.1", 82 | "clsx": "^2.1.1", 83 | "fabric": "^5.5.2", 84 | "jspdf": "^3.0.3", 85 | "lucide-react": "^0.552.0", 86 | "react": "19.2.0", 87 | "react-dom": "19.2.0", 88 | "next": "14.2.35", 89 | "tailwind-merge": "^3.3.1", 90 | "tailwindcss-animate": "^1.0.7", 91 | "uuid": "^11.1.0" 92 | }, 93 | "devDependencies": { 94 | "@types/fabric": "^5.3.10", 95 | "@types/node": "^24", 96 | "@types/react": "19.2.2", 97 | "@types/react-dom": "19.2.3", 98 | "@types/uuid": "^11.0.0", 99 | "eslint": "9", 100 | "eslint-config-next": "16.0.6", 101 | "eslint-plugin-unused-imports": "^4.3.0", 102 | "postcss": "^8", 103 | "prettier": "^3.6.2", 104 | "prettier-plugin-tailwindcss": "^0.7.1", 105 | "tailwindcss": "^3.4.14", 106 | "typescript": "^5" 107 | }, 108 | "trustedDependencies": [ 109 | "core-js" 110 | ] 111 | } 112 | -------------------------------------------------------------------------------- /components/settings/text.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Select, 3 | SelectContent, 4 | SelectItem, 5 | SelectTrigger, 6 | SelectValue, 7 | } from "@/components/ui/select"; 8 | import { 9 | fontFamilyOptions, 10 | fontSizeOptions, 11 | fontWeightOptions, 12 | } from "@/constants"; 13 | 14 | const selectConfigs = [ 15 | { 16 | property: "fontFamily", 17 | placeholder: "Choose a font", 18 | options: fontFamilyOptions, 19 | }, 20 | { property: "fontSize", placeholder: "30", options: fontSizeOptions }, 21 | { 22 | property: "fontWeight", 23 | placeholder: "Semibold", 24 | options: fontWeightOptions, 25 | }, 26 | ]; 27 | 28 | type TextProps = { 29 | fontFamily: string; 30 | fontSize: string; 31 | fontWeight: string; 32 | handleInputChange: (property: string, value: string) => void; 33 | }; 34 | 35 | export const Text = ({ 36 | fontFamily, 37 | fontSize, 38 | fontWeight, 39 | handleInputChange, 40 | }: TextProps) => ( 41 |
42 |

Text

43 | 44 |
45 | {RenderSelect({ 46 | config: selectConfigs[0], 47 | fontSize, 48 | fontWeight, 49 | fontFamily, 50 | handleInputChange, 51 | })} 52 | 53 |
54 | {selectConfigs.slice(1).map((config) => 55 | RenderSelect({ 56 | config, 57 | fontSize, 58 | fontWeight, 59 | fontFamily, 60 | handleInputChange, 61 | }) 62 | )} 63 |
64 |
65 |
66 | ); 67 | 68 | type RenderSelectProps = { 69 | config: { 70 | property: string; 71 | placeholder: string; 72 | options: { label: string; value: string }[]; 73 | }; 74 | fontSize: string; 75 | fontWeight: string; 76 | fontFamily: string; 77 | handleInputChange: (property: string, value: string) => void; 78 | }; 79 | 80 | export const RenderSelect = ({ 81 | config, 82 | fontSize, 83 | fontWeight, 84 | fontFamily, 85 | handleInputChange, 86 | }: RenderSelectProps) => ( 87 | 121 | ); 122 | -------------------------------------------------------------------------------- /components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { memo } from "react"; 5 | 6 | import { NewThread } from "@/components/comments/new-thread"; 7 | import { Button } from "@/components/ui/button"; 8 | import { ActiveUsers } from "@/components/users/active-users"; 9 | import { navElements } from "@/constants"; 10 | import type { ActiveElement, NavbarProps } from "@/types/type"; 11 | 12 | import { ShapesMenu } from "./shapes-menu"; 13 | import Link from "next/link"; 14 | import { links } from "@/config"; 15 | 16 | export const Navbar = memo( 17 | ({ 18 | activeElement, 19 | imageInputRef, 20 | handleImageUpload, 21 | handleActiveElement, 22 | }: NavbarProps) => { 23 | const isActive = (value: string | Array) => 24 | (activeElement && activeElement.value === value) || 25 | (Array.isArray(value) && 26 | value.some((val) => val?.value === activeElement?.value)); 27 | 28 | return ( 29 | 103 | ); 104 | }, 105 | (prevProps, nextProps) => prevProps.activeElement === nextProps.activeElement 106 | ); 107 | 108 | Navbar.displayName = "Navbar"; 109 | -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | export const COLORS = ["#DC2626", "#D97706", "#059669", "#7C3AED", "#DB2777"]; 2 | 3 | export const shapeElements = [ 4 | { 5 | icon: "/rectangle.svg", 6 | name: "Rectangle", 7 | value: "rectangle", 8 | }, 9 | { 10 | icon: "/circle.svg", 11 | name: "Circle", 12 | value: "circle", 13 | }, 14 | { 15 | icon: "/triangle.svg", 16 | name: "Triangle", 17 | value: "triangle", 18 | }, 19 | { 20 | icon: "/line.svg", 21 | name: "Line", 22 | value: "line", 23 | }, 24 | { 25 | icon: "/image.svg", 26 | name: "Image", 27 | value: "image", 28 | }, 29 | { 30 | icon: "/freeform.svg", 31 | name: "Free Drawing", 32 | value: "freeform", 33 | }, 34 | ]; 35 | 36 | export const navElements = [ 37 | { 38 | icon: "/select.svg", 39 | name: "Select", 40 | value: "select", 41 | }, 42 | { 43 | icon: "/rectangle.svg", 44 | name: "Rectangle", 45 | value: shapeElements, 46 | }, 47 | { 48 | icon: "/text.svg", 49 | value: "text", 50 | name: "Text", 51 | }, 52 | { 53 | icon: "/delete.svg", 54 | value: "delete", 55 | name: "Delete", 56 | }, 57 | { 58 | icon: "/reset.svg", 59 | value: "reset", 60 | name: "Reset", 61 | }, 62 | { 63 | icon: "/comments.svg", 64 | value: "comments", 65 | name: "Comments", 66 | }, 67 | ]; 68 | 69 | export const defaultNavElement = { 70 | icon: "/select.svg", 71 | name: "Select", 72 | value: "select", 73 | }; 74 | 75 | export const directionOptions = [ 76 | { label: "Bring to Front", value: "front", icon: "/front.svg" }, 77 | { label: "Send to Back", value: "back", icon: "/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: "/align-left.svg" }, 163 | { 164 | value: "horizontalCenter", 165 | label: "Align Horizontal Center", 166 | icon: "/align-horizontal-center.svg", 167 | }, 168 | { value: "right", label: "Align Right", icon: "/align-right.svg" }, 169 | { value: "top", label: "Align Top", icon: "/align-top.svg" }, 170 | { 171 | value: "verticalCenter", 172 | label: "Align Vertical Center", 173 | icon: "/align-vertical-center.svg", 174 | }, 175 | { value: "bottom", label: "Align Bottom", icon: "/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 | -------------------------------------------------------------------------------- /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.key === "c" || e.key === "C")) { 98 | handleCopy(canvas); 99 | } 100 | 101 | // Check if the key pressed is ctrl/cmd + v (paste) 102 | if ((e?.ctrlKey || e?.metaKey) && (e.key === "v" || e.key === "V")) { 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.key === "x" || e.key === "X")) { 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.key === "z" || e.key === "Z")) { 119 | undo(); 120 | } 121 | 122 | // check if the key pressed is ctrl/cmd + y (redo) 123 | if ((e?.ctrlKey || e?.metaKey) && (e.key === "y" || e.key === "Y")) { 124 | redo(); 125 | } 126 | 127 | if (e.key === "/?" && !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 | }; 187 | -------------------------------------------------------------------------------- /types/type.ts: -------------------------------------------------------------------------------- 1 | import { Gradient, Pattern } from "fabric/fabric-impl"; 2 | 3 | export enum CursorMode { 4 | Hidden, 5 | Chat, 6 | ReactionSelector, 7 | Reaction, 8 | } 9 | 10 | export type CursorState = 11 | | { 12 | mode: CursorMode.Hidden; 13 | } 14 | | { 15 | mode: CursorMode.Chat; 16 | message: string; 17 | previousMessage: string | null; 18 | } 19 | | { 20 | mode: CursorMode.ReactionSelector; 21 | } 22 | | { 23 | mode: CursorMode.Reaction; 24 | reaction: string; 25 | isPressed: boolean; 26 | }; 27 | 28 | export type Reaction = { 29 | value: string; 30 | timestamp: number; 31 | point: { x: number; y: number }; 32 | }; 33 | 34 | export type ReactionEvent = { 35 | x: number; 36 | y: number; 37 | value: string; 38 | }; 39 | 40 | export type ShapeData = { 41 | type: string; 42 | width: number; 43 | height: number; 44 | fill: string | Pattern | Gradient; 45 | left: number; 46 | top: number; 47 | objectId: string | undefined; 48 | }; 49 | 50 | export type Attributes = { 51 | width: string; 52 | height: string; 53 | fontSize: string; 54 | fontFamily: string; 55 | fontWeight: string; 56 | fill: string; 57 | stroke: string; 58 | }; 59 | 60 | export type ActiveElement = { 61 | name: string; 62 | value: string; 63 | icon: string; 64 | } | null; 65 | 66 | export interface CustomFabricObject 67 | extends fabric.Object { 68 | objectId?: string; 69 | } 70 | 71 | export type ModifyShape = { 72 | canvas: fabric.Canvas; 73 | property: string; 74 | value: any; 75 | activeObjectRef: React.MutableRefObject; 76 | syncShapeInStorage: (shape: fabric.Object) => void; 77 | }; 78 | 79 | export type ElementDirection = { 80 | canvas: fabric.Canvas; 81 | direction: string; 82 | syncShapeInStorage: (shape: fabric.Object) => void; 83 | }; 84 | 85 | export type ImageUpload = { 86 | file: File; 87 | canvas: React.MutableRefObject; 88 | shapeRef: React.MutableRefObject; 89 | syncShapeInStorage: (shape: fabric.Object) => void; 90 | }; 91 | 92 | export type RightSidebarProps = { 93 | elementAttributes: Attributes; 94 | setElementAttributes: React.Dispatch>; 95 | fabricRef: React.RefObject; 96 | activeObjectRef: React.RefObject; 97 | isEditingRef: React.MutableRefObject; 98 | syncShapeInStorage: (obj: any) => void; 99 | }; 100 | 101 | export type NavbarProps = { 102 | activeElement: ActiveElement; 103 | imageInputRef: React.MutableRefObject; 104 | handleImageUpload: (e: React.ChangeEvent) => void; 105 | handleActiveElement: (element: ActiveElement) => void; 106 | }; 107 | 108 | export type ShapesMenuProps = { 109 | item: { 110 | name: string; 111 | icon: string; 112 | value: Array; 113 | }; 114 | activeElement: any; 115 | handleActiveElement: any; 116 | handleImageUpload: any; 117 | imageInputRef: any; 118 | }; 119 | 120 | export type CanvasMouseDown = { 121 | options: fabric.IEvent; 122 | canvas: fabric.Canvas; 123 | selectedShapeRef: any; 124 | isDrawing: React.MutableRefObject; 125 | shapeRef: React.MutableRefObject; 126 | }; 127 | 128 | export type CanvasMouseMove = { 129 | options: fabric.IEvent; 130 | canvas: fabric.Canvas; 131 | isDrawing: React.MutableRefObject; 132 | selectedShapeRef: any; 133 | shapeRef: any; 134 | syncShapeInStorage: (shape: fabric.Object) => void; 135 | }; 136 | 137 | export type CanvasMouseUp = { 138 | canvas: fabric.Canvas; 139 | isDrawing: React.MutableRefObject; 140 | shapeRef: any; 141 | activeObjectRef: React.MutableRefObject; 142 | selectedShapeRef: any; 143 | syncShapeInStorage: (shape: fabric.Object) => void; 144 | setActiveElement: any; 145 | }; 146 | 147 | export type CanvasObjectModified = { 148 | options: fabric.IEvent; 149 | syncShapeInStorage: (shape: fabric.Object) => void; 150 | }; 151 | 152 | export type CanvasPathCreated = { 153 | options: (fabric.IEvent & { path: CustomFabricObject }) | any; 154 | syncShapeInStorage: (shape: fabric.Object) => void; 155 | }; 156 | 157 | export type CanvasSelectionCreated = { 158 | options: fabric.IEvent; 159 | isEditingRef: React.MutableRefObject; 160 | setElementAttributes: React.Dispatch>; 161 | }; 162 | 163 | export type CanvasObjectScaling = { 164 | options: fabric.IEvent; 165 | setElementAttributes: React.Dispatch>; 166 | }; 167 | 168 | export type RenderCanvas = { 169 | fabricRef: React.MutableRefObject; 170 | canvasObjects: any; 171 | activeObjectRef: any; 172 | }; 173 | 174 | export type CursorChatProps = { 175 | cursor: { x: number; y: number }; 176 | cursorState: CursorState; 177 | setCursorState: (cursorState: CursorState) => void; 178 | updateMyPresence: ( 179 | presence: Partial<{ 180 | cursor: { x: number; y: number }; 181 | cursorColor: string; 182 | message: string; 183 | }> 184 | ) => void; 185 | }; 186 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct - Figma Clone 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to a positive environment for our 15 | community include: 16 | 17 | - Demonstrating empathy and kindness toward other people 18 | - Being respectful of differing opinions, viewpoints, and experiences 19 | - Giving and gracefully accepting constructive feedback 20 | - Accepting responsibility and apologizing to those affected by our mistakes, 21 | and learning from the experience 22 | - Focusing on what is best not just for us as individuals, but for the 23 | overall community 24 | 25 | Examples of unacceptable behavior include: 26 | 27 | - The use of sexualized language or imagery, and sexual attention or 28 | advances 29 | - Trolling, insulting or derogatory comments, and personal or political attacks 30 | - Public or private harassment 31 | - Publishing others' private information, such as a physical or email 32 | address, without their explicit permission 33 | - Other conduct which could reasonably be considered inappropriate in a 34 | professional setting 35 | 36 | ## Our Responsibilities 37 | 38 | Project maintainers are responsible for clarifying and enforcing our standards of 39 | acceptable behavior and will take appropriate and fair corrective action in 40 | response to any behavior that they deem inappropriate, 41 | threatening, offensive, or harmful. 42 | 43 | Project maintainers have the right and responsibility to remove, edit, or reject 44 | comments, commits, code, wiki edits, issues, and other contributions that are 45 | not aligned to this Code of Conduct, and will 46 | communicate reasons for moderation decisions when appropriate. 47 | 48 | ## Scope 49 | 50 | This Code of Conduct applies within all community spaces, and also applies when 51 | an individual is officially representing the community in public spaces. 52 | Examples of representing our community include using an official e-mail address, 53 | posting via an official social media account, or acting as an appointed 54 | representative at an online or offline event. 55 | 56 | ## Enforcement 57 | 58 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 59 | reported to the community leaders responsible for enforcement at . 60 | All complaints will be reviewed and investigated promptly and fairly. 61 | 62 | All community leaders are obligated to respect the privacy and security of the 63 | reporter of any incident. 64 | 65 | ## Enforcement Guidelines 66 | 67 | Community leaders will follow these Community Impact Guidelines in determining 68 | the consequences for any action they deem in violation of this Code of Conduct: 69 | 70 | ### 1. Correction 71 | 72 | **Community Impact**: Use of inappropriate language or other behavior deemed 73 | unprofessional or unwelcome in the community. 74 | 75 | **Consequence**: A private, written warning from community leaders, providing 76 | clarity around the nature of the violation and an explanation of why the 77 | behavior was inappropriate. A public apology may be requested. 78 | 79 | ### 2. Warning 80 | 81 | **Community Impact**: A violation through a single incident or series 82 | of actions. 83 | 84 | **Consequence**: A warning with consequences for continued behavior. No 85 | interaction with the people involved, including unsolicited interaction with 86 | those enforcing the Code of Conduct, for a specified period of time. This 87 | includes avoiding interactions in community spaces as well as external channels 88 | like social media. Violating these terms may lead to a temporary or 89 | permanent ban. 90 | 91 | ### 3. Temporary Ban 92 | 93 | **Community Impact**: A serious violation of community standards, including 94 | sustained inappropriate behavior. 95 | 96 | **Consequence**: A temporary ban from any sort of interaction or public 97 | communication with the community for a specified period of time. No public or 98 | private interaction with the people involved, including unsolicited interaction 99 | with those enforcing the Code of Conduct, is allowed during this period. 100 | Violating these terms may lead to a permanent ban. 101 | 102 | ### 4. Permanent Ban 103 | 104 | **Community Impact**: Demonstrating a pattern of violation of community 105 | standards, including sustained inappropriate behavior, harassment of an 106 | individual, or aggression toward or disparagement of classes of individuals. 107 | 108 | **Consequence**: A permanent ban from any sort of public interaction within 109 | the community. 110 | 111 | ## Attribution 112 | 113 | This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version 114 | [1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and 115 | [2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md), 116 | and was generated by [contributing-gen](https://github.com/bttger/contributing-gen). 117 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/app.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { fabric } from "fabric"; 4 | import { useEffect, useRef, useState } from "react"; 5 | 6 | import { LeftSidebar } from "@/components/left-sidebar"; 7 | import { Live } from "@/components/live"; 8 | import { Navbar } from "@/components/navbar"; 9 | import { RightSidebar } from "@/components/right-sidebar"; 10 | import { defaultNavElement } from "@/constants"; 11 | import { 12 | handleCanvasMouseDown, 13 | handleCanvasMouseMove, 14 | handleCanvasMouseUp, 15 | handleCanvasObjectModified, 16 | handleCanvasObjectScaling, 17 | handleCanvasSelectionCreated, 18 | handlePathCreated, 19 | handleResize, 20 | initializeFabric, 21 | renderCanvas, 22 | } from "@/lib/canvas"; 23 | import { handleDelete, handleKeyDown } from "@/lib/key-events"; 24 | import { handleImageUpload } from "@/lib/shapes"; 25 | import { useMutation, useRedo, useStorage, useUndo } from "@/liveblocks.config"; 26 | import type { ActiveElement, Attributes } from "@/types/type"; 27 | 28 | const App = () => { 29 | const canvasRef = useRef(null); 30 | const imageInputRef = useRef(null); 31 | const fabricRef = useRef(null); 32 | const shapeRef = useRef(null); 33 | const selectedShapeRef = useRef(null); 34 | const activeObjectRef = useRef(null); 35 | const isDrawing = useRef(false); 36 | const isEditingRef = useRef(false); 37 | 38 | const [elementAttributes, setElementAttributes] = useState({ 39 | width: "", 40 | height: "", 41 | fontSize: "", 42 | fontFamily: "", 43 | fontWeight: "", 44 | fill: "#aabbcc", 45 | stroke: "#aabbcc", 46 | }); 47 | const [activeElement, setActiveElement] = useState({ 48 | name: "", 49 | value: "", 50 | icon: "", 51 | }); 52 | 53 | const undo = useUndo(); 54 | const redo = useRedo(); 55 | const canvasObjects = useStorage((root) => root.canvasObjects); 56 | 57 | const syncShapeInStorage = useMutation(({ storage }, object) => { 58 | if (!object) return; 59 | 60 | const { objectId } = object; 61 | 62 | const shapeData = object.toJSON(); 63 | shapeData.objectId = objectId; 64 | 65 | const canvasObjects = storage.get("canvasObjects"); 66 | 67 | canvasObjects.set(objectId, shapeData); 68 | }, []); 69 | 70 | const deleteAllShapes = useMutation(({ storage }) => { 71 | const canvasObjects = storage.get("canvasObjects"); 72 | 73 | if (!canvasObjects || canvasObjects.size === 0) return true; 74 | 75 | for (const [key] of canvasObjects.entries()) { 76 | canvasObjects.delete(key); 77 | } 78 | 79 | return canvasObjects.size === 0; 80 | }, []); 81 | 82 | const deleteShapeFromStorage = useMutation(({ storage }, objectId) => { 83 | const canvasObjects = storage.get("canvasObjects"); 84 | 85 | canvasObjects.delete(objectId); 86 | }, []); 87 | 88 | const handleActiveElement = (elem: ActiveElement) => { 89 | setActiveElement(elem); 90 | 91 | switch (elem?.value) { 92 | case "reset": 93 | deleteAllShapes(); 94 | fabricRef.current?.clear(); 95 | setActiveElement(defaultNavElement); 96 | 97 | break; 98 | case "delete": 99 | handleDelete( 100 | fabricRef.current as fabric.Canvas, 101 | deleteShapeFromStorage 102 | ); 103 | setActiveElement(defaultNavElement); 104 | 105 | break; 106 | case "image": 107 | imageInputRef.current?.click(); 108 | isDrawing.current = false; 109 | 110 | if (fabricRef.current) fabricRef.current.isDrawingMode = false; 111 | 112 | break; 113 | default: 114 | break; 115 | } 116 | 117 | selectedShapeRef.current = elem?.value as string; 118 | }; 119 | 120 | useEffect(() => { 121 | const canvas = initializeFabric({ canvasRef, fabricRef }); 122 | 123 | canvas.on("mouse:down", (options) => { 124 | handleCanvasMouseDown({ 125 | options, 126 | canvas, 127 | isDrawing, 128 | shapeRef, 129 | selectedShapeRef, 130 | }); 131 | }); 132 | 133 | canvas.on("mouse:move", (options) => { 134 | handleCanvasMouseMove({ 135 | options, 136 | canvas, 137 | isDrawing, 138 | shapeRef, 139 | selectedShapeRef, 140 | syncShapeInStorage, 141 | }); 142 | }); 143 | 144 | canvas.on("mouse:up", () => { 145 | handleCanvasMouseUp({ 146 | canvas, 147 | isDrawing, 148 | shapeRef, 149 | selectedShapeRef, 150 | syncShapeInStorage, 151 | setActiveElement, 152 | activeObjectRef, 153 | }); 154 | }); 155 | 156 | canvas.on("object:modified", (options) => { 157 | handleCanvasObjectModified({ 158 | options, 159 | syncShapeInStorage, 160 | }); 161 | }); 162 | 163 | canvas.on("selection:created", (options) => { 164 | handleCanvasSelectionCreated({ 165 | options, 166 | isEditingRef, 167 | setElementAttributes, 168 | }); 169 | }); 170 | 171 | canvas.on("object:scaling", (options) => { 172 | handleCanvasObjectScaling({ 173 | options, 174 | setElementAttributes, 175 | }); 176 | }); 177 | 178 | canvas.on("path:created", (options) => { 179 | handlePathCreated({ 180 | options, 181 | syncShapeInStorage, 182 | }); 183 | }); 184 | 185 | window.addEventListener("resize", () => { 186 | handleResize({ canvas }); 187 | }); 188 | 189 | window.addEventListener("keydown", (e: KeyboardEvent) => { 190 | handleKeyDown({ 191 | e, 192 | canvas: fabricRef.current, 193 | undo, 194 | redo, 195 | syncShapeInStorage, 196 | deleteShapeFromStorage, 197 | }); 198 | }); 199 | 200 | return () => { 201 | canvas.dispose(); 202 | 203 | window.removeEventListener("resize", () => { 204 | handleResize({ 205 | canvas: null, 206 | }); 207 | }); 208 | }; 209 | }, [canvasRef, deleteShapeFromStorage, redo, syncShapeInStorage, undo]); 210 | 211 | useEffect(() => { 212 | renderCanvas({ 213 | fabricRef, 214 | canvasObjects, 215 | activeObjectRef, 216 | }); 217 | }, [canvasObjects]); 218 | 219 | return ( 220 |
221 | { 226 | e.stopPropagation(); 227 | 228 | if (!fabricRef?.current || !e.target?.files) return; 229 | 230 | handleImageUpload({ 231 | file: e.target.files[0], 232 | canvas: fabricRef as React.MutableRefObject, 233 | shapeRef, 234 | syncShapeInStorage, 235 | }); 236 | }} 237 | /> 238 | 239 |
240 | 241 | 242 | 243 | 244 | 252 |
253 |
254 | ); 255 | }; 256 | 257 | export default App; 258 | -------------------------------------------------------------------------------- /components/ui/context-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; 5 | import { Check, ChevronRight, Circle } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const ContextMenu = ContextMenuPrimitive.Root; 10 | 11 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger; 12 | 13 | const ContextMenuGroup = ContextMenuPrimitive.Group; 14 | 15 | const ContextMenuPortal = ContextMenuPrimitive.Portal; 16 | 17 | const ContextMenuSub = ContextMenuPrimitive.Sub; 18 | 19 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; 20 | 21 | const ContextMenuSubTrigger = 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 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; 41 | 42 | const ContextMenuSubContent = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef 45 | >(({ className, ...props }, ref) => ( 46 | 54 | )); 55 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; 56 | 57 | const ContextMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 62 | 70 | 71 | )); 72 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; 73 | 74 | const ContextMenuItem = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef & { 77 | inset?: boolean; 78 | } 79 | >(({ className, inset, ...props }, ref) => ( 80 | 89 | )); 90 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; 91 | 92 | const ContextMenuCheckboxItem = React.forwardRef< 93 | React.ElementRef, 94 | React.ComponentPropsWithoutRef 95 | >(({ className, children, checked, ...props }, ref) => ( 96 | 105 | 106 | 107 | 108 | 109 | 110 | {children} 111 | 112 | )); 113 | ContextMenuCheckboxItem.displayName = 114 | ContextMenuPrimitive.CheckboxItem.displayName; 115 | 116 | const ContextMenuRadioItem = React.forwardRef< 117 | React.ElementRef, 118 | React.ComponentPropsWithoutRef 119 | >(({ className, children, ...props }, ref) => ( 120 | 128 | 129 | 130 | 131 | 132 | 133 | {children} 134 | 135 | )); 136 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; 137 | 138 | const ContextMenuLabel = React.forwardRef< 139 | React.ElementRef, 140 | React.ComponentPropsWithoutRef & { 141 | inset?: boolean; 142 | } 143 | >(({ className, inset, ...props }, ref) => ( 144 | 153 | )); 154 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; 155 | 156 | const ContextMenuSeparator = React.forwardRef< 157 | React.ElementRef, 158 | React.ComponentPropsWithoutRef 159 | >(({ className, ...props }, ref) => ( 160 | 165 | )); 166 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; 167 | 168 | const ContextMenuShortcut = ({ 169 | className, 170 | ...props 171 | }: React.HTMLAttributes) => { 172 | return ( 173 | 180 | ); 181 | }; 182 | ContextMenuShortcut.displayName = "ContextMenuShortcut"; 183 | 184 | export { 185 | ContextMenu, 186 | ContextMenuTrigger, 187 | ContextMenuContent, 188 | ContextMenuItem, 189 | ContextMenuCheckboxItem, 190 | ContextMenuRadioItem, 191 | ContextMenuLabel, 192 | ContextMenuSeparator, 193 | ContextMenuShortcut, 194 | ContextMenuGroup, 195 | ContextMenuPortal, 196 | ContextMenuSub, 197 | ContextMenuSubContent, 198 | ContextMenuSubTrigger, 199 | ContextMenuRadioGroup, 200 | }; 201 | -------------------------------------------------------------------------------- /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/live.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | 3 | import { Comments } from "@/components/comments/comments"; 4 | import { CursorChat } from "@/components/cursor/cursor-chat"; 5 | import { LiveCursors } from "@/components/cursor/live-cursors"; 6 | import { FlyingReaction } from "@/components/reaction/flying-reaction"; 7 | import { ReactionSelector } from "@/components/reaction/reaction-button"; 8 | import { 9 | ContextMenu, 10 | ContextMenuContent, 11 | ContextMenuItem, 12 | ContextMenuTrigger, 13 | } from "@/components/ui/context-menu"; 14 | import { shortcuts } from "@/constants"; 15 | import { useInterval } from "@/hooks/use-interval"; 16 | import { 17 | useBroadcastEvent, 18 | useEventListener, 19 | useMyPresence, 20 | } from "@/liveblocks.config"; 21 | import { CursorMode, type Reaction, type CursorState } from "@/types/type"; 22 | 23 | type LiveProps = { 24 | canvasRef: React.MutableRefObject; 25 | undo: () => void; 26 | redo: () => void; 27 | }; 28 | 29 | export const Live = ({ canvasRef, undo, redo }: LiveProps) => { 30 | const broadcast = useBroadcastEvent(); 31 | const [{ cursor }, updateMyPresence] = useMyPresence(); 32 | 33 | const [reaction, setReaction] = useState([]); 34 | const [cursorState, setCursorState] = useState({ 35 | mode: CursorMode.Hidden, 36 | }); 37 | 38 | useInterval(() => { 39 | setReaction((reaction) => { 40 | return reaction.filter((react) => react.timestamp > Date.now() - 4000); 41 | }); 42 | }, 1000); 43 | 44 | useInterval(() => { 45 | if ( 46 | cursor && 47 | cursorState.mode === CursorMode.Reaction && 48 | cursorState.isPressed 49 | ) { 50 | setReaction((prevReaction) => { 51 | return prevReaction.concat([ 52 | { 53 | point: { x: cursor.x, y: cursor.y }, 54 | value: cursorState.reaction, 55 | timestamp: Date.now(), 56 | }, 57 | ]); 58 | }); 59 | 60 | broadcast({ 61 | x: cursor.x, 62 | y: cursor.y, 63 | value: cursorState.reaction, 64 | }); 65 | } 66 | }, 100); 67 | 68 | const handlePointerMove = useCallback( 69 | (e: React.PointerEvent) => { 70 | e.preventDefault(); 71 | 72 | if (cursor === null || cursorState.mode !== CursorMode.ReactionSelector) { 73 | const x = e.clientX - e.currentTarget.getBoundingClientRect().x; 74 | const y = e.clientY - e.currentTarget.getBoundingClientRect().y; 75 | 76 | updateMyPresence({ 77 | cursor: { x, y }, 78 | }); 79 | } 80 | }, 81 | [cursor, cursorState.mode, updateMyPresence] 82 | ); 83 | 84 | const handlePointerLeave = useCallback( 85 | (_e: React.PointerEvent) => { 86 | setCursorState({ mode: CursorMode.Hidden }); 87 | 88 | updateMyPresence({ 89 | cursor: null, 90 | message: null, 91 | }); 92 | }, 93 | [updateMyPresence] 94 | ); 95 | 96 | const handlePointerDown = useCallback( 97 | (e: React.PointerEvent) => { 98 | const x = e.clientX - e.currentTarget.getBoundingClientRect().x; 99 | const y = e.clientY - e.currentTarget.getBoundingClientRect().y; 100 | 101 | updateMyPresence({ 102 | cursor: { x, y }, 103 | }); 104 | 105 | setCursorState((prevCursorState) => { 106 | if (prevCursorState.mode === CursorMode.Reaction) { 107 | return { ...prevCursorState, isPressed: true }; 108 | } else { 109 | return prevCursorState; 110 | } 111 | }); 112 | }, 113 | [updateMyPresence] 114 | ); 115 | 116 | const handlePointerUp = useCallback( 117 | (_e: React.PointerEvent) => { 118 | setCursorState((prevCursorState) => { 119 | if (prevCursorState.mode === CursorMode.Reaction) { 120 | return { ...prevCursorState, isPressed: true }; 121 | } else { 122 | return prevCursorState; 123 | } 124 | }); 125 | }, 126 | [setCursorState] 127 | ); 128 | 129 | const setReactions = useCallback((reaction: string) => { 130 | setCursorState({ 131 | mode: CursorMode.Reaction, 132 | reaction, 133 | isPressed: false, 134 | }); 135 | }, []); 136 | 137 | useEffect(() => { 138 | const onKeyUp = (e: KeyboardEvent) => { 139 | if (e.key === "/") { 140 | setCursorState({ 141 | mode: CursorMode.Chat, 142 | previousMessage: "", 143 | message: "", 144 | }); 145 | } 146 | 147 | if (e.key === "Escape") { 148 | updateMyPresence({ message: "" }); 149 | setCursorState({ mode: CursorMode.Hidden }); 150 | } 151 | 152 | if (e.key === "e" || e.key === "E") { 153 | setCursorState({ 154 | mode: CursorMode.ReactionSelector, 155 | }); 156 | } 157 | }; 158 | const onKeyDown = (e: KeyboardEvent) => { 159 | if (e.key === "/") { 160 | e.preventDefault(); 161 | } 162 | }; 163 | 164 | window.addEventListener("keyup", onKeyUp); 165 | window.addEventListener("keydown", onKeyDown); 166 | 167 | return () => { 168 | window.removeEventListener("keyup", onKeyUp); 169 | window.removeEventListener("keydown", onKeyDown); 170 | }; 171 | }, [updateMyPresence]); 172 | 173 | const handleContextMenuClick = useCallback( 174 | (key: string) => { 175 | switch (key) { 176 | case "Chat": 177 | setCursorState({ 178 | mode: CursorMode.Chat, 179 | previousMessage: null, 180 | message: "", 181 | }); 182 | 183 | break; 184 | case "Undo": 185 | undo(); 186 | break; 187 | case "Redo": 188 | redo(); 189 | break; 190 | case "Reactions": 191 | setCursorState({ 192 | mode: CursorMode.ReactionSelector, 193 | }); 194 | break; 195 | default: 196 | break; 197 | } 198 | }, 199 | [redo, undo] 200 | ); 201 | 202 | useEventListener((eventData) => { 203 | const event = eventData.event; 204 | 205 | setReaction((prevReaction) => { 206 | return prevReaction.concat([ 207 | { 208 | point: { x: event.x, y: event.y }, 209 | value: event.value, 210 | timestamp: Date.now(), 211 | }, 212 | ]); 213 | }); 214 | }); 215 | 216 | return ( 217 | 218 | 226 | 227 | 228 | {cursor && ( 229 | 235 | )} 236 | 237 | {cursorState.mode === CursorMode.ReactionSelector && ( 238 | 239 | )} 240 | 241 | {reaction.map((react) => ( 242 | 249 | ))} 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | {shortcuts.map((shortcut) => ( 258 | handleContextMenuClick(shortcut.name)} 261 | className="right-menu-item" 262 | > 263 |

{shortcut.name}

264 |

{shortcut.shortcut}

265 |
266 | ))} 267 |
268 |
269 | ); 270 | }; 271 | -------------------------------------------------------------------------------- /components/comments/new-thread.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ComposerSubmitComment } from "@liveblocks/react-comments/primitives"; 4 | import * as Portal from "@radix-ui/react-portal"; 5 | import { Slot } from "@radix-ui/react-slot"; 6 | import { 7 | FormEvent, 8 | type PropsWithChildren, 9 | useCallback, 10 | useEffect, 11 | useRef, 12 | useState, 13 | } from "react"; 14 | 15 | import { useMaxZIndex } from "@/lib/use-max-zindex"; 16 | import { useCreateThread } from "@/liveblocks.config"; 17 | 18 | import { NewThreadCursor } from "./new-thread-cursor"; 19 | import { PinnedComposer } from "./pinned-composer"; 20 | 21 | type ComposerCoords = null | { x: number; y: number }; 22 | 23 | export const NewThread = ({ children }: PropsWithChildren) => { 24 | // set state to track if we're placing a new comment or not 25 | const [creatingCommentState, setCreatingCommentState] = useState< 26 | "placing" | "placed" | "complete" 27 | >("complete"); 28 | 29 | /** 30 | * We're using the useCreateThread hook to create a new thread. 31 | * 32 | * useCreateThread: https://liveblocks.io/docs/api-reference/liveblocks-react#useCreateThread 33 | */ 34 | const createThread = useCreateThread(); 35 | 36 | // get the max z-index of a thread 37 | const maxZIndex = useMaxZIndex(); 38 | 39 | // set state to track the coordinates of the composer (liveblocks comment editor) 40 | const [composerCoords, setComposerCoords] = useState(null); 41 | 42 | // set state to track the last pointer event 43 | const lastPointerEvent = useRef(null); 44 | 45 | // set state to track if user is allowed to use the composer 46 | const [allowUseComposer, setAllowUseComposer] = useState(false); 47 | const allowComposerRef = useRef(allowUseComposer); 48 | allowComposerRef.current = allowUseComposer; 49 | 50 | useEffect(() => { 51 | // If composer is already placed, don't do anything 52 | if (creatingCommentState === "complete") { 53 | return; 54 | } 55 | 56 | // Place a composer on the screen 57 | const newComment = (e: MouseEvent) => { 58 | e.preventDefault(); 59 | 60 | // If already placed, click outside to close composer 61 | if (creatingCommentState === "placed") { 62 | // check if the click event is on/inside the composer 63 | const isClickOnComposer = ((e as any)._savedComposedPath = e 64 | .composedPath() 65 | .some((el: any) => { 66 | return el.classList?.contains("lb-composer-editor-actions"); 67 | })); 68 | 69 | // if click is inisde/on composer, don't do anything 70 | if (isClickOnComposer) { 71 | return; 72 | } 73 | 74 | // if click is outside composer, close composer 75 | if (!isClickOnComposer) { 76 | setCreatingCommentState("complete"); 77 | return; 78 | } 79 | } 80 | 81 | // First click sets composer down 82 | setCreatingCommentState("placed"); 83 | setComposerCoords({ 84 | x: e.clientX, 85 | y: e.clientY, 86 | }); 87 | }; 88 | 89 | document.documentElement.addEventListener("click", newComment); 90 | 91 | return () => { 92 | document.documentElement.removeEventListener("click", newComment); 93 | }; 94 | }, [creatingCommentState]); 95 | 96 | useEffect(() => { 97 | // If dragging composer, update position 98 | const handlePointerMove = (e: PointerEvent) => { 99 | // Prevents issue with composedPath getting removed 100 | (e as any)._savedComposedPath = e.composedPath(); 101 | lastPointerEvent.current = e; 102 | }; 103 | 104 | document.documentElement.addEventListener("pointermove", handlePointerMove); 105 | 106 | return () => { 107 | document.documentElement.removeEventListener( 108 | "pointermove", 109 | handlePointerMove 110 | ); 111 | }; 112 | }, []); 113 | 114 | // Set pointer event from last click on body for use later 115 | useEffect(() => { 116 | if (creatingCommentState !== "placing") { 117 | return; 118 | } 119 | 120 | const handlePointerDown = (e: PointerEvent) => { 121 | // if composer is already placed, don't do anything 122 | if (allowComposerRef.current) { 123 | return; 124 | } 125 | 126 | // Prevents issue with composedPath getting removed 127 | (e as any)._savedComposedPath = e.composedPath(); 128 | lastPointerEvent.current = e; 129 | setAllowUseComposer(true); 130 | }; 131 | 132 | // Right click to cancel placing 133 | const handleContextMenu = (e: Event) => { 134 | if (creatingCommentState === "placing") { 135 | e.preventDefault(); 136 | setCreatingCommentState("complete"); 137 | } 138 | }; 139 | 140 | document.documentElement.addEventListener("pointerdown", handlePointerDown); 141 | document.documentElement.addEventListener("contextmenu", handleContextMenu); 142 | 143 | return () => { 144 | document.documentElement.removeEventListener( 145 | "pointerdown", 146 | handlePointerDown 147 | ); 148 | document.documentElement.removeEventListener( 149 | "contextmenu", 150 | handleContextMenu 151 | ); 152 | }; 153 | }, [creatingCommentState]); 154 | 155 | // On composer submit, create thread and reset state 156 | const handleComposerSubmit = useCallback( 157 | ({ body }: ComposerSubmitComment, event: FormEvent) => { 158 | event.preventDefault(); 159 | event.stopPropagation(); 160 | 161 | // Get your canvas element 162 | const overlayPanel = document.querySelector("#canvas"); 163 | 164 | // if there's no composer coords or last pointer event, meaning the user hasn't clicked yet, don't do anything 165 | if (!composerCoords || !lastPointerEvent.current || !overlayPanel) { 166 | return; 167 | } 168 | 169 | // Set coords relative to the top left of your canvas 170 | const { top, left } = overlayPanel.getBoundingClientRect(); 171 | const x = composerCoords.x - left; 172 | const y = composerCoords.y - top; 173 | 174 | // create a new thread with the composer coords and cursor selectors 175 | createThread({ 176 | body, 177 | metadata: { 178 | x, 179 | y, 180 | resolved: false, 181 | zIndex: maxZIndex + 1, 182 | }, 183 | }); 184 | 185 | setComposerCoords(null); 186 | setCreatingCommentState("complete"); 187 | setAllowUseComposer(false); 188 | }, 189 | [createThread, composerCoords, maxZIndex] 190 | ); 191 | 192 | return ( 193 | <> 194 | {/** 195 | * Slot is used to wrap the children of the NewThread component 196 | * to allow us to add a click event listener to the children 197 | * 198 | * Slot: https://www.radix-ui.com/primitives/docs/utilities/slot 199 | * 200 | * Disclaimer: We don't have to download this package specifically, 201 | * it's already included when we install Shadcn 202 | */} 203 | 205 | setCreatingCommentState( 206 | creatingCommentState !== "complete" ? "complete" : "placing" 207 | ) 208 | } 209 | style={{ opacity: creatingCommentState !== "complete" ? 0.7 : 1 }} 210 | > 211 | {children} 212 | 213 | 214 | {/* if composer coords exist and we're placing a comment, render the composer */} 215 | {composerCoords && creatingCommentState === "placed" ? ( 216 | /** 217 | * Portal.Root is used to render the composer outside of the NewThread component to avoid z-index issuess 218 | * 219 | * Portal.Root: https://www.radix-ui.com/primitives/docs/utilities/portal 220 | */ 221 | 229 | 230 | 231 | ) : null} 232 | 233 | {/* Show the customizing cursor when placing a comment. The one with comment shape */} 234 | 235 | 236 | ); 237 | }; 238 | -------------------------------------------------------------------------------- /public/freeform.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Contributing to Figma Clone 4 | 5 | First off, thanks for taking the time to contribute! ❤️ 6 | 7 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 8 | 9 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about: 10 | > 11 | > - Star the project 12 | > - Tweet about it 13 | > - Refer this project in your project's readme 14 | > - Mention the project at local meetups and tell your friends/colleagues 15 | 16 | 17 | 18 | ## Table of Contents 19 | 20 | - [Code of Conduct](#code-of-conduct) 21 | - [I Have a Question](#i-have-a-question) 22 | - [I Want To Contribute](#i-want-to-contribute) 23 | - [Reporting Bugs](#reporting-bugs) 24 | - [Suggesting Enhancements](#suggesting-enhancements) 25 | - [Your First Code Contribution](#your-first-code-contribution) 26 | - [Improving The Documentation](#improving-the-documentation) 27 | - [Styleguides](#styleguides) 28 | - [Commit Messages](#commit-messages) 29 | - [Join The Project Team](#join-the-project-team) 30 | 31 | ## Code of Conduct 32 | 33 | This project and everyone participating in it is governed by the 34 | [Figma Clone Code of Conduct](https://github.com/sanidhyy/figma-cloneblob/master/CODE_OF_CONDUCT.md). 35 | By participating, you are expected to uphold this code. Please report unacceptable behavior 36 | to . 37 | 38 | ## I Have a Question 39 | 40 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/sanidhyy/figma-clone/wiki). 41 | 42 | Before you ask a question, it is best to search for existing [Issues](https://github.com/sanidhyy/figma-clone/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first. 43 | 44 | If you then still feel the need to ask a question and need clarification, we recommend the following: 45 | 46 | - Open an [Issue](https://github.com/sanidhyy/figma-clone/issues/new). 47 | - Provide as much context as you can about what you're running into. 48 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant. 49 | 50 | We will then take care of the issue as soon as possible. 51 | 52 | ## I Want To Contribute 53 | 54 | > ### Legal Notice 55 | > 56 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. 57 | 58 | ### Reporting Bugs 59 | 60 | 61 | 62 | #### Before Submitting a Bug Report 63 | 64 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible. 65 | 66 | - Make sure that you are using the latest version. 67 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/sanidhyy/figma-clone/wiki). If you are looking for support, you might want to check [this section](#i-have-a-question)). 68 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/sanidhyy/figma-cloneissues?q=label%3Abug). 69 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue. 70 | - Collect information about the bug: 71 | - Stack trace (Traceback) 72 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM) 73 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant. 74 | - Possibly your input and the output 75 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions? 76 | 77 | 78 | 79 | #### How Do I Submit a Good Bug Report? 80 | 81 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to . 82 | 83 | We use GitHub issues to track bugs and errors. If you run into an issue with the project: 84 | 85 | - Open an [Issue](https://github.com/sanidhyy/figma-clone/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.) 86 | - Explain the behavior you would expect and the actual behavior. 87 | - Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case. 88 | - Provide the information you collected in the previous section. 89 | 90 | Once it's filed: 91 | 92 | - The project team will label the issue accordingly. 93 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced. 94 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution). 95 | 96 | ### Suggesting Enhancements 97 | 98 | This section guides you through submitting an enhancement suggestion for Figma Clone, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions. 99 | 100 | 101 | 102 | #### Before Submitting an Enhancement 103 | 104 | - Make sure that you are using the latest version. 105 | - Read the [documentation](https://github.com/sanidhyy/figma-clone/wiki) carefully and find out if the functionality is already covered, maybe by an individual configuration. 106 | - Perform a [search](https://github.com/sanidhyy/figma-clone/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 107 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library. 108 | 109 | 110 | 111 | #### How Do I Submit a Good Enhancement Suggestion? 112 | 113 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/sanidhyy/figma-clone/issues). 114 | 115 | - Use a **clear and descriptive title** for the issue to identify the suggestion. 116 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible. 117 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you. 118 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 119 | - **Explain why this enhancement would be useful** to most Figma Clone users. You may also want to point out the other projects that solved it better and which could serve as inspiration. 120 | 121 | 122 | 123 | ## Attribution 124 | 125 | This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)! 126 | -------------------------------------------------------------------------------- /.github/images/stats.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 122 | 123 | 124 | 125 | 99 126 | Performance 127 | 128 | 129 | 130 | 131 | 100 132 | Accessibility 133 | 134 | 135 | 136 | 137 | 96 138 | Best Practices 139 | 140 | 141 | 142 | 143 | 100 144 | SEO 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | Progressive 211 | Web App 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 0-49 227 | 50-89 228 | 90-100 229 | 230 | 231 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # A minimalist Figma clone using Fabric.js and Liveblocks for real-time collaboration. 4 | 5 | ![A minimalist Figma clone using Fabric.js and Liveblocks for real-time collaboration.](/.github/images/img_main.png "A minimalist Figma clone using Fabric.js and Liveblocks for real-time collaboration.") 6 | 7 | [![Ask Me Anything!](https://flat.badgen.net/static/Ask%20me/anything?icon=github&color=black&scale=1.01)](https://github.com/sanidhyy "Ask Me Anything!") 8 | [![GitHub license](https://flat.badgen.net/github/license/sanidhyy/figma-clone?icon=github&color=black&scale=1.01)](https://github.com/sanidhyy/figma-clone/blob/main/LICENSE "GitHub license") 9 | [![Maintenance](https://flat.badgen.net/static/Maintained/yes?icon=github&color=black&scale=1.01)](https://github.com/sanidhyy/figma-clone/commits/main "Maintenance") 10 | [![GitHub branches](https://flat.badgen.net/github/branches/sanidhyy/figma-clone?icon=github&color=black&scale=1.01)](https://github.com/sanidhyy/figma-clone/branches "GitHub branches") 11 | [![Github commits](https://flat.badgen.net/github/commits/sanidhyy/figma-clone?icon=github&color=black&scale=1.01)](https://github.com/sanidhyy/figma-clone/commits "Github commits") 12 | [![GitHub issues](https://flat.badgen.net/github/issues/sanidhyy/figma-clone?icon=github&color=black&scale=1.01)](https://github.com/sanidhyy/figma-clone/issues "GitHub issues") 13 | [![GitHub pull requests](https://flat.badgen.net/github/prs/sanidhyy/figma-clone?icon=github&color=black&scale=1.01)](https://github.com/sanidhyy/figma-clone/pulls "GitHub pull requests") 14 | [![Netlify Status](https://api.netlify.com/api/v1/badges/5dc30440-a2b3-47d8-80cc-b964bf94d6a2/deploy-status)](https://clone-figmaa.netlify.app/ "Netlify Status") 15 | 16 | 17 |
18 | 19 | 20 | 21 | # :notebook_with_decorative_cover: Table of Contents 22 | 23 | 24 | 25 | - [Folder Structure](#bangbang-folder-structure) 26 | - [Getting Started](#toolbox-getting-started) 27 | - [Screenshots](#camera-screenshots) 28 | - [Tech Stack](#gear-tech-stack) 29 | - [Stats](#wrench-stats) 30 | - [Contribute](#raised_hands-contribute) 31 | - [Acknowledgements](#gem-acknowledgements) 32 | - [Buy Me a Coffee](#coffee-buy-me-a-coffee) 33 | - [Follow Me](#rocket-follow-me) 34 | - [Learn More](#books-learn-more) 35 | - [Deploy on Netlify](#page_with_curl-deploy-on-netlify) 36 | - [Give A Star](#star-give-a-star) 37 | - [Star History](#star2-star-history) 38 | - [Give A Star](#star-give-a-star) 39 | 40 |
41 | 42 | ## :bangbang: Folder Structure 43 | 44 | Here is the folder structure of this app. 45 | 46 | ```bash 47 | figma-clone/ 48 | |- app/ 49 | |-- app.tsx 50 | |-- apple-icon.png 51 | |-- favicon.ico 52 | |-- globals.css 53 | |-- icon1.png 54 | |-- icon2.png 55 | |-- layout.tsx 56 | |-- page.tsx 57 | |-- room.tsx 58 | |- components/ 59 | |-- comments/ 60 | |-- cursor/ 61 | |-- reaction/ 62 | |-- settings/ 63 | |-- ui/ 64 | |-- users/ 65 | |-- left-sidebar.tsx 66 | |-- live.tsx 67 | |-- loader.tsx 68 | |-- navbar.tsx 69 | |-- right-sidebar.tsx 70 | |-- shapes-menu.tsx 71 | |- config/ 72 | |-- index.ts 73 | |- constants/ 74 | |-- index.ts 75 | |- hooks/ 76 | |-- use-interval.ts 77 | |- lib/ 78 | |-- canvas.ts 79 | |-- key-events.ts 80 | |-- shapes.ts 81 | |-- use-max-zindex.ts 82 | |-- utils.ts 83 | |- public/ 84 | |- scripts/ 85 | |-- prod.ts 86 | |-- reset.ts 87 | |-- seed.ts 88 | |- store/ 89 | |-- use-exit-modal.ts 90 | |-- use-hearts-modal.ts 91 | |-- use-practice-modal.ts 92 | |- types/ 93 | |-- declaration.d.ts 94 | |-- type.ts 95 | |- .env.local 96 | |- .env.example 97 | |- .eslintrc.js 98 | |- .gitignore 99 | |- .prettierrc.json 100 | |- components.json 101 | |- environment.d.ts 102 | |- liveblocks.config.ts 103 | |- next.config.mjs 104 | |- package-lock.json 105 | |- package.json 106 | |- postcss.config.js 107 | |- tailwind.config.ts 108 | |- tsconfig.json 109 | ``` 110 | 111 |
112 | 113 | ## :toolbox: Getting Started 114 | 115 | 1. Make sure **Git** and **NodeJS** is installed. 116 | 2. Clone this repository to your local computer. 117 | 3. Create `.env.local` file in **root** directory. 118 | 4. Contents of `.env.local`: 119 | 120 | ```env 121 | # .env.local 122 | 123 | # disable next.js telemetry 124 | NEXT_TELEMETRY_DISABLED=1 125 | 126 | # liveblocks api key 127 | NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_dev_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 128 | 129 | ``` 130 | 131 | ### 5. Set Up Liveblocks 132 | 133 | 1. **Create a Liveblocks Account**: 134 | 135 | - If you don't have a Liveblocks account, sign up at [Liveblocks](https://liveblocks.io/). 136 | 137 | 2. **Create a New Project**: 138 | - After logging in, navigate to the Liveblocks dashboard. 139 | - Click on "Create Project" to set up a new project for Figma-Clone. 140 | - Provide a name for your project and create it. 141 | 142 | ### 6. Obtain the Liveblocks Public Key 143 | 144 | 1. **Navigate to the Project Settings**: 145 | 146 | - In your Liveblocks dashboard, select the project you created for Figma-Clone. 147 | - Go to the "Settings" or "API Keys" section. 148 | 149 | 2. **Copy the Public Key**: 150 | 151 | - You will find the "Public Key" under the API Keys section. Copy the public key that starts with `pk_dev_` and replace placeholder value in `NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY` with the copied value. 152 | 153 | 3. Save and Secure: 154 | 155 | - Save the changes to the `.env.local` file. 156 | 157 | 4. Install Project Dependencies using `npm install --legacy-peer-deps` or `yarn install --legacy-peer-deps`. 158 | 159 | 5. Now app is fully configured 👍 and you can start using this app using either one of `npm run dev` or `yarn dev`. 160 | 161 | **NOTE:** Please make sure to keep your API keys and configuration values secure and do not expose them publicly. 162 | 163 | ## :camera: Screenshots 164 | 165 | ![Modern UI/UX](/.github/images/img1.png "Modern UI/UX") 166 | 167 | ![Right Click Context Menu](/.github/images/img2.png "Right Click Context Menu") 168 | 169 | ![Fully functional Drawing Board](/.github/images/img3.png "Fully functional Drawing Board") 170 | 171 | ## :gear: Tech Stack 172 | 173 | [![React JS](https://skillicons.dev/icons?i=react "React JS")](https://react.dev/ "React JS") [![Next JS](https://skillicons.dev/icons?i=next "Next JS")](https://nextjs.org/ "Next JS") [![Typescript](https://skillicons.dev/icons?i=ts "Typescript")](https://www.typescriptlang.org/ "Typescript") [![Tailwind CSS](https://skillicons.dev/icons?i=tailwind "Tailwind CSS")](https://tailwindcss.com/ "Tailwind CSS") [![Netlify](https://skillicons.dev/icons?i=netlify "Netlify")](https://netlify.app/ "Netlify") 174 | 175 | ## :wrench: Stats 176 | 177 | [![Stats for Figma Clone](/.github/images/stats.svg "Stats for Figma Clone")](https://pagespeed.web.dev/analysis?url=https://clone-figmaa.netlify.app/ "Stats for Figma Clone") 178 | 179 | ## :raised_hands: Contribute 180 | 181 | You might encounter some bugs while using this app. You are more than welcome to contribute. Just submit changes via pull request and I will review them before merging. Make sure you follow community guidelines. 182 | 183 | ## :gem: Acknowledgements 184 | 185 | Useful resources and dependencies that are used in Figma Clone. 186 | 187 | - [@liveblocks/client](https://www.npmjs.com/package/@liveblocks/client): ^1.12.0 188 | - [@liveblocks/react](https://www.npmjs.com/package/@liveblocks/react): ^1.12.0 189 | - [@liveblocks/react-comments](https://www.npmjs.com/package/@liveblocks/react-comments): ^1.12.0 190 | - [@radix-ui/react-context-menu](https://www.npmjs.com/package/@radix-ui/react-context-menu): ^2.1.5 191 | - [@radix-ui/react-dropdown-menu](https://www.npmjs.com/package/@radix-ui/react-dropdown-menu): ^2.0.6 192 | - [@radix-ui/react-label](https://www.npmjs.com/package/@radix-ui/react-label): ^2.0.2 193 | - [@radix-ui/react-select](https://www.npmjs.com/package/@radix-ui/react-select): ^2.0.0 194 | - [@radix-ui/react-slot](https://www.npmjs.com/package/@radix-ui/react-slot): ^1.0.2 195 | - [class-variance-authority](https://www.npmjs.com/package/class-variance-authority): ^0.7.0 196 | - [clsx](https://www.npmjs.com/package/clsx): ^2.1.1 197 | - [fabric](https://www.npmjs.com/package/fabric): ^5.3.0 198 | - [jspdf](https://www.npmjs.com/package/jspdf): ^2.5.1 199 | - [lucide-react](https://www.npmjs.com/package/lucide-react): ^0.379.0 200 | - [next](https://www.npmjs.com/package/next): 14.2.3 201 | - [react](https://www.npmjs.com/package/react): ^18 202 | - [react-dom](https://www.npmjs.com/package/react-dom): ^18 203 | - [tailwind-merge](https://www.npmjs.com/package/tailwind-merge): ^2.3.0 204 | - [tailwindcss-animate](https://www.npmjs.com/package/tailwindcss-animate): ^1.0.7 205 | - [uuid](https://www.npmjs.com/package/uuid): ^9.0.1 206 | - [@types/fabric](https://www.npmjs.com/package/@types/fabric): ^5.3.7 207 | - [@types/node](https://www.npmjs.com/package/@types/node): ^20 208 | - [@types/react](https://www.npmjs.com/package/@types/react): ^18 209 | - [@types/react-dom](https://www.npmjs.com/package/@types/react-dom): ^18 210 | - [@types/uuid](https://www.npmjs.com/package/@types/uuid): ^9.0.8 211 | - [eslint](https://www.npmjs.com/package/eslint): ^8 212 | - [eslint-config-next](https://www.npmjs.com/package/eslint-config-next): 14.2.3 213 | - [postcss](https://www.npmjs.com/package/postcss): ^8 214 | - [tailwindcss](https://www.npmjs.com/package/tailwindcss): ^3.4.1 215 | - [typescript](https://www.npmjs.com/package/typescript): ^5 216 | 217 | ## :coffee: Buy Me a Coffee 218 | 219 | [](https://www.buymeacoffee.com/sanidhy "Buy me a Coffee") 220 | 221 | ## :rocket: Follow Me 222 | 223 | [![Follow Me](https://img.shields.io/github/followers/sanidhyy?style=social&label=Follow&maxAge=2592000)](https://github.com/sanidhyy "Follow Me") 224 | [![Tweet about this project](https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Ftwitter.com%2FTechnicalShubam)](https://twitter.com/intent/tweet?text=Check+out+this+amazing+app:&url=https%3A%2F%2Fgithub.com%2Fsanidhyy%2Ffigma-clone "Tweet about this project") 225 | [![Subscribe to my YouTube Channel](https://img.shields.io/youtube/channel/subscribers/UCNAz_hUVBG2ZUN8TVm0bmYw)](https://www.youtube.com/@OPGAMER./?sub_confirmation=1 "Subscribe to my YouTube Channel") 226 | 227 | ## :books: Learn More 228 | 229 | To learn more about Next.js, take a look at the following resources: 230 | 231 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 232 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 233 | 234 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 235 | 236 | ## :page_with_curl: Deploy on Netlify 237 | 238 | The simplest way to deploy your React.js app is to use the [Netlify Platform](https://app.netlify.com/start) - a powerful platform for modern web projects. 239 | 240 | Explore the [Netlify deployment documentation](https://docs.netlify.com/site-deploys/create-deploys) for step-by-step instructions on deploying your React.js app on Netlify. 241 | 242 | Happy coding, and feel free to share your thoughts and improvements with the [Netlify community](https://community.netlify.com)! 243 | 244 | ## :star: Give A Star 245 | 246 | You can also give this repository a star to show more people and they can use this repository. 247 | 248 | ## :star2: Star History 249 | 250 | 251 | 252 | 253 | 254 | Star History Chart 255 | 256 | 257 | 258 |
259 |

(back to top)

260 | -------------------------------------------------------------------------------- /lib/canvas.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import { v4 as uuid4 } from "uuid"; 3 | 4 | import { defaultNavElement } from "@/constants"; 5 | import { 6 | CanvasMouseDown, 7 | CanvasMouseMove, 8 | CanvasMouseUp, 9 | CanvasObjectModified, 10 | CanvasObjectScaling, 11 | CanvasPathCreated, 12 | CanvasSelectionCreated, 13 | RenderCanvas, 14 | } from "@/types/type"; 15 | 16 | import { createSpecificShape } from "./shapes"; 17 | 18 | // initialize fabric canvas 19 | export const initializeFabric = ({ 20 | fabricRef, 21 | canvasRef, 22 | }: { 23 | fabricRef: React.MutableRefObject; 24 | canvasRef: React.MutableRefObject; 25 | }) => { 26 | // get canvas element 27 | const canvasElement = document.getElementById("canvas"); 28 | 29 | // create fabric canvas 30 | const canvas = new fabric.Canvas(canvasRef.current, { 31 | width: canvasElement?.clientWidth, 32 | height: canvasElement?.clientHeight, 33 | }); 34 | 35 | // set canvas reference to fabricRef so we can use it later anywhere outside canvas listener 36 | fabricRef.current = canvas; 37 | 38 | return canvas; 39 | }; 40 | 41 | // instantiate creation of custom fabric object/shape and add it to canvas 42 | export const handleCanvasMouseDown = ({ 43 | options, 44 | canvas, 45 | selectedShapeRef, 46 | isDrawing, 47 | shapeRef, 48 | }: CanvasMouseDown) => { 49 | // get pointer coordinates 50 | const pointer = canvas.getPointer(options.e); 51 | 52 | /** 53 | * get target object i.e., the object that is clicked 54 | * findtarget() returns the object that is clicked 55 | * 56 | * findTarget: http://fabricjs.com/docs/fabric.Canvas.html#findTarget 57 | */ 58 | const target = canvas.findTarget(options.e, false); 59 | 60 | // set canvas drawing mode to false 61 | canvas.isDrawingMode = false; 62 | 63 | // if selected shape is freeform, set drawing mode to true and return 64 | if (selectedShapeRef.current === "freeform") { 65 | isDrawing.current = true; 66 | canvas.isDrawingMode = true; 67 | canvas.freeDrawingBrush.width = 5; 68 | return; 69 | } 70 | 71 | canvas.isDrawingMode = false; 72 | 73 | // if target is the selected shape or active selection, set isDrawing to false 74 | if ( 75 | target && 76 | (target.type === selectedShapeRef.current || 77 | target.type === "activeSelection") 78 | ) { 79 | isDrawing.current = false; 80 | 81 | // set active object to target 82 | canvas.setActiveObject(target); 83 | 84 | /** 85 | * setCoords() is used to update the controls of the object 86 | * setCoords: http://fabricjs.com/docs/fabric.Object.html#setCoords 87 | */ 88 | target.setCoords(); 89 | } else { 90 | isDrawing.current = true; 91 | 92 | // create custom fabric object/shape and set it to shapeRef 93 | shapeRef.current = createSpecificShape( 94 | selectedShapeRef.current, 95 | pointer as any 96 | ); 97 | 98 | // if shapeRef is not null, add it to canvas 99 | if (shapeRef.current) { 100 | // add: http://fabricjs.com/docs/fabric.Canvas.html#add 101 | canvas.add(shapeRef.current); 102 | } 103 | } 104 | }; 105 | 106 | // handle mouse move event on canvas to draw shapes with different dimensions 107 | export const handleCanvasMouseMove = ({ 108 | options, 109 | canvas, 110 | isDrawing, 111 | selectedShapeRef, 112 | shapeRef, 113 | syncShapeInStorage, 114 | }: CanvasMouseMove) => { 115 | // if selected shape is freeform, return 116 | if (!isDrawing.current) return; 117 | if (selectedShapeRef.current === "freeform") return; 118 | 119 | canvas.isDrawingMode = false; 120 | 121 | // get pointer coordinates 122 | const pointer = canvas.getPointer(options.e); 123 | 124 | // depending on the selected shape, set the dimensions of the shape stored in shapeRef in previous step of handelCanvasMouseDown 125 | // calculate shape dimensions based on pointer coordinates 126 | switch (selectedShapeRef?.current) { 127 | case "rectangle": 128 | shapeRef.current?.set({ 129 | width: pointer.x - (shapeRef.current?.left || 0), 130 | height: pointer.y - (shapeRef.current?.top || 0), 131 | }); 132 | break; 133 | 134 | case "circle": 135 | shapeRef.current.set({ 136 | radius: Math.abs(pointer.x - (shapeRef.current?.left || 0)) / 2, 137 | }); 138 | break; 139 | 140 | case "triangle": 141 | shapeRef.current?.set({ 142 | width: pointer.x - (shapeRef.current?.left || 0), 143 | height: pointer.y - (shapeRef.current?.top || 0), 144 | }); 145 | break; 146 | 147 | case "line": 148 | shapeRef.current?.set({ 149 | x2: pointer.x, 150 | y2: pointer.y, 151 | }); 152 | break; 153 | 154 | case "image": 155 | shapeRef.current?.set({ 156 | width: pointer.x - (shapeRef.current?.left || 0), 157 | height: pointer.y - (shapeRef.current?.top || 0), 158 | }); 159 | 160 | default: 161 | break; 162 | } 163 | 164 | // render objects on canvas 165 | // renderAll: http://fabricjs.com/docs/fabric.Canvas.html#renderAll 166 | canvas.renderAll(); 167 | 168 | // sync shape in storage 169 | if (shapeRef.current?.objectId) { 170 | syncShapeInStorage(shapeRef.current); 171 | } 172 | }; 173 | 174 | // handle mouse up event on canvas to stop drawing shapes 175 | export const handleCanvasMouseUp = ({ 176 | canvas, 177 | isDrawing, 178 | shapeRef, 179 | activeObjectRef, 180 | selectedShapeRef, 181 | syncShapeInStorage, 182 | setActiveElement, 183 | }: CanvasMouseUp) => { 184 | isDrawing.current = false; 185 | if (selectedShapeRef.current === "freeform") return; 186 | 187 | // sync shape in storage as drawing is stopped 188 | syncShapeInStorage(shapeRef.current); 189 | 190 | // set everything to null 191 | shapeRef.current = null; 192 | activeObjectRef.current = null; 193 | selectedShapeRef.current = null; 194 | 195 | // if canvas is not in drawing mode, set active element to default nav element after 700ms 196 | if (!canvas.isDrawingMode) { 197 | setTimeout(() => { 198 | setActiveElement(defaultNavElement); 199 | }, 700); 200 | } 201 | }; 202 | 203 | // update shape in storage when object is modified 204 | export const handleCanvasObjectModified = ({ 205 | options, 206 | syncShapeInStorage, 207 | }: CanvasObjectModified) => { 208 | const target = options.target; 209 | if (!target) return; 210 | 211 | if (target?.type == "activeSelection") { 212 | // fix this 213 | } else { 214 | syncShapeInStorage(target); 215 | } 216 | }; 217 | 218 | // update shape in storage when path is created when in freeform mode 219 | export const handlePathCreated = ({ 220 | options, 221 | syncShapeInStorage, 222 | }: CanvasPathCreated) => { 223 | // get path object 224 | const path = options.path; 225 | if (!path) return; 226 | 227 | // set unique id to path object 228 | path.set({ 229 | objectId: uuid4(), 230 | }); 231 | 232 | // sync shape in storage 233 | syncShapeInStorage(path); 234 | }; 235 | 236 | // check how object is moving on canvas and restrict it to canvas boundaries 237 | export const handleCanvasObjectMoving = ({ 238 | options, 239 | }: { 240 | options: fabric.IEvent; 241 | }) => { 242 | // get target object which is moving 243 | const target = options.target as fabric.Object; 244 | 245 | // target.canvas is the canvas on which the object is moving 246 | const canvas = target.canvas as fabric.Canvas; 247 | 248 | // set coordinates of target object 249 | target.setCoords(); 250 | 251 | // restrict object to canvas boundaries (horizontal) 252 | if (target && target.left) { 253 | target.left = Math.max( 254 | 0, 255 | Math.min( 256 | target.left, 257 | (canvas.width || 0) - (target.getScaledWidth() || target.width || 0) 258 | ) 259 | ); 260 | } 261 | 262 | // restrict object to canvas boundaries (vertical) 263 | if (target && target.top) { 264 | target.top = Math.max( 265 | 0, 266 | Math.min( 267 | target.top, 268 | (canvas.height || 0) - (target.getScaledHeight() || target.height || 0) 269 | ) 270 | ); 271 | } 272 | }; 273 | 274 | // set element attributes when element is selected 275 | export const handleCanvasSelectionCreated = ({ 276 | options, 277 | isEditingRef, 278 | setElementAttributes, 279 | }: CanvasSelectionCreated) => { 280 | // if user is editing manually, return 281 | if (isEditingRef.current) return; 282 | 283 | // if no element is selected, return 284 | if (!options?.selected) return; 285 | 286 | // get the selected element 287 | const selectedElement = options?.selected[0] as fabric.Object; 288 | 289 | // if only one element is selected, set element attributes 290 | if (selectedElement && options.selected.length === 1) { 291 | // calculate scaled dimensions of the object 292 | const scaledWidth = selectedElement?.scaleX 293 | ? selectedElement?.width! * selectedElement?.scaleX 294 | : selectedElement?.width; 295 | 296 | const scaledHeight = selectedElement?.scaleY 297 | ? selectedElement?.height! * selectedElement?.scaleY 298 | : selectedElement?.height; 299 | 300 | setElementAttributes({ 301 | width: scaledWidth?.toFixed(0).toString() || "", 302 | height: scaledHeight?.toFixed(0).toString() || "", 303 | fill: selectedElement?.fill?.toString() || "", 304 | stroke: selectedElement?.stroke || "", 305 | // @ts-ignore 306 | fontSize: selectedElement?.fontSize || "", 307 | // @ts-ignore 308 | fontFamily: selectedElement?.fontFamily || "", 309 | // @ts-ignore 310 | fontWeight: selectedElement?.fontWeight || "", 311 | }); 312 | } 313 | }; 314 | 315 | // update element attributes when element is scaled 316 | export const handleCanvasObjectScaling = ({ 317 | options, 318 | setElementAttributes, 319 | }: CanvasObjectScaling) => { 320 | const selectedElement = options.target; 321 | 322 | // calculate scaled dimensions of the object 323 | const scaledWidth = selectedElement?.scaleX 324 | ? selectedElement?.width! * selectedElement?.scaleX 325 | : selectedElement?.width; 326 | 327 | const scaledHeight = selectedElement?.scaleY 328 | ? selectedElement?.height! * selectedElement?.scaleY 329 | : selectedElement?.height; 330 | 331 | setElementAttributes((prev) => ({ 332 | ...prev, 333 | width: scaledWidth?.toFixed(0).toString() || "", 334 | height: scaledHeight?.toFixed(0).toString() || "", 335 | })); 336 | }; 337 | 338 | // render canvas objects coming from storage on canvas 339 | export const renderCanvas = ({ 340 | fabricRef, 341 | canvasObjects, 342 | activeObjectRef, 343 | }: RenderCanvas) => { 344 | // clear canvas 345 | fabricRef.current?.clear(); 346 | 347 | // render all objects on canvas 348 | Array.from(canvasObjects, ([objectId, objectData]) => { 349 | /** 350 | * enlivenObjects() is used to render objects on canvas. 351 | * It takes two arguments: 352 | * 1. objectData: object data to render on canvas 353 | * 2. callback: callback function to execute after rendering objects 354 | * on canvas 355 | * 356 | * enlivenObjects: http://fabricjs.com/docs/fabric.util.html#.enlivenObjectEnlivables 357 | */ 358 | fabric.util.enlivenObjects( 359 | [objectData], 360 | (enlivenedObjects: fabric.Object[]) => { 361 | enlivenedObjects.forEach((enlivenedObj) => { 362 | // if element is active, keep it in active state so that it can be edited further 363 | if (activeObjectRef.current?.objectId === objectId) { 364 | fabricRef.current?.setActiveObject(enlivenedObj); 365 | } 366 | 367 | // add object to canvas 368 | fabricRef.current?.add(enlivenedObj); 369 | }); 370 | }, 371 | /** 372 | * specify namespace of the object for fabric to render it on canvas 373 | * A namespace is a string that is used to identify the type of 374 | * object. 375 | * 376 | * Fabric Namespace: http://fabricjs.com/docs/fabric.html 377 | */ 378 | "fabric" 379 | ); 380 | }); 381 | 382 | fabricRef.current?.renderAll(); 383 | }; 384 | 385 | // resize canvas dimensions on window resize 386 | export const handleResize = ({ canvas }: { canvas: fabric.Canvas | null }) => { 387 | const canvasElement = document.getElementById("canvas"); 388 | 389 | if (!canvasElement) return; 390 | if (!canvas) return; 391 | 392 | canvas.setDimensions({ 393 | width: canvasElement.clientWidth, 394 | height: canvasElement.clientHeight, 395 | }); 396 | }; 397 | 398 | // zoom canvas on mouse scroll 399 | export const handleCanvasZoom = ({ 400 | options, 401 | canvas, 402 | }: { 403 | options: fabric.IEvent & { e: WheelEvent }; 404 | canvas: fabric.Canvas; 405 | }) => { 406 | const delta = options.e?.deltaY; 407 | let zoom = canvas.getZoom(); 408 | 409 | // allow zooming to min 20% and max 100% 410 | const minZoom = 0.2; 411 | const maxZoom = 1; 412 | const zoomStep = 0.001; 413 | 414 | // calculate zoom based on mouse scroll wheel with min and max zoom 415 | zoom = Math.min(Math.max(minZoom, zoom + delta * zoomStep), maxZoom); 416 | 417 | // set zoom to canvas 418 | // zoomToPoint: http://fabricjs.com/docs/fabric.Canvas.html#zoomToPoint 419 | canvas.zoomToPoint({ x: options.e.offsetX, y: options.e.offsetY }, zoom); 420 | 421 | options.e.preventDefault(); 422 | options.e.stopPropagation(); 423 | }; 424 | --------------------------------------------------------------------------------