├── .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 |
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 |
13 | Export to PDF
14 |
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 |
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 |
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 | {attribute}
31 |
32 | 90%
33 |
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 | onSelect(reaction)}
30 | >
31 | {reaction}
32 |
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 |
25 | {item.label}
26 |
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 |
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 |
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 | handleActiveElement(item)}>
24 |
30 |
31 |
32 |
33 |
34 | {item.value.map((elem) => (
35 | {
38 | handleActiveElement(elem);
39 | }}
40 | className={`flex h-fit justify-between gap-10 rounded-none px-5 py-3 focus:border-none ${
41 | activeElement.value === elem?.value ? "bg-primary-green" : "hover:bg-primary-grey-200"
42 | }`}
43 | >
44 |
45 |
52 |
57 | {elem?.name}
58 |
59 |
60 |
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 |
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 |
21 |
22 |
23 |
24 | {navElements.map((item: ActiveElement | any) => (
25 | {
28 | if (Array.isArray(item.value)) return;
29 | handleActiveElement(item);
30 | }}
31 | className={`group px-2.5 py-5 flex justify-center items-center
32 | ${isActive(item.value) ? "bg-primary-green" : "hover:bg-primary-grey-200"}
33 | `}
34 | >
35 | {/* If value is an array means it's a nav element with sub options i.e., dropdown */}
36 | {Array.isArray(item.value) ? (
37 |
44 | ) : item?.value === "comments" ? (
45 | // If value is comments, trigger the NewThread component
46 |
47 |
48 |
54 |
55 |
56 | ) : (
57 |
58 |
64 |
65 | )}
66 |
67 | ))}
68 |
69 |
70 |
71 |
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 | handleInputChange(config.property, value)}
91 | value={
92 | config.property === "fontFamily"
93 | ? fontFamily
94 | : config.property === "fontSize"
95 | ? fontSize
96 | : fontWeight
97 | }
98 | >
99 |
100 |
109 |
110 |
111 | {config.options.map((option) => (
112 |
117 | {option.label}
118 |
119 | ))}
120 |
121 |
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 |
5 |
6 |
7 |
8 |
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 |
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 |
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 |
--------------------------------------------------------------------------------