├── .eslintrc.json ├── app ├── favicon.ico ├── page.tsx ├── layout.tsx ├── globals.css ├── Room.tsx └── App.tsx ├── public ├── assets │ ├── favicon.ico │ ├── loader.gif │ ├── line.svg │ ├── hash.svg │ ├── delete.svg │ ├── rectangle.svg │ ├── circle.svg │ ├── polygon.svg │ ├── select.svg │ ├── CursorSVG.tsx │ ├── text.svg │ ├── reset.svg │ ├── back.svg │ ├── front.svg │ ├── comments.svg │ ├── image.svg │ ├── triangle.svg │ ├── group.svg │ ├── ungroup.svg │ ├── align-top.svg │ ├── align-bottom.svg │ ├── align-left.svg │ ├── align-vertical-center.svg │ ├── align-right.svg │ ├── align-horizontal-center.svg │ ├── logo.svg │ └── freeform.svg ├── vercel.svg └── next.svg ├── postcss.config.js ├── types ├── declaration.d.ts └── type.ts ├── .prettierrc.json ├── components ├── comments │ ├── Comments.tsx │ ├── PinnedComposer.tsx │ ├── PinnedThread.tsx │ ├── NewThreadCursor.tsx │ ├── CommentsOverlay.tsx │ └── NewThread.tsx ├── ui │ ├── collapsible.tsx │ ├── label.tsx │ ├── input.tsx │ ├── tooltip.tsx │ ├── button.tsx │ ├── select.tsx │ ├── context-menu.tsx │ └── dropdown-menu.tsx ├── Loader.tsx ├── settings │ ├── Export.tsx │ ├── Color.tsx │ ├── Dimensions.tsx │ └── Text.tsx ├── index.ts ├── reaction │ ├── FlyingReaction.tsx │ ├── ReactionButton.tsx │ └── index.module.css ├── cursor │ ├── LiveCursors.tsx │ ├── Cursor.tsx │ └── CursorChat.tsx ├── users │ ├── Avatar.tsx │ └── ActiveUsers.tsx ├── LeftSidebar.tsx ├── ShapesMenu.tsx ├── RightSidebar.tsx ├── Navbar.tsx └── Live.tsx ├── components.json ├── .gitignore ├── lib ├── useMaxZIndex.ts ├── utils.ts ├── key-events.ts ├── shapes.ts └── canvas.ts ├── .vscode └── settings.json ├── next.config.mjs ├── hooks └── useInterval.ts ├── tsconfig.json ├── tailwind.config.ts ├── package.json ├── liveblocks.config.ts ├── constants └── index.ts └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals"] 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavaScript-Mastery-Pro/figma-ts/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavaScript-Mastery-Pro/figma-ts/HEAD/public/assets/favicon.ico -------------------------------------------------------------------------------- /public/assets/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JavaScript-Mastery-Pro/figma-ts/HEAD/public/assets/loader.gif -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /types/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.module.css" { 2 | const classes: { [key: string]: string }; 3 | export default classes; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "jsxSingleQuote": true, 5 | "tabWidth": 2, 6 | "useTabs": false, 7 | "plugins": ["prettier-plugin-tailwindcss"] 8 | } 9 | -------------------------------------------------------------------------------- /public/assets/line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/hash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | 3 | /** 4 | * disable ssr to avoid pre-rendering issues of Next.js 5 | * 6 | * we're doing this because we're using a canvas element that can't be pre-rendered by Next.js on the server 7 | */ 8 | const App = dynamic(() => import("./App"), { ssr: false }); 9 | 10 | export default App; 11 | -------------------------------------------------------------------------------- /components/comments/Comments.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ClientSideSuspense } from "@liveblocks/react"; 4 | 5 | import { CommentsOverlay } from "@/components/comments/CommentsOverlay"; 6 | 7 | export const Comments = () => ( 8 | 9 | {() => } 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /public/assets/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 4 | 5 | const Collapsible = CollapsiblePrimitive.Root 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 12 | -------------------------------------------------------------------------------- /public/assets/rectangle.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 | } -------------------------------------------------------------------------------- /public/assets/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/polygon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | const Loader = () => ( 4 |
5 | loader 12 |

Loading...

13 |
14 | ); 15 | 16 | export default Loader; 17 | -------------------------------------------------------------------------------- /public/assets/select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/assets/CursorSVG.tsx: -------------------------------------------------------------------------------- 1 | function CursorSVG({ color }: { color: string }) { 2 | return ( 3 | 12 | 16 | 17 | ); 18 | } 19 | 20 | export default CursorSVG; 21 | -------------------------------------------------------------------------------- /public/assets/text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /components/settings/Export.tsx: -------------------------------------------------------------------------------- 1 | import { exportToPdf } from "@/lib/utils"; 2 | 3 | import { Button } from "../ui/button"; 4 | 5 | const Export = () => ( 6 |
7 |

Export

8 | 15 |
16 | ); 17 | 18 | export default Export; 19 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /public/assets/reset.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/index.ts: -------------------------------------------------------------------------------- 1 | import LiveCursors from "./cursor/LiveCursors"; 2 | import ReactionSelector from "./reaction/ReactionButton"; 3 | import FlyingReaction from "./reaction/FlyingReaction"; 4 | import Navbar from "./Navbar"; 5 | import LeftSidebar from "./LeftSidebar"; 6 | import RightSidebar from "./RightSidebar"; 7 | import CursorChat from "./cursor/CursorChat"; 8 | import Live from "./Live"; 9 | 10 | export { 11 | LiveCursors, 12 | ReactionSelector, 13 | FlyingReaction, 14 | Navbar, 15 | LeftSidebar, 16 | RightSidebar, 17 | CursorChat, 18 | Live, 19 | }; 20 | -------------------------------------------------------------------------------- /lib/useMaxZIndex.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { useThreads } from "@/liveblocks.config"; 4 | 5 | // Returns the highest z-index of all threads 6 | export const useMaxZIndex = () => { 7 | // get all threads 8 | const { threads } = useThreads(); 9 | 10 | // calculate the max z-index 11 | return useMemo(() => { 12 | let max = 0; 13 | for (const thread of threads) { 14 | // @ts-ignore 15 | if (thread.metadata.zIndex > max) { 16 | // @ts-ignore 17 | max = thread.metadata.zIndex; 18 | } 19 | } 20 | return max; 21 | }, [threads]); 22 | }; 23 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit", 6 | "source.addMissingImports": "explicit" 7 | }, 8 | "[typescriptreact]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[typescript]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[javascript]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "typescript.tsdk": "node_modules/typescript/lib", 18 | "editor.rulers": [110], 19 | "prettier.printWidth": 110 20 | } 21 | -------------------------------------------------------------------------------- /public/assets/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | webpack: (config) => { 4 | config.externals.push({ 5 | "utf-8-validate": "commonjs utf-8-validate", 6 | bufferutil: "commonjs bufferutil", 7 | canvas: "commonjs canvas", 8 | }); 9 | // config.infrastructureLogging = { debug: /PackFileCache/ }; 10 | return config; 11 | }, 12 | images: { 13 | remotePatterns: [ 14 | { 15 | protocol: "https", 16 | hostname: "liveblocks.io", 17 | port: "", 18 | }, 19 | ], 20 | }, 21 | typescript: { 22 | ignoreBuildErrors: true, 23 | }, 24 | }; 25 | 26 | export default nextConfig; 27 | -------------------------------------------------------------------------------- /public/assets/front.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /components/reaction/FlyingReaction.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./index.module.css"; 2 | 3 | type Props = { 4 | x: number; 5 | y: number; 6 | timestamp: number; 7 | value: string; 8 | }; 9 | 10 | const FlyingReaction = ({ x, y, timestamp, value }: Props) => ( 11 |
17 |
18 |
{value}
19 |
20 |
21 | ); 22 | 23 | export default FlyingReaction; 24 | -------------------------------------------------------------------------------- /components/cursor/LiveCursors.tsx: -------------------------------------------------------------------------------- 1 | import Cursor from "./Cursor"; 2 | import { COLORS } from "@/constants"; 3 | import { LiveCursorProps } from "@/types/type"; 4 | 5 | // display all other live cursors 6 | const LiveCursors = ({ others }: LiveCursorProps) => { 7 | return others.map(({ connectionId, presence }) => { 8 | if (presence == null || !presence?.cursor) { 9 | return null; 10 | } 11 | 12 | return ( 13 | 20 | ); 21 | }); 22 | }; 23 | 24 | export default LiveCursors; 25 | -------------------------------------------------------------------------------- /hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | // From Dan Abramov's blog: https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 4 | 5 | export default function useInterval(callback: () => void, delay: number) { 6 | const savedCallback = useRef<() => void>(callback); 7 | 8 | // Remember the latest callback. 9 | useEffect(() => { 10 | savedCallback.current = callback; 11 | }, [callback]); 12 | 13 | // Set up the interval. 14 | useEffect(() => { 15 | const tick = () => { 16 | savedCallback.current(); 17 | }; 18 | 19 | if (delay !== null) { 20 | let id = setInterval(tick, delay); 21 | return () => clearInterval(id); 22 | } 23 | }, [delay]); 24 | } 25 | -------------------------------------------------------------------------------- /public/assets/comments.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/assets/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/assets/triangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /components/cursor/Cursor.tsx: -------------------------------------------------------------------------------- 1 | import CursorSVG from "@/public/assets/CursorSVG"; 2 | 3 | type Props = { 4 | color: string; 5 | x: number; 6 | y: number; 7 | message?: string; 8 | }; 9 | 10 | const Cursor = ({ color, x, y, message }: Props) => ( 11 |
15 | 16 | 17 | {message && ( 18 |
22 |

23 | {message} 24 |

25 |
26 | )} 27 |
28 | ); 29 | 30 | export default Cursor; 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Work_Sans } from "next/font/google"; 2 | 3 | import "./globals.css"; 4 | import { TooltipProvider } from "@/components/ui/tooltip"; 5 | 6 | import Room from "./Room"; 7 | 8 | export const metadata = { 9 | title: "Figma Clone", 10 | description: 11 | "A minimalist Figma clone using fabric.js and Liveblocks for realtime collaboration", 12 | }; 13 | 14 | const workSans = Work_Sans({ 15 | subsets: ["latin"], 16 | variable: "--font-work-sans", 17 | weight: ["400", "600", "700"], 18 | }); 19 | 20 | const RootLayout = ({ children }: { children: React.ReactNode }) => ( 21 | 22 | 23 | 24 | {children} 25 | 26 | 27 | 28 | ); 29 | 30 | export default RootLayout; 31 | -------------------------------------------------------------------------------- /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 | @layer utilities { 14 | .no-ring { 15 | @apply outline-none ring-0 ring-offset-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-offset-0 !important; 16 | } 17 | 18 | .input-ring { 19 | @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; 20 | } 21 | 22 | .right-menu-content { 23 | @apply flex w-80 flex-col gap-y-1 border-none bg-primary-black py-4 text-white !important; 24 | } 25 | 26 | .right-menu-item { 27 | @apply flex justify-between px-3 py-2 hover:bg-primary-grey-200 !important; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | }, 24 | "target": "es2018", 25 | "typeRoots": ["./types"] 26 | }, 27 | "include": [ 28 | "next-env.d.ts", 29 | "**/*.ts", 30 | "**/*.tsx", 31 | ".next/types/**/*.ts", 32 | ".next/types/**/*.tsx", 33 | "./types/**/*.d.ts" 34 | ], 35 | "exclude": ["node_modules"] 36 | } 37 | -------------------------------------------------------------------------------- /components/users/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; 4 | 5 | type Props = { 6 | name: string; 7 | otherStyles?: string; 8 | }; 9 | 10 | const Avatar = ({ name, otherStyles }: Props) => ( 11 | <> 12 | 13 | 14 |
15 | {name} 21 |
22 |
23 | 24 | {name} 25 | 26 |
27 | 28 | ); 29 | 30 | export default Avatar; 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/assets/group.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/assets/ungroup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /components/settings/Color.tsx: -------------------------------------------------------------------------------- 1 | import { Label } from "../ui/label"; 2 | 3 | type Props = { 4 | inputRef: any; 5 | attribute: string; 6 | placeholder: string; 7 | attributeType: string; 8 | handleInputChange: (property: string, value: string) => void; 9 | }; 10 | 11 | const Color = ({ 12 | inputRef, 13 | attribute, 14 | placeholder, 15 | attributeType, 16 | handleInputChange, 17 | }: Props) => ( 18 |
19 |

{placeholder}

20 |
inputRef.current.click()} 23 | > 24 | handleInputChange(attributeType, e.target.value)} 29 | /> 30 | 31 | 34 |
35 |
36 | ); 37 | 38 | export default Color; 39 | -------------------------------------------------------------------------------- /components/reaction/ReactionButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | setReaction: (reaction: string) => void; 5 | }; 6 | 7 | const ReactionSelector = ({ setReaction }: Props) => ( 8 |
e.stopPropagation()} 11 | > 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | ); 20 | 21 | type ReactionButtonProps = { 22 | reaction: string; 23 | onSelect: (reaction: string) => void; 24 | }; 25 | 26 | const ReactionButton = ({ reaction, onSelect }: ReactionButtonProps) => ( 27 | 33 | ); 34 | 35 | export default ReactionSelector; 36 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{ts,tsx}", 7 | "./components/**/*.{ts,tsx}", 8 | "./app/**/*.{ts,tsx}", 9 | "./src/**/*.{ts,tsx}", 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | primary: { 23 | black: "#14181F", 24 | green: "#56FFA6", 25 | grey: { 26 | 100: "#2B303B", 27 | 200: "#202731", 28 | 300: "#C4D3ED", 29 | }, 30 | }, 31 | }, 32 | keyframes: { 33 | "accordion-down": { 34 | from: { height: "0" }, 35 | to: { height: "var(--radix-accordion-content-height)" }, 36 | }, 37 | "accordion-up": { 38 | from: { height: "var(--radix-accordion-content-height)" }, 39 | to: { height: "0" }, 40 | }, 41 | }, 42 | animation: { 43 | "accordion-down": "accordion-down 0.2s ease-out", 44 | "accordion-up": "accordion-up 0.2s ease-out", 45 | }, 46 | }, 47 | }, 48 | plugins: [require("tailwindcss-animate")], 49 | } satisfies Config; 50 | 51 | export default config; 52 | -------------------------------------------------------------------------------- /app/Room.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LiveMap } from "@liveblocks/client"; 4 | import { ClientSideSuspense } from "@liveblocks/react"; 5 | 6 | import Loader from "@/components/Loader"; 7 | import { RoomProvider } from "@/liveblocks.config"; 8 | 9 | const Room = ({ children }: { children: React.ReactNode }) => { 10 | return ( 11 | 34 | }> 35 | {() => children} 36 | 37 | 38 | ); 39 | } 40 | 41 | export default Room; -------------------------------------------------------------------------------- /public/assets/align-top.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/align-bottom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/align-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/align-vertical-center.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /components/settings/Dimensions.tsx: -------------------------------------------------------------------------------- 1 | import { Label } from "../ui/label"; 2 | import { Input } from "../ui/input"; 3 | 4 | const dimensionsOptions = [ 5 | { label: "W", property: "width" }, 6 | { label: "H", property: "height" }, 7 | ]; 8 | 9 | type Props = { 10 | width: string; 11 | height: string; 12 | isEditingRef: React.MutableRefObject; 13 | handleInputChange: (property: string, value: string) => void; 14 | }; 15 | 16 | const Dimensions = ({ width, height, isEditingRef, handleInputChange }: Props) => ( 17 |
18 |
19 | {dimensionsOptions.map((item) => ( 20 |
24 | 27 | handleInputChange(item.property, e.target.value)} 35 | onBlur={(e) => { 36 | isEditingRef.current = false 37 | }} 38 | /> 39 |
40 | ))} 41 |
42 |
43 | ); 44 | 45 | export default Dimensions; 46 | -------------------------------------------------------------------------------- /public/assets/align-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/align-horizontal-center.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /components/comments/PinnedComposer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { Composer, ComposerProps } from "@liveblocks/react-comments"; 5 | 6 | type Props = { 7 | onComposerSubmit: ComposerProps["onComposerSubmit"]; 8 | }; 9 | 10 | const PinnedComposer = ({ onComposerSubmit, ...props }: Props) => { 11 | return ( 12 |
13 |
14 | someone 21 |
22 |
23 | {/** 24 | * We're using the Composer component to create a new comment. 25 | * Liveblocks provides a Composer component that allows to 26 | * create/edit/delete comments. 27 | * 28 | * Composer: https://liveblocks.io/docs/api-reference/liveblocks-react-comments#Composer 29 | */} 30 | { 34 | e.stopPropagation() 35 | }} 36 | /> 37 |
38 |
39 | ); 40 | }; 41 | 42 | export default PinnedComposer; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tigma", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@liveblocks/client": "^1.9.7", 13 | "@liveblocks/react": "^1.9.7", 14 | "@liveblocks/react-comments": "^1.9.7", 15 | "@radix-ui/react-collapsible": "^1.0.3", 16 | "@radix-ui/react-context-menu": "^2.1.5", 17 | "@radix-ui/react-dropdown-menu": "^2.0.6", 18 | "@radix-ui/react-label": "^2.0.2", 19 | "@radix-ui/react-select": "^2.0.0", 20 | "@radix-ui/react-slot": "^1.0.2", 21 | "@radix-ui/react-tooltip": "^1.0.7", 22 | "class-variance-authority": "^0.7.0", 23 | "clsx": "^2.1.0", 24 | "fabric": "^5.3.0", 25 | "jspdf": "^2.5.1", 26 | "lucide-react": "^0.316.0", 27 | "next": "14.1.0", 28 | "react": "^18", 29 | "react-dom": "^18", 30 | "tailwind-merge": "^2.2.1", 31 | "tailwindcss-animate": "^1.0.7", 32 | "uuid": "^9.0.1" 33 | }, 34 | "devDependencies": { 35 | "@types/fabric": "^5.3.6", 36 | "@types/node": "^20", 37 | "@types/react": "^18", 38 | "@types/react-dom": "^18", 39 | "@types/uuid": "^9.0.8", 40 | "autoprefixer": "^10.4.17", 41 | "eslint": "^8", 42 | "eslint-config-next": "14.1.0", 43 | "eslint-plugin-prettier": "^5.1.3", 44 | "postcss": "^8", 45 | "prettier": "^3.2.4", 46 | "prettier-plugin-tailwindcss": "^0.5.11", 47 | "tailwindcss": "^3.4.1", 48 | "typescript": "^5" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /components/LeftSidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMemo } from "react"; 4 | import Image from "next/image"; 5 | 6 | import { getShapeInfo } from "@/lib/utils"; 7 | 8 | const LeftSidebar = ({ allShapes }: { allShapes: Array }) => { 9 | // memoize the result of this function so that it doesn't change on every render but only when there are new shapes 10 | const memoizedShapes = useMemo( 11 | () => ( 12 |
13 |

Layers

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

{info.name}

31 |
32 | ); 33 | })} 34 |
35 |
36 | ), 37 | [allShapes?.length] 38 | ); 39 | 40 | return memoizedShapes; 41 | }; 42 | 43 | export default LeftSidebar; 44 | -------------------------------------------------------------------------------- /components/users/ActiveUsers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMemo } from "react"; 4 | 5 | import { generateRandomName } from "@/lib/utils"; 6 | import { useOthers, useSelf } from "@/liveblocks.config"; 7 | 8 | import Avatar from "./Avatar"; 9 | 10 | const ActiveUsers = () => { 11 | /** 12 | * useOthers returns the list of other users in the room. 13 | * 14 | * useOthers: https://liveblocks.io/docs/api-reference/liveblocks-react#useOthers 15 | */ 16 | const others = useOthers(); 17 | 18 | /** 19 | * useSelf returns the current user details in the room 20 | * 21 | * useSelf: https://liveblocks.io/docs/api-reference/liveblocks-react#useSelf 22 | */ 23 | const currentUser = useSelf(); 24 | 25 | // memoize the result of this function so that it doesn't change on every render but only when there are new users joining the room 26 | const memoizedUsers = useMemo(() => { 27 | const hasMoreUsers = others.length > 2; 28 | 29 | return ( 30 |
31 | {currentUser && ( 32 | 33 | )} 34 | 35 | {others.slice(0, 2).map(({ connectionId }) => ( 36 | 41 | ))} 42 | 43 | {hasMoreUsers && ( 44 |
45 | +{others.length - 2} 46 |
47 | )} 48 |
49 | ); 50 | }, [others.length]); 51 | 52 | return memoizedUsers; 53 | }; 54 | 55 | export default ActiveUsers; 56 | -------------------------------------------------------------------------------- /components/reaction/index.module.css: -------------------------------------------------------------------------------- 1 | .goUp0 { 2 | opacity: 0; 3 | animation: goUpAnimation0 2s, fadeOut 2s; 4 | } 5 | 6 | @keyframes goUpAnimation0 { 7 | from { 8 | transform: translate(0px, 0px); 9 | } 10 | 11 | to { 12 | transform: translate(0px, -400px); 13 | } 14 | } 15 | 16 | .goUp1 { 17 | opacity: 0; 18 | animation: goUpAnimation1 2s, fadeOut 2s; 19 | } 20 | 21 | @keyframes goUpAnimation1 { 22 | from { 23 | transform: translate(0px, 0px); 24 | } 25 | 26 | to { 27 | transform: translate(0px, -300px); 28 | } 29 | } 30 | 31 | .goUp2 { 32 | opacity: 0; 33 | animation: goUpAnimation2 2s, fadeOut 2s; 34 | } 35 | 36 | @keyframes goUpAnimation2 { 37 | from { 38 | transform: translate(0px, 0px); 39 | } 40 | 41 | to { 42 | transform: translate(0px, -200px); 43 | } 44 | } 45 | 46 | .leftRight0 { 47 | animation: leftRightAnimation0 0.3s alternate infinite ease-in-out; 48 | } 49 | 50 | @keyframes leftRightAnimation0 { 51 | from { 52 | transform: translate(0px, 0px); 53 | } 54 | 55 | to { 56 | transform: translate(50px, 0px); 57 | } 58 | } 59 | 60 | .leftRight1 { 61 | animation: leftRightAnimation1 0.3s alternate infinite ease-in-out; 62 | } 63 | 64 | @keyframes leftRightAnimation1 { 65 | from { 66 | transform: translate(0px, 0px); 67 | } 68 | 69 | to { 70 | transform: translate(100px, 0px); 71 | } 72 | } 73 | 74 | .leftRight2 { 75 | animation: leftRightAnimation2 0.3s alternate infinite ease-in-out; 76 | } 77 | 78 | @keyframes leftRightAnimation2 { 79 | from { 80 | transform: translate(0px, 0px); 81 | } 82 | 83 | to { 84 | transform: translate(-50px, 0px); 85 | } 86 | } 87 | 88 | @keyframes fadeOut { 89 | from { 90 | opacity: 1; 91 | } 92 | 93 | to { 94 | opacity: 0; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /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/CursorChat.tsx: -------------------------------------------------------------------------------- 1 | import { CursorChatProps, CursorMode } from "@/types/type"; 2 | import CursorSVG from "@/public/assets/CursorSVG"; 3 | 4 | const CursorChat = ({ cursor, cursorState, setCursorState, updateMyPresence }: CursorChatProps) => { 5 | const handleChange = (e: React.ChangeEvent) => { 6 | updateMyPresence({ message: e.target.value }); 7 | setCursorState({ 8 | mode: CursorMode.Chat, 9 | previousMessage: null, 10 | message: e.target.value, 11 | }); 12 | }; 13 | 14 | const handleKeyDown = (e: React.KeyboardEvent) => { 15 | if (e.key === "Enter") { 16 | setCursorState({ 17 | mode: CursorMode.Chat, 18 | // @ts-ignore 19 | previousMessage: cursorState.message, 20 | message: "", 21 | }); 22 | } else if (e.key === "Escape") { 23 | setCursorState({ 24 | mode: CursorMode.Hidden, 25 | }); 26 | } 27 | }; 28 | 29 | return ( 30 |
36 | {/* Show message input when cursor is in chat mode */} 37 | {cursorState.mode === CursorMode.Chat && ( 38 | <> 39 | {/* Custom Cursor shape */} 40 | 41 | 42 |
e.stopPropagation()} 45 | style={{ 46 | borderRadius: 20, 47 | }} 48 | > 49 | {/** 50 | * if there is a previous message, show it above the input 51 | * 52 | * We're doing this because when user press enter, we want to 53 | * show the previous message at top and the input at bottom 54 | */} 55 | {cursorState.previousMessage &&
{cursorState.previousMessage}
} 56 | 65 |
66 | 67 | )} 68 |
69 | ); 70 | }; 71 | 72 | export default CursorChat; 73 | -------------------------------------------------------------------------------- /components/ShapesMenu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | 5 | import { ShapesMenuProps } from "@/types/type"; 6 | 7 | import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "./ui/dropdown-menu"; 8 | import { Button } from "./ui/button"; 9 | 10 | const ShapesMenu = ({ 11 | item, 12 | activeElement, 13 | handleActiveElement, 14 | handleImageUpload, 15 | imageInputRef, 16 | }: ShapesMenuProps) => { 17 | const isDropdownElem = item.value.some((elem) => elem?.value === activeElement.value); 18 | 19 | return ( 20 | <> 21 | 22 | 23 | 31 | 32 | 33 | 34 | {item.value.map((elem) => ( 35 | 61 | ))} 62 | 63 | 64 | 65 | 72 | 73 | ); 74 | }; 75 | 76 | export default ShapesMenu; 77 | -------------------------------------------------------------------------------- /components/comments/PinnedThread.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useMemo, useState } from "react"; 5 | import { ThreadData } from "@liveblocks/client"; 6 | import { Thread } from "@liveblocks/react-comments"; 7 | 8 | import { ThreadMetadata } from "@/liveblocks.config"; 9 | 10 | type Props = { 11 | thread: ThreadData; 12 | onFocus: (threadId: string) => void; 13 | }; 14 | 15 | export const PinnedThread = ({ thread, onFocus, ...props }: Props) => { 16 | // Open pinned threads that have just been created 17 | const startMinimized = useMemo( 18 | () => Number(new Date()) - Number(new Date(thread.createdAt)) > 100, 19 | [thread] 20 | ); 21 | 22 | const [minimized, setMinimized] = useState(startMinimized); 23 | 24 | /** 25 | * memoize the result of this function so that it doesn't change on every render but only when the thread changes 26 | * Memo is used to optimize performance and avoid unnecessary re-renders. 27 | * 28 | * useMemo: https://react.dev/reference/react/useMemo 29 | */ 30 | 31 | const memoizedContent = useMemo( 32 | () => ( 33 |
{ 37 | onFocus(thread.id); 38 | 39 | // check if click is on/in the composer 40 | if ( 41 | e.target && 42 | e.target.classList.contains("lb-icon") && 43 | e.target.classList.contains("lb-button-icon") 44 | ) { 45 | return; 46 | } 47 | 48 | setMinimized(!minimized); 49 | }} 50 | > 51 |
55 | Dummy Name 63 |
64 | {!minimized ? ( 65 |
66 | { 70 | e.stopPropagation(); 71 | }} 72 | /> 73 |
74 | ) : null} 75 |
76 | ), 77 | [thread.comments.length, minimized] 78 | ); 79 | 80 | return <>{memoizedContent}; 81 | }; 82 | -------------------------------------------------------------------------------- /components/comments/NewThreadCursor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import * as Portal from "@radix-ui/react-portal"; 5 | 6 | const DEFAULT_CURSOR_POSITION = -10000; 7 | 8 | // display a custom cursor when placing a new thread 9 | const NewThreadCursor = ({ display }: { display: boolean }) => { 10 | const [coords, setCoords] = useState({ 11 | x: DEFAULT_CURSOR_POSITION, 12 | y: DEFAULT_CURSOR_POSITION, 13 | }); 14 | 15 | useEffect(() => { 16 | const updatePosition = (e: MouseEvent) => { 17 | // get canvas element 18 | const canvas = document.getElementById("canvas"); 19 | 20 | if (canvas) { 21 | /** 22 | * getBoundingClientRect returns the size of an element and its position relative to the viewport 23 | * 24 | * getBoundingClientRect: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect 25 | */ 26 | const canvasRect = canvas.getBoundingClientRect(); 27 | 28 | // check if the mouse is outside the canvas 29 | // if so, hide the custom comment cursor 30 | if ( 31 | e.clientX < canvasRect.left || 32 | e.clientX > canvasRect.right || 33 | e.clientY < canvasRect.top || 34 | e.clientY > canvasRect.bottom 35 | ) { 36 | setCoords({ 37 | x: DEFAULT_CURSOR_POSITION, 38 | y: DEFAULT_CURSOR_POSITION, 39 | }); 40 | return; 41 | } 42 | } 43 | 44 | // set the coordinates of the cursor 45 | setCoords({ 46 | x: e.clientX, 47 | y: e.clientY, 48 | }); 49 | }; 50 | 51 | document.addEventListener("mousemove", updatePosition, false); 52 | document.addEventListener("mouseenter", updatePosition, false); 53 | 54 | return () => { 55 | document.removeEventListener("mousemove", updatePosition); 56 | document.removeEventListener("mouseenter", updatePosition); 57 | }; 58 | }, []); 59 | 60 | useEffect(() => { 61 | if (display) { 62 | document.documentElement.classList.add("hide-cursor"); 63 | } else { 64 | document.documentElement.classList.remove("hide-cursor"); 65 | } 66 | }, [display]); 67 | 68 | if (!display) { 69 | return null; 70 | } 71 | 72 | return ( 73 | // Portal.Root is used to render a component outside of its parent component 74 | 75 |
81 | 82 | ); 83 | }; 84 | 85 | export default NewThreadCursor; 86 | -------------------------------------------------------------------------------- /components/comments/CommentsOverlay.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useRef } from "react"; 4 | import { ThreadData } from "@liveblocks/client"; 5 | 6 | import { ThreadMetadata, useEditThreadMetadata, useThreads, useUser } from "@/liveblocks.config"; 7 | import { useMaxZIndex } from "@/lib/useMaxZIndex"; 8 | 9 | import { PinnedThread } from "./PinnedThread"; 10 | 11 | type OverlayThreadProps = { 12 | thread: ThreadData; 13 | maxZIndex: number; 14 | }; 15 | 16 | export const CommentsOverlay = () => { 17 | /** 18 | * We're using the useThreads hook to get the list of threads 19 | * in the room. 20 | * 21 | * useThreads: https://liveblocks.io/docs/api-reference/liveblocks-react#useThreads 22 | */ 23 | const { threads } = useThreads(); 24 | 25 | // get the max z-index of a thread 26 | const maxZIndex = useMaxZIndex(); 27 | 28 | return ( 29 |
30 | {threads 31 | .filter((thread) => !thread.metadata.resolved) 32 | .map((thread) => ( 33 | 34 | ))} 35 |
36 | ); 37 | }; 38 | 39 | const OverlayThread = ({ thread, maxZIndex }: OverlayThreadProps) => { 40 | /** 41 | * We're using the useEditThreadMetadata hook to edit the metadata 42 | * of a thread. 43 | * 44 | * useEditThreadMetadata: https://liveblocks.io/docs/api-reference/liveblocks-react#useEditThreadMetadata 45 | */ 46 | const editThreadMetadata = useEditThreadMetadata(); 47 | 48 | /** 49 | * We're using the useUser hook to get the user of the thread. 50 | * 51 | * useUser: https://liveblocks.io/docs/api-reference/liveblocks-react#useUser 52 | */ 53 | const { isLoading } = useUser(thread.comments[0].userId); 54 | 55 | // We're using a ref to get the thread element to position it 56 | const threadRef = useRef(null); 57 | 58 | // If other thread(s) above, increase z-index on last element updated 59 | const handleIncreaseZIndex = useCallback(() => { 60 | if (maxZIndex === thread.metadata.zIndex) { 61 | return; 62 | } 63 | 64 | // Update the z-index of the thread in the room 65 | editThreadMetadata({ 66 | threadId: thread.id, 67 | metadata: { 68 | zIndex: maxZIndex + 1, 69 | }, 70 | }); 71 | }, [thread, editThreadMetadata, maxZIndex]); 72 | 73 | if (isLoading) { 74 | return null; 75 | } 76 | 77 | return ( 78 |
86 | {/* render the thread */} 87 | 88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import jsPDF from "jspdf"; 2 | import { twMerge } from "tailwind-merge"; 3 | import { type ClassValue, clsx } from "clsx"; 4 | 5 | const adjectives = [ 6 | "Happy", 7 | "Creative", 8 | "Energetic", 9 | "Lively", 10 | "Dynamic", 11 | "Radiant", 12 | "Joyful", 13 | "Vibrant", 14 | "Cheerful", 15 | "Sunny", 16 | "Sparkling", 17 | "Bright", 18 | "Shining", 19 | ]; 20 | 21 | const animals = [ 22 | "Dolphin", 23 | "Tiger", 24 | "Elephant", 25 | "Penguin", 26 | "Kangaroo", 27 | "Panther", 28 | "Lion", 29 | "Cheetah", 30 | "Giraffe", 31 | "Hippopotamus", 32 | "Monkey", 33 | "Panda", 34 | "Crocodile", 35 | ]; 36 | 37 | export function cn(...inputs: ClassValue[]) { 38 | return twMerge(clsx(inputs)); 39 | } 40 | 41 | export function generateRandomName(): string { 42 | const randomAdjective = 43 | adjectives[Math.floor(Math.random() * adjectives.length)]; 44 | const randomAnimal = animals[Math.floor(Math.random() * animals.length)]; 45 | 46 | return `${randomAdjective} ${randomAnimal}`; 47 | } 48 | 49 | export const getShapeInfo = (shapeType: string) => { 50 | switch (shapeType) { 51 | case "rect": 52 | return { 53 | icon: "/assets/rectangle.svg", 54 | name: "Rectangle", 55 | }; 56 | 57 | case "circle": 58 | return { 59 | icon: "/assets/circle.svg", 60 | name: "Circle", 61 | }; 62 | 63 | case "triangle": 64 | return { 65 | icon: "/assets/triangle.svg", 66 | name: "Triangle", 67 | }; 68 | 69 | case "line": 70 | return { 71 | icon: "/assets/line.svg", 72 | name: "Line", 73 | }; 74 | 75 | case "i-text": 76 | return { 77 | icon: "/assets/text.svg", 78 | name: "Text", 79 | }; 80 | 81 | case "image": 82 | return { 83 | icon: "/assets/image.svg", 84 | name: "Image", 85 | }; 86 | 87 | case "freeform": 88 | return { 89 | icon: "/assets/freeform.svg", 90 | name: "Free Drawing", 91 | }; 92 | 93 | default: 94 | return { 95 | icon: "/assets/rectangle.svg", 96 | name: shapeType, 97 | }; 98 | } 99 | }; 100 | 101 | export const exportToPdf = () => { 102 | const canvas = document.querySelector("canvas"); 103 | 104 | if (!canvas) return; 105 | 106 | // use jspdf 107 | const doc = new jsPDF({ 108 | orientation: "landscape", 109 | unit: "px", 110 | format: [canvas.width, canvas.height], 111 | }); 112 | 113 | // get the canvas data url 114 | const data = canvas.toDataURL(); 115 | 116 | // add the image to the pdf 117 | doc.addImage(data, "PNG", 0, 0, canvas.width, canvas.height); 118 | 119 | // download the pdf 120 | doc.save("canvas.pdf"); 121 | }; 122 | -------------------------------------------------------------------------------- /components/RightSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useRef } from "react"; 2 | 3 | import { RightSidebarProps } from "@/types/type"; 4 | import { bringElement, modifyShape } from "@/lib/shapes"; 5 | 6 | import Text from "./settings/Text"; 7 | import Color from "./settings/Color"; 8 | import Export from "./settings/Export"; 9 | import Dimensions from "./settings/Dimensions"; 10 | 11 | const RightSidebar = ({ 12 | elementAttributes, 13 | setElementAttributes, 14 | fabricRef, 15 | activeObjectRef, 16 | isEditingRef, 17 | syncShapeInStorage, 18 | }: RightSidebarProps) => { 19 | const colorInputRef = useRef(null); 20 | const strokeInputRef = useRef(null); 21 | 22 | const handleInputChange = (property: string, value: string) => { 23 | if (!isEditingRef.current) isEditingRef.current = true; 24 | 25 | setElementAttributes((prev) => ({ ...prev, [property]: value })); 26 | 27 | modifyShape({ 28 | canvas: fabricRef.current as fabric.Canvas, 29 | property, 30 | value, 31 | activeObjectRef, 32 | syncShapeInStorage, 33 | }); 34 | }; 35 | 36 | // memoize the content of the right sidebar to avoid re-rendering on every mouse actions 37 | const memoizedContent = useMemo( 38 | () => ( 39 |
40 |

Design

41 | 42 | Make changes to canvas as you like 43 | 44 | 45 | 51 | 52 | 59 | 60 | 67 | 68 | 75 | 76 | 77 |
78 | ), 79 | [elementAttributes] 80 | ); // only re-render when elementAttributes changes 81 | 82 | return memoizedContent; 83 | }; 84 | 85 | export default RightSidebar; 86 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { memo } from "react"; 5 | 6 | import { navElements } from "@/constants"; 7 | import { ActiveElement, NavbarProps } from "@/types/type"; 8 | 9 | import { Button } from "./ui/button"; 10 | import ShapesMenu from "./ShapesMenu"; 11 | import ActiveUsers from "./users/ActiveUsers"; 12 | import { NewThread } from "./comments/NewThread"; 13 | 14 | const Navbar = ({ activeElement, imageInputRef, handleImageUpload, handleActiveElement }: NavbarProps) => { 15 | const isActive = (value: string | Array) => 16 | (activeElement && activeElement.value === value) || 17 | (Array.isArray(value) && value.some((val) => val?.value === activeElement?.value)); 18 | 19 | return ( 20 | 72 | ); 73 | }; 74 | 75 | export default memo(Navbar, (prevProps, nextProps) => prevProps.activeElement === nextProps.activeElement); 76 | -------------------------------------------------------------------------------- /components/settings/Text.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | fontFamilyOptions, 3 | fontSizeOptions, 4 | fontWeightOptions, 5 | } from "@/constants"; 6 | 7 | import { 8 | Select, 9 | SelectContent, 10 | SelectItem, 11 | SelectTrigger, 12 | SelectValue, 13 | } from "../ui/select"; 14 | 15 | const selectConfigs = [ 16 | { 17 | property: "fontFamily", 18 | placeholder: "Choose a font", 19 | options: fontFamilyOptions, 20 | }, 21 | { property: "fontSize", placeholder: "30", options: fontSizeOptions }, 22 | { 23 | property: "fontWeight", 24 | placeholder: "Semibold", 25 | options: fontWeightOptions, 26 | }, 27 | ]; 28 | 29 | type TextProps = { 30 | fontFamily: string; 31 | fontSize: string; 32 | fontWeight: string; 33 | handleInputChange: (property: string, value: string) => void; 34 | }; 35 | 36 | const Text = ({ 37 | fontFamily, 38 | fontSize, 39 | fontWeight, 40 | handleInputChange, 41 | }: TextProps) => ( 42 |
43 |

Text

44 | 45 |
46 | {RenderSelect({ 47 | config: selectConfigs[0], 48 | fontSize, 49 | fontWeight, 50 | fontFamily, 51 | handleInputChange, 52 | })} 53 | 54 |
55 | {selectConfigs.slice(1).map((config) => 56 | RenderSelect({ 57 | config, 58 | fontSize, 59 | fontWeight, 60 | fontFamily, 61 | handleInputChange, 62 | }) 63 | )} 64 |
65 |
66 |
67 | ); 68 | 69 | type Props = { 70 | config: { 71 | property: string; 72 | placeholder: string; 73 | options: { label: string; value: string }[]; 74 | }; 75 | fontSize: string; 76 | fontWeight: string; 77 | fontFamily: string; 78 | handleInputChange: (property: string, value: string) => void; 79 | }; 80 | 81 | const RenderSelect = ({ 82 | config, 83 | fontSize, 84 | fontWeight, 85 | fontFamily, 86 | handleInputChange, 87 | }: Props) => ( 88 | 122 | ); 123 | 124 | export default Text; 125 | -------------------------------------------------------------------------------- /liveblocks.config.ts: -------------------------------------------------------------------------------- 1 | import { LiveMap, createClient } from "@liveblocks/client"; 2 | import { createRoomContext } from "@liveblocks/react"; 3 | 4 | const client = createClient({ 5 | throttle: 16, 6 | publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!, 7 | }); 8 | 9 | // Presence represents the properties that exist on every user in the Room 10 | // and that will automatically be kept in sync. Accessible through the 11 | // `user.presence` property. Must be JSON-serializable. 12 | type Presence = { 13 | // cursor: { x: number, y: number } | null, 14 | // ... 15 | }; 16 | 17 | // Optionally, Storage represents the shared document that persists in the 18 | // Room, even after all users leave. Fields under Storage typically are 19 | // LiveList, LiveMap, LiveObject instances, for which updates are 20 | // automatically persisted and synced to all connected clients. 21 | type Storage = { 22 | // author: LiveObject<{ firstName: string, lastName: string }>, 23 | // ... 24 | canvasObjects: LiveMap; 25 | }; 26 | 27 | // Optionally, UserMeta represents static/readonly metadata on each user, as 28 | // provided by your own custom auth back end (if used). Useful for data that 29 | // will not change during a session, like a user's name or avatar. 30 | type UserMeta = { 31 | // id?: string, // Accessible through `user.id` 32 | // info?: Json, // Accessible through `user.info` 33 | }; 34 | 35 | // Optionally, the type of custom events broadcast and listened to in this 36 | // room. Use a union for multiple events. Must be JSON-serializable. 37 | type RoomEvent = { 38 | // type: "NOTIFICATION", 39 | // ... 40 | }; 41 | 42 | // Optionally, when using Comments, ThreadMetadata represents metadata on 43 | // each thread. Can only contain booleans, strings, and numbers. 44 | export type ThreadMetadata = { 45 | resolved: boolean; 46 | zIndex: number; 47 | time?: number; 48 | x: number; 49 | y: number; 50 | }; 51 | 52 | export const { 53 | suspense: { 54 | RoomProvider, 55 | useRoom, 56 | useMyPresence, 57 | useUpdateMyPresence, 58 | useSelf, 59 | useOthers, 60 | useOthersMapped, 61 | useOthersConnectionIds, 62 | useOther, 63 | useBroadcastEvent, 64 | useEventListener, 65 | useErrorListener, 66 | useStorage, 67 | useObject, 68 | useMap, 69 | useList, 70 | useBatch, 71 | useHistory, 72 | useUndo, 73 | useRedo, 74 | useCanUndo, 75 | useCanRedo, 76 | useMutation, 77 | useStatus, 78 | useLostConnectionListener, 79 | useThreads, 80 | useUser, 81 | useCreateThread, 82 | useEditThreadMetadata, 83 | useCreateComment, 84 | useEditComment, 85 | useDeleteComment, 86 | useAddReaction, 87 | useRemoveReaction, 88 | }, 89 | } = createRoomContext(client, { 90 | async resolveUsers({ userIds }) { 91 | // Used only for Comments. Return a list of user information retrieved 92 | // from `userIds`. This info is used in comments, mentions etc. 93 | 94 | // const usersData = await __fetchUsersFromDB__(userIds); 95 | // 96 | // return usersData.map((userData) => ({ 97 | // name: userData.name, 98 | // avatar: userData.avatar.src, 99 | // })); 100 | 101 | return []; 102 | }, 103 | async resolveMentionSuggestions({ text, roomId }) { 104 | // Used only for Comments. Return a list of userIds that match `text`. 105 | // These userIds are used to create a mention list when typing in the 106 | // composer. 107 | // 108 | // For example when you type "@jo", `text` will be `"jo"`, and 109 | // you should to return an array with John and Joanna's userIds: 110 | // ["john@example.com", "joanna@example.com"] 111 | 112 | // const userIds = await __fetchAllUserIdsFromDB__(roomId); 113 | // 114 | // Return all userIds if no `text` 115 | // if (!text) { 116 | // return userIds; 117 | // } 118 | // 119 | // Otherwise, filter userIds for the search `text` and return 120 | // return userIds.filter((userId) => 121 | // userId.toLowerCase().includes(text.toLowerCase()) 122 | // ); 123 | 124 | return []; 125 | }, 126 | }); 127 | -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | export const COLORS = ["#DC2626", "#D97706", "#059669", "#7C3AED", "#DB2777"]; 2 | 3 | export const shapeElements = [ 4 | { 5 | icon: "/assets/rectangle.svg", 6 | name: "Rectangle", 7 | value: "rectangle", 8 | }, 9 | { 10 | icon: "/assets/circle.svg", 11 | name: "Circle", 12 | value: "circle", 13 | }, 14 | { 15 | icon: "/assets/triangle.svg", 16 | name: "Triangle", 17 | value: "triangle", 18 | }, 19 | { 20 | icon: "/assets/line.svg", 21 | name: "Line", 22 | value: "line", 23 | }, 24 | { 25 | icon: "/assets/image.svg", 26 | name: "Image", 27 | value: "image", 28 | }, 29 | { 30 | icon: "/assets/freeform.svg", 31 | name: "Free Drawing", 32 | value: "freeform", 33 | }, 34 | ]; 35 | 36 | export const navElements = [ 37 | { 38 | icon: "/assets/select.svg", 39 | name: "Select", 40 | value: "select", 41 | }, 42 | { 43 | icon: "/assets/rectangle.svg", 44 | name: "Rectangle", 45 | value: shapeElements, 46 | }, 47 | { 48 | icon: "/assets/text.svg", 49 | value: "text", 50 | name: "Text", 51 | }, 52 | { 53 | icon: "/assets/delete.svg", 54 | value: "delete", 55 | name: "Delete", 56 | }, 57 | { 58 | icon: "/assets/reset.svg", 59 | value: "reset", 60 | name: "Reset", 61 | }, 62 | { 63 | icon: "/assets/comments.svg", 64 | value: "comments", 65 | name: "Comments", 66 | }, 67 | ]; 68 | 69 | export const defaultNavElement = { 70 | icon: "/assets/select.svg", 71 | name: "Select", 72 | value: "select", 73 | }; 74 | 75 | export const directionOptions = [ 76 | { label: "Bring to Front", value: "front", icon: "/assets/front.svg" }, 77 | { label: "Send to Back", value: "back", icon: "/assets/back.svg" }, 78 | ]; 79 | 80 | export const fontFamilyOptions = [ 81 | { value: "Helvetica", label: "Helvetica" }, 82 | { value: "Times New Roman", label: "Times New Roman" }, 83 | { value: "Comic Sans MS", label: "Comic Sans MS" }, 84 | { value: "Brush Script MT", label: "Brush Script MT" }, 85 | ]; 86 | 87 | export const fontSizeOptions = [ 88 | { 89 | value: "10", 90 | label: "10", 91 | }, 92 | { 93 | value: "12", 94 | label: "12", 95 | }, 96 | { 97 | value: "14", 98 | label: "14", 99 | }, 100 | { 101 | value: "16", 102 | label: "16", 103 | }, 104 | { 105 | value: "18", 106 | label: "18", 107 | }, 108 | { 109 | value: "20", 110 | label: "20", 111 | }, 112 | { 113 | value: "22", 114 | label: "22", 115 | }, 116 | { 117 | value: "24", 118 | label: "24", 119 | }, 120 | { 121 | value: "26", 122 | label: "26", 123 | }, 124 | { 125 | value: "28", 126 | label: "28", 127 | }, 128 | { 129 | value: "30", 130 | label: "30", 131 | }, 132 | { 133 | value: "32", 134 | label: "32", 135 | }, 136 | { 137 | value: "34", 138 | label: "34", 139 | }, 140 | { 141 | value: "36", 142 | label: "36", 143 | }, 144 | ]; 145 | 146 | export const fontWeightOptions = [ 147 | { 148 | value: "400", 149 | label: "Normal", 150 | }, 151 | { 152 | value: "500", 153 | label: "Semibold", 154 | }, 155 | { 156 | value: "600", 157 | label: "Bold", 158 | }, 159 | ]; 160 | 161 | export const alignmentOptions = [ 162 | { value: "left", label: "Align Left", icon: "/assets/align-left.svg" }, 163 | { 164 | value: "horizontalCenter", 165 | label: "Align Horizontal Center", 166 | icon: "/assets/align-horizontal-center.svg", 167 | }, 168 | { value: "right", label: "Align Right", icon: "/assets/align-right.svg" }, 169 | { value: "top", label: "Align Top", icon: "/assets/align-top.svg" }, 170 | { 171 | value: "verticalCenter", 172 | label: "Align Vertical Center", 173 | icon: "/assets/align-vertical-center.svg", 174 | }, 175 | { value: "bottom", label: "Align Bottom", icon: "/assets/align-bottom.svg" }, 176 | ]; 177 | 178 | export const shortcuts = [ 179 | { 180 | key: "1", 181 | name: "Chat", 182 | shortcut: "/", 183 | }, 184 | { 185 | key: "2", 186 | name: "Undo", 187 | shortcut: "⌘ + Z", 188 | }, 189 | { 190 | key: "3", 191 | name: "Redo", 192 | shortcut: "⌘ + Y", 193 | }, 194 | { 195 | key: "4", 196 | name: "Reactions", 197 | shortcut: "E", 198 | }, 199 | ]; 200 | -------------------------------------------------------------------------------- /lib/key-events.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | import { CustomFabricObject } from "@/types/type"; 5 | 6 | export const handleCopy = (canvas: fabric.Canvas) => { 7 | const activeObjects = canvas.getActiveObjects(); 8 | if (activeObjects.length > 0) { 9 | // Serialize the selected objects 10 | const serializedObjects = activeObjects.map((obj) => obj.toObject()); 11 | // Store the serialized objects in the clipboard 12 | localStorage.setItem("clipboard", JSON.stringify(serializedObjects)); 13 | } 14 | 15 | return activeObjects; 16 | }; 17 | 18 | export const handlePaste = ( 19 | canvas: fabric.Canvas, 20 | syncShapeInStorage: (shape: fabric.Object) => void 21 | ) => { 22 | if (!canvas || !(canvas instanceof fabric.Canvas)) { 23 | console.error("Invalid canvas object. Aborting paste operation."); 24 | return; 25 | } 26 | 27 | // Retrieve serialized objects from the clipboard 28 | const clipboardData = localStorage.getItem("clipboard"); 29 | 30 | if (clipboardData) { 31 | try { 32 | const parsedObjects = JSON.parse(clipboardData); 33 | parsedObjects.forEach((objData: fabric.Object) => { 34 | // convert the plain javascript objects retrieved from localStorage into fabricjs objects (deserialization) 35 | fabric.util.enlivenObjects( 36 | [objData], 37 | (enlivenedObjects: fabric.Object[]) => { 38 | enlivenedObjects.forEach((enlivenedObj) => { 39 | // Offset the pasted objects to avoid overlap with existing objects 40 | enlivenedObj.set({ 41 | left: enlivenedObj.left || 0 + 20, 42 | top: enlivenedObj.top || 0 + 20, 43 | objectId: uuidv4(), 44 | fill: "#aabbcc", 45 | } as CustomFabricObject); 46 | 47 | canvas.add(enlivenedObj); 48 | syncShapeInStorage(enlivenedObj); 49 | }); 50 | canvas.renderAll(); 51 | }, 52 | "fabric" 53 | ); 54 | }); 55 | } catch (error) { 56 | console.error("Error parsing clipboard data:", error); 57 | } 58 | } 59 | }; 60 | 61 | export const handleDelete = ( 62 | canvas: fabric.Canvas, 63 | deleteShapeFromStorage: (id: string) => void 64 | ) => { 65 | const activeObjects = canvas.getActiveObjects(); 66 | if (!activeObjects || activeObjects.length === 0) return; 67 | 68 | if (activeObjects.length > 0) { 69 | activeObjects.forEach((obj: CustomFabricObject) => { 70 | if (!obj.objectId) return; 71 | canvas.remove(obj); 72 | deleteShapeFromStorage(obj.objectId); 73 | }); 74 | } 75 | 76 | canvas.discardActiveObject(); 77 | canvas.requestRenderAll(); 78 | }; 79 | 80 | // create a handleKeyDown function that listen to different keydown events 81 | export const handleKeyDown = ({ 82 | e, 83 | canvas, 84 | undo, 85 | redo, 86 | syncShapeInStorage, 87 | deleteShapeFromStorage, 88 | }: { 89 | e: KeyboardEvent; 90 | canvas: fabric.Canvas | any; 91 | undo: () => void; 92 | redo: () => void; 93 | syncShapeInStorage: (shape: fabric.Object) => void; 94 | deleteShapeFromStorage: (id: string) => void; 95 | }) => { 96 | // Check if the key pressed is ctrl/cmd + c (copy) 97 | if ((e?.ctrlKey || e?.metaKey) && e.keyCode === 67) { 98 | handleCopy(canvas); 99 | } 100 | 101 | // Check if the key pressed is ctrl/cmd + v (paste) 102 | if ((e?.ctrlKey || e?.metaKey) && e.keyCode === 86) { 103 | handlePaste(canvas, syncShapeInStorage); 104 | } 105 | 106 | // Check if the key pressed is delete/backspace (delete) 107 | // if (e.keyCode === 8 || e.keyCode === 46) { 108 | // handleDelete(canvas, deleteShapeFromStorage); 109 | // } 110 | 111 | // check if the key pressed is ctrl/cmd + x (cut) 112 | if ((e?.ctrlKey || e?.metaKey) && e.keyCode === 88) { 113 | handleCopy(canvas); 114 | handleDelete(canvas, deleteShapeFromStorage); 115 | } 116 | 117 | // check if the key pressed is ctrl/cmd + z (undo) 118 | if ((e?.ctrlKey || e?.metaKey) && e.keyCode === 90) { 119 | undo(); 120 | } 121 | 122 | // check if the key pressed is ctrl/cmd + y (redo) 123 | if ((e?.ctrlKey || e?.metaKey) && e.keyCode === 89) { 124 | redo(); 125 | } 126 | 127 | if (e.keyCode === 191 && !e.shiftKey) { 128 | e.preventDefault(); 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /lib/shapes.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | import { 5 | CustomFabricObject, 6 | ElementDirection, 7 | ImageUpload, 8 | ModifyShape, 9 | } from "@/types/type"; 10 | 11 | export const createRectangle = (pointer: PointerEvent) => { 12 | const rect = new fabric.Rect({ 13 | left: pointer.x, 14 | top: pointer.y, 15 | width: 100, 16 | height: 100, 17 | fill: "#aabbcc", 18 | objectId: uuidv4(), 19 | } as CustomFabricObject); 20 | 21 | return rect; 22 | }; 23 | 24 | export const createTriangle = (pointer: PointerEvent) => { 25 | return new fabric.Triangle({ 26 | left: pointer.x, 27 | top: pointer.y, 28 | width: 100, 29 | height: 100, 30 | fill: "#aabbcc", 31 | objectId: uuidv4(), 32 | } as CustomFabricObject); 33 | }; 34 | 35 | export const createCircle = (pointer: PointerEvent) => { 36 | return new fabric.Circle({ 37 | left: pointer.x, 38 | top: pointer.y, 39 | radius: 100, 40 | fill: "#aabbcc", 41 | objectId: uuidv4(), 42 | } as any); 43 | }; 44 | 45 | export const createLine = (pointer: PointerEvent) => { 46 | return new fabric.Line( 47 | [pointer.x, pointer.y, pointer.x + 100, pointer.y + 100], 48 | { 49 | stroke: "#aabbcc", 50 | strokeWidth: 2, 51 | objectId: uuidv4(), 52 | } as CustomFabricObject 53 | ); 54 | }; 55 | 56 | export const createText = (pointer: PointerEvent, text: string) => { 57 | return new fabric.IText(text, { 58 | left: pointer.x, 59 | top: pointer.y, 60 | fill: "#aabbcc", 61 | fontFamily: "Helvetica", 62 | fontSize: 36, 63 | fontWeight: "400", 64 | objectId: uuidv4() 65 | } as fabric.ITextOptions); 66 | }; 67 | 68 | export const createSpecificShape = ( 69 | shapeType: string, 70 | pointer: PointerEvent 71 | ) => { 72 | switch (shapeType) { 73 | case "rectangle": 74 | return createRectangle(pointer); 75 | 76 | case "triangle": 77 | return createTriangle(pointer); 78 | 79 | case "circle": 80 | return createCircle(pointer); 81 | 82 | case "line": 83 | return createLine(pointer); 84 | 85 | case "text": 86 | return createText(pointer, "Tap to Type"); 87 | 88 | default: 89 | return null; 90 | } 91 | }; 92 | 93 | export const handleImageUpload = ({ 94 | file, 95 | canvas, 96 | shapeRef, 97 | syncShapeInStorage, 98 | }: ImageUpload) => { 99 | const reader = new FileReader(); 100 | 101 | reader.onload = () => { 102 | fabric.Image.fromURL(reader.result as string, (img) => { 103 | img.scaleToWidth(200); 104 | img.scaleToHeight(200); 105 | 106 | canvas.current.add(img); 107 | 108 | // @ts-ignore 109 | img.objectId = uuidv4(); 110 | 111 | shapeRef.current = img; 112 | 113 | syncShapeInStorage(img); 114 | canvas.current.requestRenderAll(); 115 | }); 116 | }; 117 | 118 | reader.readAsDataURL(file); 119 | }; 120 | 121 | export const createShape = ( 122 | canvas: fabric.Canvas, 123 | pointer: PointerEvent, 124 | shapeType: string 125 | ) => { 126 | if (shapeType === "freeform") { 127 | canvas.isDrawingMode = true; 128 | return null; 129 | } 130 | 131 | return createSpecificShape(shapeType, pointer); 132 | }; 133 | 134 | export const modifyShape = ({ 135 | canvas, 136 | property, 137 | value, 138 | activeObjectRef, 139 | syncShapeInStorage, 140 | }: ModifyShape) => { 141 | const selectedElement = canvas.getActiveObject(); 142 | 143 | if (!selectedElement || selectedElement?.type === "activeSelection") return; 144 | 145 | // if property is width or height, set the scale of the selected element 146 | if (property === "width") { 147 | selectedElement.set("scaleX", 1); 148 | selectedElement.set("width", value); 149 | } else if (property === "height") { 150 | selectedElement.set("scaleY", 1); 151 | selectedElement.set("height", value); 152 | } else { 153 | if (selectedElement[property as keyof object] === value) return; 154 | selectedElement.set(property as keyof object, value); 155 | } 156 | 157 | // set selectedElement to activeObjectRef 158 | activeObjectRef.current = selectedElement; 159 | 160 | syncShapeInStorage(selectedElement); 161 | }; 162 | 163 | export const bringElement = ({ 164 | canvas, 165 | direction, 166 | syncShapeInStorage, 167 | }: ElementDirection) => { 168 | if (!canvas) return; 169 | 170 | // get the selected element. If there is no selected element or there are more than one selected element, return 171 | const selectedElement = canvas.getActiveObject(); 172 | 173 | if (!selectedElement || selectedElement?.type === "activeSelection") return; 174 | 175 | // bring the selected element to the front 176 | if (direction === "front") { 177 | canvas.bringToFront(selectedElement); 178 | } else if (direction === "back") { 179 | canvas.sendToBack(selectedElement); 180 | } 181 | 182 | // canvas.renderAll(); 183 | syncShapeInStorage(selectedElement); 184 | 185 | // re-render all objects on the canvas 186 | }; -------------------------------------------------------------------------------- /types/type.ts: -------------------------------------------------------------------------------- 1 | import { BaseUserMeta, User } from "@liveblocks/client"; 2 | import { Gradient, Pattern } from "fabric/fabric-impl"; 3 | 4 | export enum CursorMode { 5 | Hidden, 6 | Chat, 7 | ReactionSelector, 8 | Reaction, 9 | } 10 | 11 | export type CursorState = 12 | | { 13 | mode: CursorMode.Hidden; 14 | } 15 | | { 16 | mode: CursorMode.Chat; 17 | message: string; 18 | previousMessage: string | null; 19 | } 20 | | { 21 | mode: CursorMode.ReactionSelector; 22 | } 23 | | { 24 | mode: CursorMode.Reaction; 25 | reaction: string; 26 | isPressed: boolean; 27 | }; 28 | 29 | export type Reaction = { 30 | value: string; 31 | timestamp: number; 32 | point: { x: number; y: number }; 33 | }; 34 | 35 | export type ReactionEvent = { 36 | x: number; 37 | y: number; 38 | value: string; 39 | }; 40 | 41 | export type ShapeData = { 42 | type: string; 43 | width: number; 44 | height: number; 45 | fill: string | Pattern | Gradient; 46 | left: number; 47 | top: number; 48 | objectId: string | undefined; 49 | }; 50 | 51 | export type Attributes = { 52 | width: string; 53 | height: string; 54 | fontSize: string; 55 | fontFamily: string; 56 | fontWeight: string; 57 | fill: string; 58 | stroke: string; 59 | }; 60 | 61 | export type ActiveElement = { 62 | name: string; 63 | value: string; 64 | icon: string; 65 | } | null; 66 | 67 | export interface CustomFabricObject 68 | extends fabric.Object { 69 | objectId?: string; 70 | } 71 | 72 | export type ModifyShape = { 73 | canvas: fabric.Canvas; 74 | property: string; 75 | value: any; 76 | activeObjectRef: React.MutableRefObject; 77 | syncShapeInStorage: (shape: fabric.Object) => void; 78 | }; 79 | 80 | export type ElementDirection = { 81 | canvas: fabric.Canvas; 82 | direction: string; 83 | syncShapeInStorage: (shape: fabric.Object) => void; 84 | }; 85 | 86 | export type ImageUpload = { 87 | file: File; 88 | canvas: React.MutableRefObject; 89 | shapeRef: React.MutableRefObject; 90 | syncShapeInStorage: (shape: fabric.Object) => void; 91 | }; 92 | 93 | export type RightSidebarProps = { 94 | elementAttributes: Attributes; 95 | setElementAttributes: React.Dispatch>; 96 | fabricRef: React.RefObject; 97 | activeObjectRef: React.RefObject; 98 | isEditingRef: React.MutableRefObject; 99 | syncShapeInStorage: (obj: any) => void; 100 | }; 101 | 102 | export type NavbarProps = { 103 | activeElement: ActiveElement; 104 | imageInputRef: React.MutableRefObject; 105 | handleImageUpload: (e: React.ChangeEvent) => void; 106 | handleActiveElement: (element: ActiveElement) => void; 107 | }; 108 | 109 | export type ShapesMenuProps = { 110 | item: { 111 | name: string; 112 | icon: string; 113 | value: Array; 114 | }; 115 | activeElement: any; 116 | handleActiveElement: any; 117 | handleImageUpload: any; 118 | imageInputRef: any; 119 | }; 120 | 121 | export type Presence = any; 122 | 123 | export type LiveCursorProps = { 124 | others: readonly User[]; 125 | }; 126 | 127 | export type CanvasMouseDown = { 128 | options: fabric.IEvent; 129 | canvas: fabric.Canvas; 130 | selectedShapeRef: any; 131 | isDrawing: React.MutableRefObject; 132 | shapeRef: React.MutableRefObject; 133 | }; 134 | 135 | export type CanvasMouseMove = { 136 | options: fabric.IEvent; 137 | canvas: fabric.Canvas; 138 | isDrawing: React.MutableRefObject; 139 | selectedShapeRef: any; 140 | shapeRef: any; 141 | syncShapeInStorage: (shape: fabric.Object) => void; 142 | }; 143 | 144 | export type CanvasMouseUp = { 145 | canvas: fabric.Canvas; 146 | isDrawing: React.MutableRefObject; 147 | shapeRef: any; 148 | activeObjectRef: React.MutableRefObject; 149 | selectedShapeRef: any; 150 | syncShapeInStorage: (shape: fabric.Object) => void; 151 | setActiveElement: any; 152 | }; 153 | 154 | export type CanvasObjectModified = { 155 | options: fabric.IEvent; 156 | syncShapeInStorage: (shape: fabric.Object) => void; 157 | }; 158 | 159 | export type CanvasPathCreated = { 160 | options: (fabric.IEvent & { path: CustomFabricObject }) | any; 161 | syncShapeInStorage: (shape: fabric.Object) => void; 162 | }; 163 | 164 | export type CanvasSelectionCreated = { 165 | options: fabric.IEvent; 166 | isEditingRef: React.MutableRefObject; 167 | setElementAttributes: React.Dispatch>; 168 | }; 169 | 170 | export type CanvasObjectScaling = { 171 | options: fabric.IEvent; 172 | setElementAttributes: React.Dispatch>; 173 | }; 174 | 175 | export type RenderCanvas = { 176 | fabricRef: React.MutableRefObject; 177 | canvasObjects: any; 178 | activeObjectRef: any; 179 | }; 180 | 181 | export type CursorChatProps = { 182 | cursor: { x: number; y: number }; 183 | cursorState: CursorState; 184 | setCursorState: (cursorState: CursorState) => void; 185 | updateMyPresence: ( 186 | presence: Partial<{ 187 | cursor: { x: number; y: number }; 188 | cursorColor: string; 189 | message: string; 190 | }> 191 | ) => void; 192 | }; 193 | -------------------------------------------------------------------------------- /public/assets/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 | -------------------------------------------------------------------------------- /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/comments/NewThread.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | FormEvent, 5 | ReactNode, 6 | useCallback, 7 | useEffect, 8 | useRef, 9 | useState, 10 | } from "react"; 11 | import { Slot } from "@radix-ui/react-slot"; 12 | import * as Portal from "@radix-ui/react-portal"; 13 | import { ComposerSubmitComment } from "@liveblocks/react-comments/primitives"; 14 | 15 | import { useCreateThread } from "@/liveblocks.config"; 16 | import { useMaxZIndex } from "@/lib/useMaxZIndex"; 17 | 18 | import PinnedComposer from "./PinnedComposer"; 19 | import NewThreadCursor from "./NewThreadCursor"; 20 | 21 | type ComposerCoords = null | { x: number; y: number }; 22 | 23 | type Props = { 24 | children: ReactNode; 25 | }; 26 | 27 | export const NewThread = ({ children }: Props) => { 28 | // set state to track if we're placing a new comment or not 29 | const [creatingCommentState, setCreatingCommentState] = useState< 30 | "placing" | "placed" | "complete" 31 | >("complete"); 32 | 33 | /** 34 | * We're using the useCreateThread hook to create a new thread. 35 | * 36 | * useCreateThread: https://liveblocks.io/docs/api-reference/liveblocks-react#useCreateThread 37 | */ 38 | const createThread = useCreateThread(); 39 | 40 | // get the max z-index of a thread 41 | const maxZIndex = useMaxZIndex(); 42 | 43 | // set state to track the coordinates of the composer (liveblocks comment editor) 44 | const [composerCoords, setComposerCoords] = useState(null); 45 | 46 | // set state to track the last pointer event 47 | const lastPointerEvent = useRef(); 48 | 49 | // set state to track if user is allowed to use the composer 50 | const [allowUseComposer, setAllowUseComposer] = useState(false); 51 | const allowComposerRef = useRef(allowUseComposer); 52 | allowComposerRef.current = allowUseComposer; 53 | 54 | useEffect(() => { 55 | // If composer is already placed, don't do anything 56 | if (creatingCommentState === "complete") { 57 | return; 58 | } 59 | 60 | // Place a composer on the screen 61 | const newComment = (e: MouseEvent) => { 62 | e.preventDefault(); 63 | 64 | // If already placed, click outside to close composer 65 | if (creatingCommentState === "placed") { 66 | // check if the click event is on/inside the composer 67 | const isClickOnComposer = ((e as any)._savedComposedPath = e 68 | .composedPath() 69 | .some((el: any) => { 70 | return el.classList?.contains("lb-composer-editor-actions"); 71 | })); 72 | 73 | // if click is inisde/on composer, don't do anything 74 | if (isClickOnComposer) { 75 | return; 76 | } 77 | 78 | // if click is outside composer, close composer 79 | if (!isClickOnComposer) { 80 | setCreatingCommentState("complete"); 81 | return; 82 | } 83 | } 84 | 85 | // First click sets composer down 86 | setCreatingCommentState("placed"); 87 | setComposerCoords({ 88 | x: e.clientX, 89 | y: e.clientY, 90 | }); 91 | }; 92 | 93 | document.documentElement.addEventListener("click", newComment); 94 | 95 | return () => { 96 | document.documentElement.removeEventListener("click", newComment); 97 | }; 98 | }, [creatingCommentState]); 99 | 100 | useEffect(() => { 101 | // If dragging composer, update position 102 | const handlePointerMove = (e: PointerEvent) => { 103 | // Prevents issue with composedPath getting removed 104 | (e as any)._savedComposedPath = e.composedPath(); 105 | lastPointerEvent.current = e; 106 | }; 107 | 108 | document.documentElement.addEventListener("pointermove", handlePointerMove); 109 | 110 | return () => { 111 | document.documentElement.removeEventListener( 112 | "pointermove", 113 | handlePointerMove 114 | ); 115 | }; 116 | }, []); 117 | 118 | // Set pointer event from last click on body for use later 119 | useEffect(() => { 120 | if (creatingCommentState !== "placing") { 121 | return; 122 | } 123 | 124 | const handlePointerDown = (e: PointerEvent) => { 125 | // if composer is already placed, don't do anything 126 | if (allowComposerRef.current) { 127 | return; 128 | } 129 | 130 | // Prevents issue with composedPath getting removed 131 | (e as any)._savedComposedPath = e.composedPath(); 132 | lastPointerEvent.current = e; 133 | setAllowUseComposer(true); 134 | }; 135 | 136 | // Right click to cancel placing 137 | const handleContextMenu = (e: Event) => { 138 | if (creatingCommentState === "placing") { 139 | e.preventDefault(); 140 | setCreatingCommentState("complete"); 141 | } 142 | }; 143 | 144 | document.documentElement.addEventListener("pointerdown", handlePointerDown); 145 | document.documentElement.addEventListener("contextmenu", handleContextMenu); 146 | 147 | return () => { 148 | document.documentElement.removeEventListener( 149 | "pointerdown", 150 | handlePointerDown 151 | ); 152 | document.documentElement.removeEventListener( 153 | "contextmenu", 154 | handleContextMenu 155 | ); 156 | }; 157 | }, [creatingCommentState]); 158 | 159 | // On composer submit, create thread and reset state 160 | const handleComposerSubmit = useCallback( 161 | ({ body }: ComposerSubmitComment, event: FormEvent) => { 162 | event.preventDefault(); 163 | event.stopPropagation(); 164 | 165 | // Get your canvas element 166 | const overlayPanel = document.querySelector("#canvas"); 167 | 168 | // if there's no composer coords or last pointer event, meaning the user hasn't clicked yet, don't do anything 169 | if (!composerCoords || !lastPointerEvent.current || !overlayPanel) { 170 | return; 171 | } 172 | 173 | // Set coords relative to the top left of your canvas 174 | const { top, left } = overlayPanel.getBoundingClientRect(); 175 | const x = composerCoords.x - left; 176 | const y = composerCoords.y - top; 177 | 178 | // create a new thread with the composer coords and cursor selectors 179 | createThread({ 180 | body, 181 | metadata: { 182 | x, 183 | y, 184 | resolved: false, 185 | zIndex: maxZIndex + 1, 186 | }, 187 | }); 188 | 189 | setComposerCoords(null); 190 | setCreatingCommentState("complete"); 191 | setAllowUseComposer(false); 192 | }, 193 | [createThread, composerCoords, maxZIndex] 194 | ); 195 | 196 | return ( 197 | <> 198 | {/** 199 | * Slot is used to wrap the children of the NewThread component 200 | * to allow us to add a click event listener to the children 201 | * 202 | * Slot: https://www.radix-ui.com/primitives/docs/utilities/slot 203 | * 204 | * Disclaimer: We don't have to download this package specifically, 205 | * it's already included when we install Shadcn 206 | */} 207 | 209 | setCreatingCommentState( 210 | creatingCommentState !== "complete" ? "complete" : "placing" 211 | ) 212 | } 213 | style={{ opacity: creatingCommentState !== "complete" ? 0.7 : 1 }} 214 | > 215 | {children} 216 | 217 | 218 | {/* if composer coords exist and we're placing a comment, render the composer */} 219 | {composerCoords && creatingCommentState === "placed" ? ( 220 | /** 221 | * Portal.Root is used to render the composer outside of the NewThread component to avoid z-index issuess 222 | * 223 | * Portal.Root: https://www.radix-ui.com/primitives/docs/utilities/portal 224 | */ 225 | 233 | 234 | 235 | ) : null} 236 | 237 | {/* Show the customizing cursor when placing a comment. The one with comment shape */} 238 | 239 | 240 | ); 241 | }; 242 | -------------------------------------------------------------------------------- /public/assets/freeform.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Project Banner 5 | 6 |
7 | 8 |
9 | typescript 10 | nextdotjs 11 | tailwindcss 12 |
13 | 14 |

Real Time Figma Clone

15 | 16 |
17 | Build this project step by step with our detailed tutorial on JavaScript Mastery YouTube. Join the JSM family! 18 |
19 |
20 | 21 | ## 📋 Table of Contents 22 | 23 | 1. 🤖 [Introduction](#introduction) 24 | 2. ⚙️ [Tech Stack](#tech-stack) 25 | 3. 🔋 [Features](#features) 26 | 4. 🤸 [Quick Start](#quick-start) 27 | 5. 🕸️ [Snippets](#snippets) 28 | 6. 🔗 [Links](#links) 29 | 7. 🚀 [More](#more) 30 | 31 | ## 🚨 Tutorial 32 | 33 | This repository contains the code corresponding to an in-depth tutorial available on our YouTube channel, JavaScript Mastery. 34 | 35 | If you prefer visual learning, this is the perfect resource for you. Follow our tutorial to learn how to build projects like these step-by-step in a beginner-friendly manner! 36 | 37 | 38 | 39 | ## 🤖 Introduction 40 | 41 | A minimalistic Figma clone to show how to add real-world features like live collaboration with cursor chat, comments, reactions, and drawing designs (shapes, image upload) on the canvas using fabric.js. 42 | 43 | If you're getting started and need assistance or face any bugs, join our active Discord community with over 27k+ members. It's a place where people help each other out. 44 | 45 | 46 | 47 | ## ⚙️ Tech Stack 48 | 49 | - Next.js 50 | - TypeScript 51 | - Liveblocks 52 | - Fabric.js 53 | - Shadcn 54 | - Tailwind CSS 55 | 56 | ## 🔋 Features 57 | 58 | 👉 **Multi Cursors, Cursor Chat, and Reactions**: Allows multiple users to collaborate simultaneously by showing individual cursors, enabling real-time chat, and reactions for interactive communication. 59 | 60 | 👉 **Active Users**: Displays a list of currently active users in the collaborative environment, providing visibility into who is currently engaged. 61 | 62 | 👉 **Comment Bubbles**: Enables users to attach comments to specific elements on the canvas, fostering communication and feedback on design components. 63 | 64 | 👉 **Creating Different Shapes**: Provides tools for users to generate a variety of shapes on the canvas, allowing for diverse design elements 65 | 66 | 👉 **Uploading Images**: Import images onto the canvas, expanding the range of visual content in the design 67 | 68 | 👉 **Customization**: Allows users to adjust the properties of design elements, offering flexibility in customizing and fine-tuning visual components 69 | 70 | 👉 **Freeform Drawing**: Enables users to draw freely on the canvas, promoting artistic expression and creative design. 71 | 72 | 👉 **Undo/Redo**: Provides the ability to reverse (undo) or restore (redo) previous actions, offering flexibility in design decision-making 73 | 74 | 👉 **Keyboard Actions**: Allows users to utilize keyboard shortcuts for various actions, including copying, pasting, deleting, and triggering shortcuts for features like opening cursor chat, reactions, and more, enhancing efficiency and accessibility. 75 | 76 | 👉 **History**: Review the chronological history of actions and changes made on the canvas, aiding in project management and version control. 77 | 78 | 👉 **Deleting, Scaling, Moving, Clearing, Exporting Canvas**: Offers a range of functions for managing design elements, including deletion, scaling, moving, clearing the canvas, and exporting the final design for external use. 79 | 80 | and many more, including code architecture, advanced react hooks, and reusability 81 | 82 | ## 🤸 Quick Start 83 | 84 | Follow these steps to set up the project locally on your machine. 85 | 86 | **Prerequisites** 87 | 88 | Make sure you have the following installed on your machine: 89 | 90 | - [Git](https://git-scm.com/) 91 | - [Node.js](https://nodejs.org/en) 92 | - [npm](https://www.npmjs.com/) (Node Package Manager) 93 | 94 | **Cloning the Repository** 95 | 96 | ```bash 97 | git clone https://github.com/JavaScript-Mastery-Pro/figma-ts.git 98 | cd figma-ts 99 | ``` 100 | 101 | **Installation** 102 | 103 | Install the project dependencies using npm: 104 | 105 | ```bash 106 | npm install 107 | ``` 108 | 109 | **Set Up Environment Variables** 110 | 111 | Create a new file named `.env.local` in the root of your project and add the following content: 112 | 113 | ```env 114 | NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY= 115 | ``` 116 | 117 | Replace the placeholder values with your actual Liveblocks credentials. You can obtain these credentials by signing up on the [Liveblocks website](https://liveblocks.io). 118 | 119 | **Running the Project** 120 | 121 | ```bash 122 | npm run dev 123 | ``` 124 | 125 | Open [http://localhost:3000](http://localhost:3000) in your browser to view the project. 126 | 127 | ## 🕸️ Snippets 128 | 129 |
130 | tailwind.config.ts 131 | 132 | ```typescript 133 | import type { Config } from "tailwindcss"; 134 | 135 | const config = { 136 | darkMode: ["class"], 137 | content: [ 138 | "./pages/**/*.{ts,tsx}", 139 | "./components/**/*.{ts,tsx}", 140 | "./app/**/*.{ts,tsx}", 141 | "./src/**/*.{ts,tsx}", 142 | ], 143 | prefix: "", 144 | theme: { 145 | container: { 146 | center: true, 147 | padding: "2rem", 148 | screens: { 149 | "2xl": "1400px", 150 | }, 151 | }, 152 | extend: { 153 | colors: { 154 | primary: { 155 | black: "#14181F", 156 | green: "#56FFA6", 157 | grey: { 158 | 100: "#2B303B", 159 | 200: "#202731", 160 | 300: "#C4D3ED", 161 | }, 162 | }, 163 | }, 164 | keyframes: { 165 | "accordion-down": { 166 | from: { height: "0" }, 167 | to: { height: "var(--radix-accordion-content-height)" }, 168 | }, 169 | "accordion-up": { 170 | from: { height: "var(--radix-accordion-content-height)" }, 171 | to: { height: "0" }, 172 | }, 173 | }, 174 | animation: { 175 | "accordion-down": "accordion-down 0.2s ease-out", 176 | "accordion-up": "accordion-up 0.2s ease-out", 177 | }, 178 | }, 179 | }, 180 | plugins: [require("tailwindcss-animate")], 181 | } satisfies Config; 182 | 183 | export default config; 184 | ``` 185 | 186 |
187 | 188 |
189 | app/globals.css 190 | 191 | ```css 192 | @tailwind base; 193 | @tailwind components; 194 | @tailwind utilities; 195 | 196 | @import "@liveblocks/react-comments/styles.css"; 197 | 198 | * { 199 | font-family: 200 | work sans, 201 | sans-serif; 202 | } 203 | 204 | @layer utilities { 205 | .no-ring { 206 | @apply outline-none ring-0 ring-offset-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-offset-0 !important; 207 | } 208 | 209 | .input-ring { 210 | @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; 211 | } 212 | 213 | .right-menu-content { 214 | @apply flex w-80 flex-col gap-y-1 border-none bg-primary-black py-4 text-white !important; 215 | } 216 | 217 | .right-menu-item { 218 | @apply flex justify-between px-3 py-2 hover:bg-primary-grey-200 !important; 219 | } 220 | } 221 | ``` 222 | 223 |
224 | 225 | ## 🔗 Links 226 | 227 | - [Assets](https://drive.google.com/file/d/17tRs0sEiIsCeTYEXhWEdHMrTshuz2oYf/view?usp=sharing) 228 | - [Components](https://drive.google.com/file/d/1bha-40vlGMIPW9bTRUgHD_SEmT9ZA38S/view?usp=sharing) 229 | 230 | ## 🚀 More 231 | 232 | **Advance your skills with Next.js 14 Pro Course** 233 | 234 | Enjoyed creating this project? Dive deeper into our PRO courses for a richer learning adventure. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go! 235 | 236 | 237 | Project Banner 238 | 239 | 240 |
241 |
242 | 243 | **Accelerate your professional journey with the Expert Training program** 244 | 245 | And if you're hungry for more than just a course and want to understand how we learn and tackle tech challenges, hop into our personalized masterclass. We cover best practices, different web skills, and offer mentorship to boost your confidence. Let's learn and grow together! 246 | 247 | 248 | Project Banner 249 | 250 | 251 | # 252 | -------------------------------------------------------------------------------- /components/Live.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useEffect, useState } from "react"; 4 | 5 | import { useBroadcastEvent, useEventListener, useMyPresence, useOthers } from "@/liveblocks.config"; 6 | import useInterval from "@/hooks/useInterval"; 7 | import { CursorMode, CursorState, Reaction, ReactionEvent } from "@/types/type"; 8 | import { shortcuts } from "@/constants"; 9 | 10 | import { Comments } from "./comments/Comments"; 11 | import { CursorChat, FlyingReaction, LiveCursors, ReactionSelector } from "./index"; 12 | import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "./ui/context-menu"; 13 | 14 | type Props = { 15 | canvasRef: React.MutableRefObject; 16 | undo: () => void; 17 | redo: () => void; 18 | }; 19 | 20 | const Live = ({ canvasRef, undo, redo }: Props) => { 21 | /** 22 | * useOthers returns the list of other users in the room. 23 | * 24 | * useOthers: https://liveblocks.io/docs/api-reference/liveblocks-react#useOthers 25 | */ 26 | const others = useOthers(); 27 | 28 | /** 29 | * useMyPresence returns the presence of the current user in the room. 30 | * It also returns a function to update the presence of the current user. 31 | * 32 | * useMyPresence: https://liveblocks.io/docs/api-reference/liveblocks-react#useMyPresence 33 | */ 34 | const [{ cursor }, updateMyPresence] = useMyPresence() as any; 35 | 36 | /** 37 | * useBroadcastEvent is used to broadcast an event to all the other users in the room. 38 | * 39 | * useBroadcastEvent: https://liveblocks.io/docs/api-reference/liveblocks-react#useBroadcastEvent 40 | */ 41 | const broadcast = useBroadcastEvent(); 42 | 43 | // store the reactions created on mouse click 44 | const [reactions, setReactions] = useState([]); 45 | 46 | // track the state of the cursor (hidden, chat, reaction, reaction selector) 47 | const [cursorState, setCursorState] = useState({ 48 | mode: CursorMode.Hidden, 49 | }); 50 | 51 | // set the reaction of the cursor 52 | const setReaction = useCallback((reaction: string) => { 53 | setCursorState({ mode: CursorMode.Reaction, reaction, isPressed: false }); 54 | }, []); 55 | 56 | // Remove reactions that are not visible anymore (every 1 sec) 57 | useInterval(() => { 58 | setReactions((reactions) => reactions.filter((reaction) => reaction.timestamp > Date.now() - 4000)); 59 | }, 1000); 60 | 61 | // Broadcast the reaction to other users (every 100ms) 62 | useInterval(() => { 63 | if (cursorState.mode === CursorMode.Reaction && cursorState.isPressed && cursor) { 64 | // concat all the reactions created on mouse click 65 | setReactions((reactions) => 66 | reactions.concat([ 67 | { 68 | point: { x: cursor.x, y: cursor.y }, 69 | value: cursorState.reaction, 70 | timestamp: Date.now(), 71 | }, 72 | ]) 73 | ); 74 | 75 | // Broadcast the reaction to other users 76 | broadcast({ 77 | x: cursor.x, 78 | y: cursor.y, 79 | value: cursorState.reaction, 80 | }); 81 | } 82 | }, 100); 83 | 84 | /** 85 | * useEventListener is used to listen to events broadcasted by other 86 | * users. 87 | * 88 | * useEventListener: https://liveblocks.io/docs/api-reference/liveblocks-react#useEventListener 89 | */ 90 | useEventListener((eventData) => { 91 | const event = eventData.event as ReactionEvent; 92 | setReactions((reactions) => 93 | reactions.concat([ 94 | { 95 | point: { x: event.x, y: event.y }, 96 | value: event.value, 97 | timestamp: Date.now(), 98 | }, 99 | ]) 100 | ); 101 | }); 102 | 103 | // Listen to keyboard events to change the cursor state 104 | useEffect(() => { 105 | const onKeyUp = (e: KeyboardEvent) => { 106 | if (e.key === "/") { 107 | setCursorState({ 108 | mode: CursorMode.Chat, 109 | previousMessage: null, 110 | message: "", 111 | }); 112 | } else if (e.key === "Escape") { 113 | updateMyPresence({ message: "" }); 114 | setCursorState({ mode: CursorMode.Hidden }); 115 | } else if (e.key === "e") { 116 | setCursorState({ mode: CursorMode.ReactionSelector }); 117 | } 118 | }; 119 | 120 | const onKeyDown = (e: KeyboardEvent) => { 121 | if (e.key === "/") { 122 | e.preventDefault(); 123 | } 124 | }; 125 | 126 | window.addEventListener("keyup", onKeyUp); 127 | window.addEventListener("keydown", onKeyDown); 128 | 129 | return () => { 130 | window.removeEventListener("keyup", onKeyUp); 131 | window.removeEventListener("keydown", onKeyDown); 132 | }; 133 | }, [updateMyPresence]); 134 | 135 | // Listen to mouse events to change the cursor state 136 | const handlePointerMove = useCallback((event: React.PointerEvent) => { 137 | event.preventDefault(); 138 | 139 | // if cursor is not in reaction selector mode, update the cursor position 140 | if (cursor == null || cursorState.mode !== CursorMode.ReactionSelector) { 141 | // get the cursor position in the canvas 142 | const x = event.clientX - event.currentTarget.getBoundingClientRect().x; 143 | const y = event.clientY - event.currentTarget.getBoundingClientRect().y; 144 | 145 | // broadcast the cursor position to other users 146 | updateMyPresence({ 147 | cursor: { 148 | x, 149 | y, 150 | }, 151 | }); 152 | } 153 | }, []); 154 | 155 | // Hide the cursor when the mouse leaves the canvas 156 | const handlePointerLeave = useCallback(() => { 157 | setCursorState({ 158 | mode: CursorMode.Hidden, 159 | }); 160 | updateMyPresence({ 161 | cursor: null, 162 | message: null, 163 | }); 164 | }, []); 165 | 166 | // Show the cursor when the mouse enters the canvas 167 | const handlePointerDown = useCallback( 168 | (event: React.PointerEvent) => { 169 | // get the cursor position in the canvas 170 | const x = event.clientX - event.currentTarget.getBoundingClientRect().x; 171 | const y = event.clientY - event.currentTarget.getBoundingClientRect().y; 172 | 173 | updateMyPresence({ 174 | cursor: { 175 | x, 176 | y, 177 | }, 178 | }); 179 | 180 | // if cursor is in reaction mode, set isPressed to true 181 | setCursorState((state: CursorState) => 182 | cursorState.mode === CursorMode.Reaction ? { ...state, isPressed: true } : state 183 | ); 184 | }, 185 | [cursorState.mode, setCursorState] 186 | ); 187 | 188 | // hide the cursor when the mouse is up 189 | const handlePointerUp = useCallback(() => { 190 | setCursorState((state: CursorState) => 191 | cursorState.mode === CursorMode.Reaction ? { ...state, isPressed: false } : state 192 | ); 193 | }, [cursorState.mode, setCursorState]); 194 | 195 | // trigger respective actions when the user clicks on the right menu 196 | const handleContextMenuClick = useCallback((key: string) => { 197 | switch (key) { 198 | case "Chat": 199 | setCursorState({ 200 | mode: CursorMode.Chat, 201 | previousMessage: null, 202 | message: "", 203 | }); 204 | break; 205 | 206 | case "Reactions": 207 | setCursorState({ mode: CursorMode.ReactionSelector }); 208 | break; 209 | 210 | case "Undo": 211 | undo(); 212 | break; 213 | 214 | case "Redo": 215 | redo(); 216 | break; 217 | 218 | default: 219 | break; 220 | } 221 | }, []); 222 | 223 | return ( 224 | 225 | 236 | 237 | 238 | {/* Render the reactions */} 239 | {reactions.map((reaction) => ( 240 | 247 | ))} 248 | 249 | {/* If cursor is in chat mode, show the chat cursor */} 250 | {cursor && ( 251 | 257 | )} 258 | 259 | {/* If cursor is in reaction selector mode, show the reaction selector */} 260 | {cursorState.mode === CursorMode.ReactionSelector && ( 261 | { 263 | setReaction(reaction); 264 | }} 265 | /> 266 | )} 267 | 268 | {/* Show the live cursors of other users */} 269 | 270 | 271 | {/* Show the comments */} 272 | 273 | 274 | 275 | 276 | {shortcuts.map((item) => ( 277 | handleContextMenuClick(item.name)} 281 | > 282 |

{item.name}

283 |

{item.shortcut}

284 |
285 | ))} 286 |
287 |
288 | ); 289 | }; 290 | 291 | export default Live; 292 | -------------------------------------------------------------------------------- /lib/canvas.ts: -------------------------------------------------------------------------------- 1 | import { fabric } from "fabric"; 2 | import { v4 as uuid4 } from "uuid"; 3 | 4 | import { 5 | CanvasMouseDown, 6 | CanvasMouseMove, 7 | CanvasMouseUp, 8 | CanvasObjectModified, 9 | CanvasObjectScaling, 10 | CanvasPathCreated, 11 | CanvasSelectionCreated, 12 | RenderCanvas, 13 | } from "@/types/type"; 14 | import { defaultNavElement } from "@/constants"; 15 | import { createSpecificShape } from "./shapes"; 16 | 17 | // initialize fabric canvas 18 | export const initializeFabric = ({ 19 | fabricRef, 20 | canvasRef, 21 | }: { 22 | fabricRef: React.MutableRefObject; 23 | canvasRef: React.MutableRefObject; 24 | }) => { 25 | // get canvas element 26 | const canvasElement = document.getElementById("canvas"); 27 | 28 | // create fabric canvas 29 | const canvas = new fabric.Canvas(canvasRef.current, { 30 | width: canvasElement?.clientWidth, 31 | height: canvasElement?.clientHeight, 32 | }); 33 | 34 | // set canvas reference to fabricRef so we can use it later anywhere outside canvas listener 35 | fabricRef.current = canvas; 36 | 37 | return canvas; 38 | }; 39 | 40 | // instantiate creation of custom fabric object/shape and add it to canvas 41 | export const handleCanvasMouseDown = ({ 42 | options, 43 | canvas, 44 | selectedShapeRef, 45 | isDrawing, 46 | shapeRef, 47 | }: CanvasMouseDown) => { 48 | // get pointer coordinates 49 | const pointer = canvas.getPointer(options.e); 50 | 51 | /** 52 | * get target object i.e., the object that is clicked 53 | * findtarget() returns the object that is clicked 54 | * 55 | * findTarget: http://fabricjs.com/docs/fabric.Canvas.html#findTarget 56 | */ 57 | const target = canvas.findTarget(options.e, false); 58 | 59 | // set canvas drawing mode to false 60 | canvas.isDrawingMode = false; 61 | 62 | // if selected shape is freeform, set drawing mode to true and return 63 | if (selectedShapeRef.current === "freeform") { 64 | isDrawing.current = true; 65 | canvas.isDrawingMode = true; 66 | canvas.freeDrawingBrush.width = 5; 67 | return; 68 | } 69 | 70 | canvas.isDrawingMode = false; 71 | 72 | // if target is the selected shape or active selection, set isDrawing to false 73 | if ( 74 | target && 75 | (target.type === selectedShapeRef.current || 76 | target.type === "activeSelection") 77 | ) { 78 | isDrawing.current = false; 79 | 80 | // set active object to target 81 | canvas.setActiveObject(target); 82 | 83 | /** 84 | * setCoords() is used to update the controls of the object 85 | * setCoords: http://fabricjs.com/docs/fabric.Object.html#setCoords 86 | */ 87 | target.setCoords(); 88 | } else { 89 | isDrawing.current = true; 90 | 91 | // create custom fabric object/shape and set it to shapeRef 92 | shapeRef.current = createSpecificShape( 93 | selectedShapeRef.current, 94 | pointer as any 95 | ); 96 | 97 | // if shapeRef is not null, add it to canvas 98 | if (shapeRef.current) { 99 | // add: http://fabricjs.com/docs/fabric.Canvas.html#add 100 | canvas.add(shapeRef.current); 101 | } 102 | } 103 | }; 104 | 105 | // handle mouse move event on canvas to draw shapes with different dimensions 106 | export const handleCanvaseMouseMove = ({ 107 | options, 108 | canvas, 109 | isDrawing, 110 | selectedShapeRef, 111 | shapeRef, 112 | syncShapeInStorage, 113 | }: CanvasMouseMove) => { 114 | // if selected shape is freeform, return 115 | if (!isDrawing.current) return; 116 | if (selectedShapeRef.current === "freeform") return; 117 | 118 | canvas.isDrawingMode = false; 119 | 120 | // get pointer coordinates 121 | const pointer = canvas.getPointer(options.e); 122 | 123 | // depending on the selected shape, set the dimensions of the shape stored in shapeRef in previous step of handelCanvasMouseDown 124 | // calculate shape dimensions based on pointer coordinates 125 | switch (selectedShapeRef?.current) { 126 | case "rectangle": 127 | shapeRef.current?.set({ 128 | width: pointer.x - (shapeRef.current?.left || 0), 129 | height: pointer.y - (shapeRef.current?.top || 0), 130 | }); 131 | break; 132 | 133 | case "circle": 134 | shapeRef.current.set({ 135 | radius: Math.abs(pointer.x - (shapeRef.current?.left || 0)) / 2, 136 | }); 137 | break; 138 | 139 | case "triangle": 140 | shapeRef.current?.set({ 141 | width: pointer.x - (shapeRef.current?.left || 0), 142 | height: pointer.y - (shapeRef.current?.top || 0), 143 | }); 144 | break; 145 | 146 | case "line": 147 | shapeRef.current?.set({ 148 | x2: pointer.x, 149 | y2: pointer.y, 150 | }); 151 | break; 152 | 153 | case "image": 154 | shapeRef.current?.set({ 155 | width: pointer.x - (shapeRef.current?.left || 0), 156 | height: pointer.y - (shapeRef.current?.top || 0), 157 | }); 158 | 159 | default: 160 | break; 161 | } 162 | 163 | // render objects on canvas 164 | // renderAll: http://fabricjs.com/docs/fabric.Canvas.html#renderAll 165 | canvas.renderAll(); 166 | 167 | // sync shape in storage 168 | if (shapeRef.current?.objectId) { 169 | syncShapeInStorage(shapeRef.current); 170 | } 171 | }; 172 | 173 | // handle mouse up event on canvas to stop drawing shapes 174 | export const handleCanvasMouseUp = ({ 175 | canvas, 176 | isDrawing, 177 | shapeRef, 178 | activeObjectRef, 179 | selectedShapeRef, 180 | syncShapeInStorage, 181 | setActiveElement, 182 | }: CanvasMouseUp) => { 183 | isDrawing.current = false; 184 | if (selectedShapeRef.current === "freeform") return; 185 | 186 | // sync shape in storage as drawing is stopped 187 | syncShapeInStorage(shapeRef.current); 188 | 189 | // set everything to null 190 | shapeRef.current = null; 191 | activeObjectRef.current = null; 192 | selectedShapeRef.current = null; 193 | 194 | // if canvas is not in drawing mode, set active element to default nav element after 700ms 195 | if (!canvas.isDrawingMode) { 196 | setTimeout(() => { 197 | setActiveElement(defaultNavElement); 198 | }, 700); 199 | } 200 | }; 201 | 202 | // update shape in storage when object is modified 203 | export const handleCanvasObjectModified = ({ 204 | options, 205 | syncShapeInStorage, 206 | }: CanvasObjectModified) => { 207 | const target = options.target; 208 | if (!target) return; 209 | 210 | if (target?.type == "activeSelection") { 211 | // fix this 212 | } else { 213 | syncShapeInStorage(target); 214 | } 215 | }; 216 | 217 | // update shape in storage when path is created when in freeform mode 218 | export const handlePathCreated = ({ 219 | options, 220 | syncShapeInStorage, 221 | }: CanvasPathCreated) => { 222 | // get path object 223 | const path = options.path; 224 | if (!path) return; 225 | 226 | // set unique id to path object 227 | path.set({ 228 | objectId: uuid4(), 229 | }); 230 | 231 | // sync shape in storage 232 | syncShapeInStorage(path); 233 | }; 234 | 235 | // check how object is moving on canvas and restrict it to canvas boundaries 236 | export const handleCanvasObjectMoving = ({ 237 | options, 238 | }: { 239 | options: fabric.IEvent; 240 | }) => { 241 | // get target object which is moving 242 | const target = options.target as fabric.Object; 243 | 244 | // target.canvas is the canvas on which the object is moving 245 | const canvas = target.canvas as fabric.Canvas; 246 | 247 | // set coordinates of target object 248 | target.setCoords(); 249 | 250 | // restrict object to canvas boundaries (horizontal) 251 | if (target && target.left) { 252 | target.left = Math.max( 253 | 0, 254 | Math.min( 255 | target.left, 256 | (canvas.width || 0) - (target.getScaledWidth() || target.width || 0) 257 | ) 258 | ); 259 | } 260 | 261 | // restrict object to canvas boundaries (vertical) 262 | if (target && target.top) { 263 | target.top = Math.max( 264 | 0, 265 | Math.min( 266 | target.top, 267 | (canvas.height || 0) - (target.getScaledHeight() || target.height || 0) 268 | ) 269 | ); 270 | } 271 | }; 272 | 273 | // set element attributes when element is selected 274 | export const handleCanvasSelectionCreated = ({ 275 | options, 276 | isEditingRef, 277 | setElementAttributes, 278 | }: CanvasSelectionCreated) => { 279 | // if user is editing manually, return 280 | if (isEditingRef.current) return; 281 | 282 | // if no element is selected, return 283 | if (!options?.selected) return; 284 | 285 | // get the selected element 286 | const selectedElement = options?.selected[0] as fabric.Object; 287 | 288 | // if only one element is selected, set element attributes 289 | if (selectedElement && options.selected.length === 1) { 290 | // calculate scaled dimensions of the object 291 | const scaledWidth = selectedElement?.scaleX 292 | ? selectedElement?.width! * selectedElement?.scaleX 293 | : selectedElement?.width; 294 | 295 | const scaledHeight = selectedElement?.scaleY 296 | ? selectedElement?.height! * selectedElement?.scaleY 297 | : selectedElement?.height; 298 | 299 | setElementAttributes({ 300 | width: scaledWidth?.toFixed(0).toString() || "", 301 | height: scaledHeight?.toFixed(0).toString() || "", 302 | fill: selectedElement?.fill?.toString() || "", 303 | stroke: selectedElement?.stroke || "", 304 | // @ts-ignore 305 | fontSize: selectedElement?.fontSize || "", 306 | // @ts-ignore 307 | fontFamily: selectedElement?.fontFamily || "", 308 | // @ts-ignore 309 | fontWeight: selectedElement?.fontWeight || "", 310 | }); 311 | } 312 | }; 313 | 314 | // update element attributes when element is scaled 315 | export const handleCanvasObjectScaling = ({ 316 | options, 317 | setElementAttributes, 318 | }: CanvasObjectScaling) => { 319 | const selectedElement = options.target; 320 | 321 | // calculate scaled dimensions of the object 322 | const scaledWidth = selectedElement?.scaleX 323 | ? selectedElement?.width! * selectedElement?.scaleX 324 | : selectedElement?.width; 325 | 326 | const scaledHeight = selectedElement?.scaleY 327 | ? selectedElement?.height! * selectedElement?.scaleY 328 | : selectedElement?.height; 329 | 330 | setElementAttributes((prev) => ({ 331 | ...prev, 332 | width: scaledWidth?.toFixed(0).toString() || "", 333 | height: scaledHeight?.toFixed(0).toString() || "", 334 | })); 335 | }; 336 | 337 | // render canvas objects coming from storage on canvas 338 | export const renderCanvas = ({ 339 | fabricRef, 340 | canvasObjects, 341 | activeObjectRef, 342 | }: RenderCanvas) => { 343 | // clear canvas 344 | fabricRef.current?.clear(); 345 | 346 | // render all objects on canvas 347 | Array.from(canvasObjects, ([objectId, objectData]) => { 348 | /** 349 | * enlivenObjects() is used to render objects on canvas. 350 | * It takes two arguments: 351 | * 1. objectData: object data to render on canvas 352 | * 2. callback: callback function to execute after rendering objects 353 | * on canvas 354 | * 355 | * enlivenObjects: http://fabricjs.com/docs/fabric.util.html#.enlivenObjectEnlivables 356 | */ 357 | fabric.util.enlivenObjects( 358 | [objectData], 359 | (enlivenedObjects: fabric.Object[]) => { 360 | enlivenedObjects.forEach((enlivenedObj) => { 361 | // if element is active, keep it in active state so that it can be edited further 362 | if (activeObjectRef.current?.objectId === objectId) { 363 | fabricRef.current?.setActiveObject(enlivenedObj); 364 | } 365 | 366 | // add object to canvas 367 | fabricRef.current?.add(enlivenedObj); 368 | }); 369 | }, 370 | /** 371 | * specify namespace of the object for fabric to render it on canvas 372 | * A namespace is a string that is used to identify the type of 373 | * object. 374 | * 375 | * Fabric Namespace: http://fabricjs.com/docs/fabric.html 376 | */ 377 | "fabric" 378 | ); 379 | }); 380 | 381 | fabricRef.current?.renderAll(); 382 | }; 383 | 384 | // resize canvas dimensions on window resize 385 | export const handleResize = ({ canvas }: { canvas: fabric.Canvas | null }) => { 386 | const canvasElement = document.getElementById("canvas"); 387 | if (!canvasElement) return; 388 | 389 | if (!canvas) return; 390 | 391 | canvas.setDimensions({ 392 | width: canvasElement.clientWidth, 393 | height: canvasElement.clientHeight, 394 | }); 395 | }; 396 | 397 | // zoom canvas on mouse scroll 398 | export const handleCanvasZoom = ({ 399 | options, 400 | canvas, 401 | }: { 402 | options: fabric.IEvent & { e: WheelEvent }; 403 | canvas: fabric.Canvas; 404 | }) => { 405 | const delta = options.e?.deltaY; 406 | let zoom = canvas.getZoom(); 407 | 408 | // allow zooming to min 20% and max 100% 409 | const minZoom = 0.2; 410 | const maxZoom = 1; 411 | const zoomStep = 0.001; 412 | 413 | // calculate zoom based on mouse scroll wheel with min and max zoom 414 | zoom = Math.min(Math.max(minZoom, zoom + delta * zoomStep), maxZoom); 415 | 416 | // set zoom to canvas 417 | // zoomToPoint: http://fabricjs.com/docs/fabric.Canvas.html#zoomToPoint 418 | canvas.zoomToPoint({ x: options.e.offsetX, y: options.e.offsetY }, zoom); 419 | 420 | options.e.preventDefault(); 421 | options.e.stopPropagation(); 422 | }; 423 | -------------------------------------------------------------------------------- /app/App.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { fabric } from "fabric"; 4 | import { useEffect, useRef, useState } from "react"; 5 | 6 | import { useMutation, useRedo, useStorage, useUndo } from "@/liveblocks.config"; 7 | import { 8 | handleCanvaseMouseMove, 9 | handleCanvasMouseDown, 10 | handleCanvasMouseUp, 11 | handleCanvasObjectModified, 12 | handleCanvasObjectMoving, 13 | handleCanvasObjectScaling, 14 | handleCanvasSelectionCreated, 15 | handleCanvasZoom, 16 | handlePathCreated, 17 | handleResize, 18 | initializeFabric, 19 | renderCanvas, 20 | } from "@/lib/canvas"; 21 | import { handleDelete, handleKeyDown } from "@/lib/key-events"; 22 | import { LeftSidebar, Live, Navbar, RightSidebar } from "@/components/index"; 23 | import { handleImageUpload } from "@/lib/shapes"; 24 | import { defaultNavElement } from "@/constants"; 25 | import { ActiveElement, Attributes } from "@/types/type"; 26 | 27 | const Home = () => { 28 | /** 29 | * useUndo and useRedo are hooks provided by Liveblocks that allow you to 30 | * undo and redo mutations. 31 | * 32 | * useUndo: https://liveblocks.io/docs/api-reference/liveblocks-react#useUndo 33 | * useRedo: https://liveblocks.io/docs/api-reference/liveblocks-react#useRedo 34 | */ 35 | const undo = useUndo(); 36 | const redo = useRedo(); 37 | 38 | /** 39 | * useStorage is a hook provided by Liveblocks that allows you to store 40 | * data in a key-value store and automatically sync it with other users 41 | * i.e., subscribes to updates to that selected data 42 | * 43 | * useStorage: https://liveblocks.io/docs/api-reference/liveblocks-react#useStorage 44 | * 45 | * Over here, we are storing the canvas objects in the key-value store. 46 | */ 47 | const canvasObjects = useStorage((root) => root.canvasObjects); 48 | 49 | /** 50 | * canvasRef is a reference to the canvas element that we'll use to initialize 51 | * the fabric canvas. 52 | * 53 | * fabricRef is a reference to the fabric canvas that we use to perform 54 | * operations on the canvas. It's a copy of the created canvas so we can use 55 | * it outside the canvas event listeners. 56 | */ 57 | const canvasRef = useRef(null); 58 | const fabricRef = useRef(null); 59 | 60 | /** 61 | * isDrawing is a boolean that tells us if the user is drawing on the canvas. 62 | * We use this to determine if the user is drawing or not 63 | * i.e., if the freeform drawing mode is on or not. 64 | */ 65 | const isDrawing = useRef(false); 66 | 67 | /** 68 | * shapeRef is a reference to the shape that the user is currently drawing. 69 | * We use this to update the shape's properties when the user is 70 | * drawing/creating shape 71 | */ 72 | const shapeRef = useRef(null); 73 | 74 | /** 75 | * selectedShapeRef is a reference to the shape that the user has selected. 76 | * For example, if the user has selected the rectangle shape, then this will 77 | * be set to "rectangle". 78 | * 79 | * We're using refs here because we want to access these variables inside the 80 | * event listeners. We don't want to lose the values of these variables when 81 | * the component re-renders. Refs help us with that. 82 | */ 83 | const selectedShapeRef = useRef(null); 84 | 85 | /** 86 | * activeObjectRef is a reference to the active/selected object in the canvas 87 | * 88 | * We want to keep track of the active object so that we can keep it in 89 | * selected form when user is editing the width, height, color etc 90 | * properties/attributes of the object. 91 | * 92 | * Since we're using live storage to sync shapes across users in real-time, 93 | * we have to re-render the canvas when the shapes are updated. 94 | * Due to this re-render, the selected shape is lost. We want to keep track 95 | * of the selected shape so that we can keep it selected when the 96 | * canvas re-renders. 97 | */ 98 | const activeObjectRef = useRef(null); 99 | const isEditingRef = useRef(false); 100 | 101 | /** 102 | * imageInputRef is a reference to the input element that we use to upload 103 | * an image to the canvas. 104 | * 105 | * We want image upload to happen when clicked on the image item from the 106 | * dropdown menu. So we're using this ref to trigger the click event on the 107 | * input element when the user clicks on the image item from the dropdown. 108 | */ 109 | const imageInputRef = useRef(null); 110 | 111 | /** 112 | * activeElement is an object that contains the name, value and icon of the 113 | * active element in the navbar. 114 | */ 115 | const [activeElement, setActiveElement] = useState({ 116 | name: "", 117 | value: "", 118 | icon: "", 119 | }); 120 | 121 | /** 122 | * elementAttributes is an object that contains the attributes of the selected 123 | * element in the canvas. 124 | * 125 | * We use this to update the attributes of the selected element when the user 126 | * is editing the width, height, color etc properties/attributes of the 127 | * object. 128 | */ 129 | const [elementAttributes, setElementAttributes] = useState({ 130 | width: "", 131 | height: "", 132 | fontSize: "", 133 | fontFamily: "", 134 | fontWeight: "", 135 | fill: "#aabbcc", 136 | stroke: "#aabbcc", 137 | }); 138 | 139 | /** 140 | * deleteShapeFromStorage is a mutation that deletes a shape from the 141 | * key-value store of liveblocks. 142 | * useMutation is a hook provided by Liveblocks that allows you to perform 143 | * mutations on liveblocks data. 144 | * 145 | * useMutation: https://liveblocks.io/docs/api-reference/liveblocks-react#useMutation 146 | * delete: https://liveblocks.io/docs/api-reference/liveblocks-client#LiveMap.delete 147 | * get: https://liveblocks.io/docs/api-reference/liveblocks-client#LiveMap.get 148 | * 149 | * We're using this mutation to delete a shape from the key-value store when 150 | * the user deletes a shape from the canvas. 151 | */ 152 | const deleteShapeFromStorage = useMutation(({ storage }, shapeId) => { 153 | /** 154 | * canvasObjects is a Map that contains all the shapes in the key-value. 155 | * Like a store. We can create multiple stores in liveblocks. 156 | * 157 | * delete: https://liveblocks.io/docs/api-reference/liveblocks-client#LiveMap.delete 158 | */ 159 | const canvasObjects = storage.get("canvasObjects"); 160 | canvasObjects.delete(shapeId); 161 | }, []); 162 | 163 | /** 164 | * deleteAllShapes is a mutation that deletes all the shapes from the 165 | * key-value store of liveblocks. 166 | * 167 | * delete: https://liveblocks.io/docs/api-reference/liveblocks-client#LiveMap.delete 168 | * get: https://liveblocks.io/docs/api-reference/liveblocks-client#LiveMap.get 169 | * 170 | * We're using this mutation to delete all the shapes from the key-value store when the user clicks on the reset button. 171 | */ 172 | const deleteAllShapes = useMutation(({ storage }) => { 173 | // get the canvasObjects store 174 | const canvasObjects = storage.get("canvasObjects"); 175 | 176 | // if the store doesn't exist or is empty, return 177 | if (!canvasObjects || canvasObjects.size === 0) return true; 178 | 179 | // delete all the shapes from the store 180 | for (const [key, value] of canvasObjects.entries()) { 181 | canvasObjects.delete(key); 182 | } 183 | 184 | // return true if the store is empty 185 | return canvasObjects.size === 0; 186 | }, []); 187 | 188 | /** 189 | * syncShapeInStorage is a mutation that syncs the shape in the key-value 190 | * store of liveblocks. 191 | * 192 | * We're using this mutation to sync the shape in the key-value store 193 | * whenever user performs any action on the canvas such as drawing, moving 194 | * editing, deleting etc. 195 | */ 196 | const syncShapeInStorage = useMutation(({ storage }, object) => { 197 | // if the passed object is null, return 198 | if (!object) return; 199 | const { objectId } = object; 200 | 201 | /** 202 | * Turn Fabric object (kclass) into JSON format so that we can store it in the 203 | * key-value store. 204 | */ 205 | const shapeData = object.toJSON(); 206 | shapeData.objectId = objectId; 207 | 208 | const canvasObjects = storage.get("canvasObjects"); 209 | /** 210 | * set is a method provided by Liveblocks that allows you to set a value 211 | * 212 | * set: https://liveblocks.io/docs/api-reference/liveblocks-client#LiveMap.set 213 | */ 214 | canvasObjects.set(objectId, shapeData); 215 | }, []); 216 | 217 | /** 218 | * Set the active element in the navbar and perform the action based 219 | * on the selected element. 220 | * 221 | * @param elem 222 | */ 223 | const handleActiveElement = (elem: ActiveElement) => { 224 | setActiveElement(elem); 225 | 226 | switch (elem?.value) { 227 | // delete all the shapes from the canvas 228 | case "reset": 229 | // clear the storage 230 | deleteAllShapes(); 231 | // clear the canvas 232 | fabricRef.current?.clear(); 233 | // set "select" as the active element 234 | setActiveElement(defaultNavElement); 235 | break; 236 | 237 | // delete the selected shape from the canvas 238 | case "delete": 239 | // delete it from the canvas 240 | handleDelete(fabricRef.current as any, deleteShapeFromStorage); 241 | // set "select" as the active element 242 | setActiveElement(defaultNavElement); 243 | break; 244 | 245 | // upload an image to the canvas 246 | case "image": 247 | // trigger the click event on the input element which opens the file dialog 248 | imageInputRef.current?.click(); 249 | /** 250 | * set drawing mode to false 251 | * If the user is drawing on the canvas, we want to stop the 252 | * drawing mode when clicked on the image item from the dropdown. 253 | */ 254 | isDrawing.current = false; 255 | 256 | if (fabricRef.current) { 257 | // disable the drawing mode of canvas 258 | fabricRef.current.isDrawingMode = false; 259 | } 260 | break; 261 | 262 | // for comments, do nothing 263 | case "comments": 264 | break; 265 | 266 | default: 267 | // set the selected shape to the selected element 268 | selectedShapeRef.current = elem?.value as string; 269 | break; 270 | } 271 | }; 272 | 273 | useEffect(() => { 274 | // initialize the fabric canvas 275 | const canvas = initializeFabric({ 276 | canvasRef, 277 | fabricRef, 278 | }); 279 | 280 | /** 281 | * listen to the mouse down event on the canvas which is fired when the 282 | * user clicks on the canvas 283 | * 284 | * Event inspector: http://fabricjs.com/events 285 | * Event list: http://fabricjs.com/docs/fabric.Canvas.html#fire 286 | */ 287 | canvas.on("mouse:down", (options) => { 288 | handleCanvasMouseDown({ 289 | options, 290 | canvas, 291 | selectedShapeRef, 292 | isDrawing, 293 | shapeRef, 294 | }); 295 | }); 296 | 297 | /** 298 | * listen to the mouse move event on the canvas which is fired when the 299 | * user moves the mouse on the canvas 300 | * 301 | * Event inspector: http://fabricjs.com/events 302 | * Event list: http://fabricjs.com/docs/fabric.Canvas.html#fire 303 | */ 304 | canvas.on("mouse:move", (options) => { 305 | handleCanvaseMouseMove({ 306 | options, 307 | canvas, 308 | isDrawing, 309 | selectedShapeRef, 310 | shapeRef, 311 | syncShapeInStorage, 312 | }); 313 | }); 314 | 315 | /** 316 | * listen to the mouse up event on the canvas which is fired when the 317 | * user releases the mouse on the canvas 318 | * 319 | * Event inspector: http://fabricjs.com/events 320 | * Event list: http://fabricjs.com/docs/fabric.Canvas.html#fire 321 | */ 322 | canvas.on("mouse:up", () => { 323 | handleCanvasMouseUp({ 324 | canvas, 325 | isDrawing, 326 | shapeRef, 327 | activeObjectRef, 328 | selectedShapeRef, 329 | syncShapeInStorage, 330 | setActiveElement, 331 | }); 332 | }); 333 | 334 | /** 335 | * listen to the path created event on the canvas which is fired when 336 | * the user creates a path on the canvas using the freeform drawing 337 | * mode 338 | * 339 | * Event inspector: http://fabricjs.com/events 340 | * Event list: http://fabricjs.com/docs/fabric.Canvas.html#fire 341 | */ 342 | canvas.on("path:created", (options) => { 343 | handlePathCreated({ 344 | options, 345 | syncShapeInStorage, 346 | }); 347 | }); 348 | 349 | /** 350 | * listen to the object modified event on the canvas which is fired 351 | * when the user modifies an object on the canvas. Basically, when the 352 | * user changes the width, height, color etc properties/attributes of 353 | * the object or moves the object on the canvas. 354 | * 355 | * Event inspector: http://fabricjs.com/events 356 | * Event list: http://fabricjs.com/docs/fabric.Canvas.html#fire 357 | */ 358 | canvas.on("object:modified", (options) => { 359 | handleCanvasObjectModified({ 360 | options, 361 | syncShapeInStorage, 362 | }); 363 | }); 364 | 365 | /** 366 | * listen to the object moving event on the canvas which is fired 367 | * when the user moves an object on the canvas. 368 | * 369 | * Event inspector: http://fabricjs.com/events 370 | * Event list: http://fabricjs.com/docs/fabric.Canvas.html#fire 371 | */ 372 | canvas?.on("object:moving", (options) => { 373 | handleCanvasObjectMoving({ 374 | options, 375 | }); 376 | }); 377 | 378 | /** 379 | * listen to the selection created event on the canvas which is fired 380 | * when the user selects an object on the canvas. 381 | * 382 | * Event inspector: http://fabricjs.com/events 383 | * Event list: http://fabricjs.com/docs/fabric.Canvas.html#fire 384 | */ 385 | canvas.on("selection:created", (options) => { 386 | handleCanvasSelectionCreated({ 387 | options, 388 | isEditingRef, 389 | setElementAttributes, 390 | }); 391 | }); 392 | 393 | /** 394 | * listen to the scaling event on the canvas which is fired when the 395 | * user scales an object on the canvas. 396 | * 397 | * Event inspector: http://fabricjs.com/events 398 | * Event list: http://fabricjs.com/docs/fabric.Canvas.html#fire 399 | */ 400 | canvas.on("object:scaling", (options) => { 401 | handleCanvasObjectScaling({ 402 | options, 403 | setElementAttributes, 404 | }); 405 | }); 406 | 407 | /** 408 | * listen to the mouse wheel event on the canvas which is fired when 409 | * the user scrolls the mouse wheel on the canvas. 410 | * 411 | * Event inspector: http://fabricjs.com/events 412 | * Event list: http://fabricjs.com/docs/fabric.Canvas.html#fire 413 | */ 414 | canvas.on("mouse:wheel", (options) => { 415 | handleCanvasZoom({ 416 | options, 417 | canvas, 418 | }); 419 | }); 420 | 421 | /** 422 | * listen to the resize event on the window which is fired when the 423 | * user resizes the window. 424 | * 425 | * We're using this to resize the canvas when the user resizes the 426 | * window. 427 | */ 428 | window.addEventListener("resize", () => { 429 | handleResize({ 430 | canvas: fabricRef.current, 431 | }); 432 | }); 433 | 434 | /** 435 | * listen to the key down event on the window which is fired when the 436 | * user presses a key on the keyboard. 437 | * 438 | * We're using this to perform some actions like delete, copy, paste, etc when the user presses the respective keys on the keyboard. 439 | */ 440 | window.addEventListener("keydown", (e) => 441 | handleKeyDown({ 442 | e, 443 | canvas: fabricRef.current, 444 | undo, 445 | redo, 446 | syncShapeInStorage, 447 | deleteShapeFromStorage, 448 | }) 449 | ); 450 | 451 | // dispose the canvas and remove the event listeners when the component unmounts 452 | return () => { 453 | /** 454 | * dispose is a method provided by Fabric that allows you to dispose 455 | * the canvas. It clears the canvas and removes all the event 456 | * listeners 457 | * 458 | * dispose: http://fabricjs.com/docs/fabric.Canvas.html#dispose 459 | */ 460 | canvas.dispose(); 461 | 462 | // remove the event listeners 463 | window.removeEventListener("resize", () => { 464 | handleResize({ 465 | canvas: null, 466 | }); 467 | }); 468 | 469 | window.removeEventListener("keydown", (e) => 470 | handleKeyDown({ 471 | e, 472 | canvas: fabricRef.current, 473 | undo, 474 | redo, 475 | syncShapeInStorage, 476 | deleteShapeFromStorage, 477 | }) 478 | ); 479 | }; 480 | }, [canvasRef]); // run this effect only once when the component mounts and the canvasRef changes 481 | 482 | // render the canvas when the canvasObjects from live storage changes 483 | useEffect(() => { 484 | renderCanvas({ 485 | fabricRef, 486 | canvasObjects, 487 | activeObjectRef, 488 | }); 489 | }, [canvasObjects]); 490 | 491 | return ( 492 |
493 | { 497 | // prevent the default behavior of the input element 498 | e.stopPropagation(); 499 | 500 | handleImageUpload({ 501 | file: e.target.files[0], 502 | canvas: fabricRef as any, 503 | shapeRef, 504 | syncShapeInStorage, 505 | }); 506 | }} 507 | handleActiveElement={handleActiveElement} 508 | /> 509 | 510 |
511 | 512 | 513 | 514 | 515 | 523 |
524 |
525 | ); 526 | }; 527 | 528 | export default Home; 529 | --------------------------------------------------------------------------------