├── bun.lockb
├── favicon.ico
├── app
├── icon1.png
├── icon2.png
├── favicon.ico
├── apple-icon.png
├── page.tsx
├── room.tsx
├── layout.tsx
├── globals.css
└── app.tsx
├── public
├── loader.gif
├── line.svg
├── hash.svg
├── delete.svg
├── rectangle.svg
├── circle.svg
├── polygon.svg
├── select.svg
├── text.svg
├── cursor-svg.tsx
├── reset.svg
├── back.svg
├── front.svg
├── comments.svg
├── image.svg
├── triangle.svg
├── github.svg
├── group.svg
├── ungroup.svg
├── align-top.svg
├── align-bottom.svg
├── align-vertical-center.svg
├── align-left.svg
├── align-right.svg
├── align-horizontal-center.svg
├── logo.svg
└── freeform.svg
├── .github
├── images
│ ├── img1.png
│ ├── img2.png
│ ├── img3.png
│ ├── img_main.png
│ └── stats.svg
├── FUNDING.yml
├── dependabot.yml
└── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── types
├── declaration.d.ts
└── type.ts
├── .eslintrc.json
├── postcss.config.mjs
├── .prettierrc.json
├── .env.example
├── environment.d.ts
├── components
├── comments
│ ├── comments.tsx
│ ├── pinned-composer.tsx
│ ├── new-thread-cursor.tsx
│ ├── comments-overlay.tsx
│ ├── pinned-thread.tsx
│ └── new-thread.tsx
├── loader.tsx
├── settings
│ ├── export.tsx
│ ├── color.tsx
│ ├── dimensions.tsx
│ └── text.tsx
├── cursor
│ ├── live-cursors.tsx
│ ├── cursor.tsx
│ └── cursor-chat.tsx
├── users
│ ├── avatar.tsx
│ ├── index.module.css
│ └── active-users.tsx
├── ui
│ ├── label.tsx
│ ├── input.tsx
│ ├── button.tsx
│ ├── select.tsx
│ ├── context-menu.tsx
│ └── dropdown-menu.tsx
├── reaction
│ ├── flying-reaction.tsx
│ ├── reaction-button.tsx
│ └── index.module.css
├── left-sidebar.tsx
├── right-sidebar.tsx
├── shapes-menu.tsx
├── navbar.tsx
└── live.tsx
├── components.json
├── next.config.mjs
├── .gitignore
├── lib
├── use-max-zindex.ts
├── utils.ts
├── key-events.ts
├── shapes.ts
└── canvas.ts
├── SECURITY.md
├── tsconfig.json
├── hooks
└── use-interval.ts
├── LICENSE
├── config
└── index.ts
├── tailwind.config.ts
├── liveblocks.config.ts
├── package.json
├── constants
└── index.ts
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
└── README.md
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/bun.lockb
--------------------------------------------------------------------------------
/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/favicon.ico
--------------------------------------------------------------------------------
/app/icon1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/app/icon1.png
--------------------------------------------------------------------------------
/app/icon2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/app/icon2.png
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/app/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/app/apple-icon.png
--------------------------------------------------------------------------------
/public/loader.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/public/loader.gif
--------------------------------------------------------------------------------
/.github/images/img1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/.github/images/img1.png
--------------------------------------------------------------------------------
/.github/images/img2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/.github/images/img2.png
--------------------------------------------------------------------------------
/.github/images/img3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/.github/images/img3.png
--------------------------------------------------------------------------------
/.github/images/img_main.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sanidhyy/figma-clone/HEAD/.github/images/img_main.png
--------------------------------------------------------------------------------
/types/declaration.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.module.css" {
2 | const classes: { [key: string]: string };
3 | export default classes;
4 | }
5 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import dynamic from "next/dynamic";
2 |
3 | const App = dynamic(() => import("./app"), { ssr: false });
4 |
5 | export default App;
6 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [sanidhyy]
4 | patreon: sanidhy
5 | custom: https://www.buymeacoffee.com/sanidhy
6 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "plugins": ["unused-imports"],
4 | "rules": {
5 | "unused-imports/no-unused-imports": 2
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "semi": true,
4 | "tabWidth": 2,
5 | "singleQuote": false,
6 | "jsxSingleQuote": false,
7 | "plugins": ["prettier-plugin-tailwindcss"]
8 | }
9 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # .env.local
2 |
3 | # disable next.js telemetry
4 | NEXT_TELEMETRY_DISABLED=1
5 |
6 | # liveblocks api key
7 | NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_dev_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
8 |
--------------------------------------------------------------------------------
/public/line.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/hash.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/environment.d.ts:
--------------------------------------------------------------------------------
1 | // This file is needed to support autocomplete for process.env
2 | export {};
3 |
4 | declare global {
5 | namespace NodeJS {
6 | interface ProcessEnv {
7 | // liveblocks api key
8 | NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY: string;
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/components/comments/comments.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ClientSideSuspense } from "@liveblocks/react";
4 |
5 | import { CommentsOverlay } from "@/components/comments/comments-overlay";
6 |
7 | export const Comments = () => (
8 |
9 | {() => }
10 |
11 | );
12 |
--------------------------------------------------------------------------------
/public/delete.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/rectangle.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/circle.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/public/polygon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/components/loader.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | export const Loader = () => (
4 |
15 | );
16 |
--------------------------------------------------------------------------------
/public/select.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/components/settings/export.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { exportToPdf } from "@/lib/utils";
3 |
4 | export const Export = () => (
5 |
6 |
Export
7 |
12 | Export to PDF
13 |
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/public/text.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/public/cursor-svg.tsx:
--------------------------------------------------------------------------------
1 | const CursorSVG = ({ color }: { color: string }) => {
2 | return (
3 |
12 |
16 |
17 | );
18 | };
19 |
20 | export default CursorSVG;
21 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | webpack: (config) => {
4 | config.externals.push({
5 | "utf-8-validate": "commonjs utf-8-validate",
6 | bufferutil: "commonjs bufferutil",
7 | canvas: "commonjs canvas",
8 | });
9 |
10 | return config;
11 | },
12 | images: {
13 | remotePatterns: [
14 | {
15 | protocol: "https",
16 | hostname: "liveblocks.io",
17 | port: "",
18 | },
19 | ],
20 | },
21 | };
22 |
23 | export default nextConfig;
24 |
--------------------------------------------------------------------------------
/public/reset.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # Local Netlify folder
39 | .netlify
40 |
--------------------------------------------------------------------------------
/lib/use-max-zindex.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 |
3 | import { useThreads } from "@/liveblocks.config";
4 |
5 | // Returns the highest z-index of all threads
6 | export const useMaxZIndex = () => {
7 | // get all threads
8 | const { threads } = useThreads();
9 |
10 | // calculate the max z-index
11 | return useMemo(() => {
12 | let max = 0;
13 | for (const thread of threads) {
14 | // @ts-ignore
15 | if (thread.metadata.zIndex > max) {
16 | // @ts-ignore
17 | max = thread.metadata.zIndex;
18 | }
19 | }
20 | return max;
21 | }, [threads]);
22 | };
23 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "bun" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "monthly"
12 | versioning-strategy: increase
13 |
--------------------------------------------------------------------------------
/components/cursor/live-cursors.tsx:
--------------------------------------------------------------------------------
1 | import { COLORS } from "@/constants";
2 | import { useOthers } from "@/liveblocks.config";
3 |
4 | import { Cursor } from "./cursor";
5 |
6 | export const LiveCursors = () => {
7 | const others = useOthers();
8 |
9 | return others.map(({ connectionId, presence }) => {
10 | if (!presence || !presence?.cursor) return;
11 |
12 | return (
13 |
20 | );
21 | });
22 | };
23 |
--------------------------------------------------------------------------------
/public/back.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/public/front.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.**
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | **Describe the solution you'd like**
13 | A clear and concise description of what you want to happen.
14 |
15 | **Describe alternatives you've considered**
16 | A clear and concise description of any alternative solutions or features you've considered.
17 |
18 | **Additional context**
19 | Add any other context or screenshots about the feature request here.
20 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | Use this section to tell people about which versions of your project are
6 | currently being supported with security updates.
7 |
8 | | Version | Supported |
9 | | ------- | ------------------ |
10 | | 5.1.x | :white_check_mark: |
11 | | 5.0.x | :x: |
12 | | 4.0.x | :white_check_mark: |
13 | | < 4.0 | :x: |
14 |
15 | ## Reporting a Vulnerability
16 |
17 | Use this section to tell people how to report a vulnerability.
18 |
19 | Tell them where to go, how often they can expect to get an update on a
20 | reported vulnerability, what to expect if the vulnerability is accepted or
21 | declined, etc.
22 |
--------------------------------------------------------------------------------
/components/users/avatar.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | import styles from "./index.module.css";
6 |
7 | type AvatarProps = { name: string; otherStyles: string };
8 |
9 | export const Avatar = ({ name, otherStyles }: AvatarProps) => {
10 | return (
11 |
15 |
23 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES6",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/hooks/use-interval.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 |
3 | // From Dan Abramov's blog: https://overreacted.io/making-setinterval-declarative-with-react-hooks/
4 |
5 | export const useInterval = (callback: () => void, delay: number) => {
6 | const savedCallback = useRef<() => void>(callback);
7 |
8 | // Remember the latest callback.
9 | useEffect(() => {
10 | savedCallback.current = callback;
11 | }, [callback]);
12 |
13 | // Set up the interval.
14 | useEffect(() => {
15 | const tick = () => {
16 | savedCallback.current();
17 | };
18 |
19 | if (delay !== null) {
20 | let id = setInterval(tick, delay);
21 | return () => clearInterval(id);
22 | }
23 | }, [delay]);
24 | };
25 |
--------------------------------------------------------------------------------
/public/comments.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/room.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { LiveMap } from "@liveblocks/client";
4 | import { ClientSideSuspense } from "@liveblocks/react";
5 | import type { PropsWithChildren } from "react";
6 |
7 | import { Loader } from "@/components/loader";
8 | import { RoomProvider } from "@/liveblocks.config";
9 |
10 | export const Room = ({ children }: PropsWithChildren) => {
11 | return (
12 |
22 | }>
23 | {() => children}
24 |
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/public/image.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/components/users/index.module.css:
--------------------------------------------------------------------------------
1 | .avatar {
2 | display: flex;
3 | place-content: center;
4 | position: relative;
5 | border: 4px solid #fff;
6 | border-radius: 9999px;
7 | width: 40px;
8 | height: 40px;
9 | background-color: #9ca3af;
10 | margin-left: -0.75rem;
11 | }
12 |
13 | .avatar:before {
14 | content: attr(data-tooltip);
15 | position: absolute;
16 | bottom: 100%;
17 | opacity: 0;
18 | transition: opacity 0.15s ease;
19 | padding: 5px 10px;
20 | color: white;
21 | font-size: 0.75rem;
22 | border-radius: 8px;
23 | margin-bottom: 10px;
24 | z-index: 1;
25 | background: black;
26 | white-space: nowrap;
27 | }
28 |
29 | .avatar:hover:before {
30 | opacity: 1;
31 | }
32 |
33 | .avatar_picture {
34 | width: 100%;
35 | height: 100%;
36 | border-radius: 9999px;
37 | }
38 |
--------------------------------------------------------------------------------
/public/triangle.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | );
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/components/reaction/flying-reaction.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import styles from "./index.module.css";
3 |
4 | type FlyingReactionProps = {
5 | x: number;
6 | y: number;
7 | timestamp: number;
8 | value: string;
9 | };
10 |
11 | export const FlyingReaction = ({
12 | x,
13 | y,
14 | timestamp,
15 | value,
16 | }: FlyingReactionProps) => {
17 | return (
18 |
27 |
28 |
29 | {value}
30 |
31 |
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/components/cursor/cursor.tsx:
--------------------------------------------------------------------------------
1 | import CursorSVG from "@/public/cursor-svg";
2 |
3 | type CursorProps = {
4 | color: string;
5 | x: number;
6 | y: number;
7 | message: string;
8 | };
9 |
10 | export const Cursor = ({ color, x, y, message }: CursorProps) => {
11 | return (
12 |
18 |
19 |
20 | {message && (
21 |
27 |
28 | {message}
29 |
30 |
31 | )}
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata, Viewport } from "next";
2 | import { Work_Sans } from "next/font/google";
3 | import type { PropsWithChildren } from "react";
4 |
5 | import { siteConfig } from "@/config";
6 | import { cn } from "@/lib/utils";
7 |
8 | import { Room } from "./room";
9 |
10 | import "./globals.css";
11 |
12 | const workSans = Work_Sans({
13 | subsets: ["latin"],
14 | variable: "--font-work-sans",
15 | weight: ["400", "600", "700"],
16 | });
17 |
18 | export const viewport: Viewport = {
19 | themeColor: "#14181F",
20 | };
21 |
22 | export const metadata: Metadata = siteConfig;
23 |
24 | const RootLayout = ({ children }: Readonly) => {
25 | return (
26 |
27 |
28 | {children}
29 |
30 |
31 | );
32 | };
33 |
34 | export default RootLayout;
35 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @import "@liveblocks/react-comments/styles.css";
6 |
7 | * {
8 | font-family:
9 | work sans,
10 | sans-serif;
11 | }
12 |
13 | body::-webkit-scrollbar {
14 | width: 0;
15 | }
16 |
17 | @layer utilities {
18 | .no-ring {
19 | @apply outline-none ring-0 ring-offset-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-offset-0 !important;
20 | }
21 |
22 | .input-ring {
23 | @apply h-8 rounded-none border-none bg-transparent outline-none ring-offset-0 focus:ring-1 focus:ring-primary-green focus:ring-offset-0 focus-visible:ring-offset-0 !important;
24 | }
25 |
26 | .right-menu-content {
27 | @apply flex w-80 flex-col gap-y-1 border-none bg-primary-black py-4 text-white !important;
28 | }
29 |
30 | .right-menu-item {
31 | @apply flex justify-between px-3 py-2 hover:bg-primary-grey-200 !important;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/public/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | }
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/components/settings/color.tsx:
--------------------------------------------------------------------------------
1 | import { Label } from "@/components/ui/label";
2 |
3 | type ColorProps = {
4 | inputRef: any;
5 | attribute: string;
6 | placeholder: string;
7 | attributeType: string;
8 | handleInputChange: (property: string, value: string) => void;
9 | };
10 |
11 | export const Color = ({
12 | inputRef,
13 | attribute,
14 | placeholder,
15 | attributeType,
16 | handleInputChange,
17 | }: ColorProps) => (
18 |
19 |
{placeholder}
20 |
inputRef.current.click()}
23 | >
24 | handleInputChange(attributeType, e.target.value)}
29 | />
30 | {attribute}
31 |
32 |
33 | );
34 |
--------------------------------------------------------------------------------
/public/group.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ""
5 | labels: ""
6 | assignees: ""
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **To Reproduce**
13 | Steps to reproduce the behavior:
14 |
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 |
28 | - OS: [e.g. iOS]
29 | - Browser [e.g. chrome, safari]
30 | - Version [e.g. 22]
31 |
32 | **Smartphone (please complete the following information):**
33 |
34 | - Device: [e.g. iPhone6]
35 | - OS: [e.g. iOS8.1]
36 | - Browser [e.g. stock browser, safari]
37 | - Version [e.g. 22]
38 |
39 | **Additional context**
40 | Add any other context about the problem here.
41 |
--------------------------------------------------------------------------------
/public/ungroup.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Sanidhya Kumar Verma
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/config/index.ts:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 |
3 | export const siteConfig: Metadata = {
4 | title: "Figma Clone",
5 | description:
6 | "A minimalist Figma clone using Fabric.js and Liveblocks for real-time collaboration.",
7 | keywords: [
8 | "reactjs",
9 | "nextjs",
10 | "vercel",
11 | "react",
12 | "liveblocks",
13 | "livekit",
14 | "shadcn",
15 | "shadcn-ui",
16 | "radix-ui",
17 | "cn",
18 | "clsx",
19 | "figma-clone",
20 | "realtime-drawing",
21 | "live-chat",
22 | "live-reactions",
23 | "live-updates",
24 | "sonner",
25 | "zustand",
26 | "zod",
27 | "lucide-react",
28 | "next-themes",
29 | "postcss",
30 | "prettier",
31 | "react-dom",
32 | "tailwindcss",
33 | "tailwindcss-animate",
34 | "ui/ux",
35 | "js",
36 | "javascript",
37 | "typescript",
38 | "eslint",
39 | "html",
40 | "css",
41 | ] as Array,
42 | authors: {
43 | name: "Sanidhya Kumar Verma",
44 | url: "https://github.com/sanidhyy",
45 | },
46 | } as const;
47 |
48 | export const links = {
49 | sourceCode: "https://github.com/sanidhyy/figma-clone",
50 | } as const;
51 |
--------------------------------------------------------------------------------
/components/reaction/reaction-button.tsx:
--------------------------------------------------------------------------------
1 | type ReactionSelectorProps = {
2 | setReaction: (reaction: string) => void;
3 | };
4 |
5 | export const ReactionSelector = ({ setReaction }: ReactionSelectorProps) => {
6 | return (
7 | e.stopPropagation()}
10 | >
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | const ReactionButton = ({
22 | reaction,
23 | onSelect,
24 | }: {
25 | reaction: string;
26 | onSelect: (reaction: string) => void;
27 | }) => {
28 | return (
29 | onSelect(reaction)}
32 | >
33 | {reaction}
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | primary: {
23 | black: "#14181F",
24 | green: "#56FFA6",
25 | grey: {
26 | 100: "#2B303B",
27 | 200: "#202731",
28 | 300: "#C4D3ED",
29 | },
30 | },
31 | },
32 | keyframes: {
33 | "accordion-down": {
34 | from: { height: "0" },
35 | to: { height: "var(--radix-accordion-content-height)" },
36 | },
37 | "accordion-up": {
38 | from: { height: "var(--radix-accordion-content-height)" },
39 | to: { height: "0" },
40 | },
41 | },
42 | animation: {
43 | "accordion-down": "accordion-down 0.2s ease-out",
44 | "accordion-up": "accordion-up 0.2s ease-out",
45 | },
46 | },
47 | },
48 | plugins: [require("tailwindcss-animate")],
49 | } satisfies Config;
50 |
51 | export default config;
52 |
--------------------------------------------------------------------------------
/public/align-top.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/public/align-bottom.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/public/align-vertical-center.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/public/align-left.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/components/users/active-users.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 |
3 | import { generateRandomName } from "@/lib/utils";
4 | import { useOthers, useSelf } from "@/liveblocks.config";
5 |
6 | import { Avatar } from "./avatar";
7 |
8 | export const ActiveUsers = () => {
9 | const users = useOthers();
10 | const currentUser = useSelf();
11 | const hasMoreUsers = users.length > 3;
12 |
13 | const memorizedUsers = useMemo(
14 | () => (
15 |
16 |
17 | {currentUser && (
18 |
22 | )}
23 |
24 | {users.slice(0, 3).map(({ connectionId }) => {
25 | return (
26 |
31 | );
32 | })}
33 |
34 | {hasMoreUsers && (
35 |
36 | +{users.length - 3}
37 |
38 | )}
39 |
40 |
41 | ),
42 | // eslint-disable-next-line react-hooks/exhaustive-deps
43 | [users.length]
44 | );
45 |
46 | return memorizedUsers;
47 | };
48 |
--------------------------------------------------------------------------------
/public/align-right.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/public/align-horizontal-center.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/components/settings/dimensions.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from "@/components/ui/input";
2 | import { Label } from "@/components/ui/label";
3 |
4 | const dimensionsOptions = [
5 | { label: "W", property: "width" },
6 | { label: "H", property: "height" },
7 | ];
8 |
9 | type DimensionsProps = {
10 | width: string;
11 | height: string;
12 | isEditingRef: React.MutableRefObject;
13 | handleInputChange: (property: string, value: string) => void;
14 | };
15 |
16 | export const Dimensions = ({
17 | width,
18 | height,
19 | isEditingRef,
20 | handleInputChange,
21 | }: DimensionsProps) => (
22 |
23 |
24 | {dimensionsOptions.map((item) => (
25 |
29 |
30 | {item.label}
31 |
32 | handleInputChange(item.property, e.target.value)}
40 | onBlur={(e) => {
41 | isEditingRef.current = false;
42 | }}
43 | />
44 |
45 | ))}
46 |
47 |
48 | );
49 |
--------------------------------------------------------------------------------
/components/comments/pinned-composer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Composer, ComposerProps } from "@liveblocks/react-comments";
4 | import Image from "next/image";
5 |
6 | type PinnedComposerProps = {
7 | onComposerSubmit: ComposerProps["onComposerSubmit"];
8 | };
9 |
10 | export const PinnedComposer = ({
11 | onComposerSubmit,
12 | ...props
13 | }: PinnedComposerProps) => {
14 | return (
15 |
16 |
17 |
26 |
27 |
28 | {/**
29 | * We're using the Composer component to create a new comment.
30 | * Liveblocks provides a Composer component that allows to
31 | * create/edit/delete comments.
32 | *
33 | * Composer: https://liveblocks.io/docs/api-reference/liveblocks-react-comments#Composer
34 | */}
35 | {
39 | e.stopPropagation();
40 | }}
41 | />
42 |
43 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/components/left-sidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useMemo } from "react";
4 | import Image from "next/image";
5 |
6 | import { getShapeInfo } from "@/lib/utils";
7 |
8 | export const LeftSidebar = ({ allShapes }: { allShapes: Array }) => {
9 | // memoize the result of this function so that it doesn't change on every render but only when there are new shapes
10 | const memoizedShapes = useMemo(
11 | () => (
12 |
13 |
14 | Layers
15 |
16 |
17 | {allShapes?.map((shape: any) => {
18 | const info = getShapeInfo(shape[1]?.type);
19 |
20 | return (
21 |
25 |
32 |
33 | {info.name}
34 |
35 |
36 | );
37 | })}
38 |
39 |
40 | ),
41 | [allShapes]
42 | );
43 |
44 | return memoizedShapes;
45 | };
46 |
--------------------------------------------------------------------------------
/components/reaction/index.module.css:
--------------------------------------------------------------------------------
1 | .goUp0 {
2 | opacity: 0;
3 | animation:
4 | goUpAnimation0 2s,
5 | fadeOut 2s;
6 | }
7 |
8 | @keyframes goUpAnimation0 {
9 | from {
10 | transform: translate(0px, 0px);
11 | }
12 |
13 | to {
14 | transform: translate(0px, -400px);
15 | }
16 | }
17 |
18 | .goUp1 {
19 | opacity: 0;
20 | animation:
21 | goUpAnimation1 2s,
22 | fadeOut 2s;
23 | }
24 |
25 | @keyframes goUpAnimation1 {
26 | from {
27 | transform: translate(0px, 0px);
28 | }
29 |
30 | to {
31 | transform: translate(0px, -300px);
32 | }
33 | }
34 |
35 | .goUp2 {
36 | opacity: 0;
37 | animation:
38 | goUpAnimation2 2s,
39 | fadeOut 2s;
40 | }
41 |
42 | @keyframes goUpAnimation2 {
43 | from {
44 | transform: translate(0px, 0px);
45 | }
46 |
47 | to {
48 | transform: translate(0px, -200px);
49 | }
50 | }
51 |
52 | .leftRight0 {
53 | animation: leftRightAnimation0 0.3s alternate infinite ease-in-out;
54 | }
55 |
56 | @keyframes leftRightAnimation0 {
57 | from {
58 | transform: translate(0px, 0px);
59 | }
60 |
61 | to {
62 | transform: translate(50px, 0px);
63 | }
64 | }
65 |
66 | .leftRight1 {
67 | animation: leftRightAnimation1 0.3s alternate infinite ease-in-out;
68 | }
69 |
70 | @keyframes leftRightAnimation1 {
71 | from {
72 | transform: translate(0px, 0px);
73 | }
74 |
75 | to {
76 | transform: translate(100px, 0px);
77 | }
78 | }
79 |
80 | .leftRight2 {
81 | animation: leftRightAnimation2 0.3s alternate infinite ease-in-out;
82 | }
83 |
84 | @keyframes leftRightAnimation2 {
85 | from {
86 | transform: translate(0px, 0px);
87 | }
88 |
89 | to {
90 | transform: translate(-50px, 0px);
91 | }
92 | }
93 |
94 | @keyframes fadeOut {
95 | from {
96 | opacity: 1;
97 | }
98 |
99 | to {
100 | opacity: 0;
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button";
45 | return (
46 |
51 | );
52 | }
53 | );
54 | Button.displayName = "Button";
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/components/cursor/cursor-chat.tsx:
--------------------------------------------------------------------------------
1 | import CursorSVG from "@/public/cursor-svg";
2 | import { CursorMode, type CursorChatProps } from "@/types/type";
3 |
4 | export const CursorChat = ({
5 | cursor,
6 | cursorState,
7 | setCursorState,
8 | updateMyPresence,
9 | }: CursorChatProps) => {
10 | const handleChange = (e: React.ChangeEvent) => {
11 | updateMyPresence({ message: e.target.value });
12 | setCursorState({
13 | mode: CursorMode.Chat,
14 | previousMessage: null,
15 | message: e.target.value,
16 | });
17 | };
18 | const handleKeyDown = (e: React.KeyboardEvent) => {
19 | if (e.key === "Enter") {
20 | if (cursorState.mode === CursorMode.Chat) {
21 | setCursorState({
22 | mode: CursorMode.Chat,
23 | previousMessage: cursorState.message,
24 | message: "",
25 | });
26 | }
27 | }
28 |
29 | if (e.key === "Escape") {
30 | setCursorState({
31 | mode: CursorMode.Hidden,
32 | });
33 | }
34 | };
35 |
36 | return (
37 | e.stopPropagation()}
43 | >
44 | {cursorState.mode === CursorMode.Chat && (
45 | <>
46 |
47 |
48 |
49 | {cursorState.previousMessage && (
50 |
{cursorState.previousMessage}
51 | )}
52 |
53 |
64 |
65 | >
66 | )}
67 |
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/liveblocks.config.ts:
--------------------------------------------------------------------------------
1 | import { type LiveMap, createClient } from "@liveblocks/client";
2 | import { createRoomContext } from "@liveblocks/react";
3 |
4 | import { ReactionEvent } from "@/types/type";
5 |
6 | const client = createClient({
7 | throttle: 16,
8 | publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!,
9 | });
10 |
11 | // Presence represents the properties that exist on every user in the Room
12 | // and that will automatically be kept in sync. Accessible through the
13 | // `user.presence` property. Must be JSON-serializable.
14 | export type Presence = {
15 | cursor: { x: number; y: number } | null;
16 | message: string | null;
17 | };
18 |
19 | // Optionally, Storage represents the shared document that persists in the
20 | // Room, even after all users leave. Fields under Storage typically are
21 | // LiveList, LiveMap, LiveObject instances, for which updates are
22 | // automatically persisted and synced to all connected clients.
23 | type Storage = {
24 | // author: LiveObject<{ firstName: string, lastName: string }>,
25 | // ...
26 | canvasObjects: LiveMap;
27 | };
28 |
29 | // Optionally, the type of custom events broadcast and listened to in this
30 | // room. Use a union for multiple events. Must be JSON-serializable.
31 | type RoomEvent = ReactionEvent;
32 |
33 | // Optionally, when using Comments, ThreadMetadata represents metadata on
34 | // each thread. Can only contain booleans, strings, and numbers.
35 | export type ThreadMetadata = {
36 | resolved: boolean;
37 | zIndex: number;
38 | time?: number;
39 | x: number;
40 | y: number;
41 | };
42 |
43 | export const {
44 | suspense: {
45 | RoomProvider,
46 | useRoom,
47 | useMyPresence,
48 | useUpdateMyPresence,
49 | useSelf,
50 | useOthers,
51 | useOthersMapped,
52 | useOthersConnectionIds,
53 | useOther,
54 | useBroadcastEvent,
55 | useEventListener,
56 | useErrorListener,
57 | useStorage,
58 | useBatch,
59 | useHistory,
60 | useUndo,
61 | useRedo,
62 | useCanUndo,
63 | useCanRedo,
64 | useMutation,
65 | useStatus,
66 | useLostConnectionListener,
67 | useThreads,
68 | useUser,
69 | useCreateThread,
70 | useEditThreadMetadata,
71 | useCreateComment,
72 | useEditComment,
73 | useDeleteComment,
74 | useAddReaction,
75 | useRemoveReaction,
76 | },
77 | } = createRoomContext(
78 | client,
79 | {}
80 | );
81 |
--------------------------------------------------------------------------------
/components/right-sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { useRef } from "react";
3 |
4 | import { Color } from "@/components/settings/color";
5 | import { Dimensions } from "@/components/settings/dimensions";
6 | import { Export } from "@/components/settings/export";
7 | import { Text } from "@/components/settings/text";
8 | import { modifyShape } from "@/lib/shapes";
9 | import type { RightSidebarProps } from "@/types/type";
10 |
11 | export const RightSidebar = ({
12 | activeObjectRef,
13 | elementAttributes,
14 | fabricRef,
15 | isEditingRef,
16 | setElementAttributes,
17 | syncShapeInStorage,
18 | }: RightSidebarProps) => {
19 | const colorInputRef = useRef(null);
20 | const strokeInputRef = useRef(null);
21 | const handleInputChange = (property: string, value: string) => {
22 | if (!isEditingRef?.current) isEditingRef.current = true;
23 |
24 | setElementAttributes((prevAttributes) => ({
25 | ...prevAttributes,
26 | [property]: value,
27 | }));
28 |
29 | modifyShape({
30 | canvas: fabricRef.current as fabric.Canvas,
31 | property,
32 | value,
33 | activeObjectRef,
34 | syncShapeInStorage,
35 | });
36 | };
37 |
38 | return (
39 |
40 | Design
41 |
42 |
43 | Make changes to canvas as you like
44 |
45 |
46 |
52 |
53 |
59 |
60 |
67 |
74 |
75 |
76 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/components/comments/new-thread-cursor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as Portal from "@radix-ui/react-portal";
4 | import { useEffect, useState } from "react";
5 |
6 | const DEFAULT_CURSOR_POSITION = -10000;
7 |
8 | // display a custom cursor when placing a new thread
9 | export const NewThreadCursor = ({ display }: { display: boolean }) => {
10 | const [coords, setCoords] = useState({
11 | x: DEFAULT_CURSOR_POSITION,
12 | y: DEFAULT_CURSOR_POSITION,
13 | });
14 |
15 | useEffect(() => {
16 | const updatePosition = (e: MouseEvent) => {
17 | // get canvas element
18 | const canvas = document.getElementById("canvas");
19 |
20 | if (canvas) {
21 | /**
22 | * getBoundingClientRect returns the size of an element and its position relative to the viewport
23 | *
24 | * getBoundingClientRect: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
25 | */
26 | const canvasRect = canvas.getBoundingClientRect();
27 |
28 | // check if the mouse is outside the canvas
29 | // if so, hide the custom comment cursor
30 | if (
31 | e.clientX < canvasRect.left ||
32 | e.clientX > canvasRect.right ||
33 | e.clientY < canvasRect.top ||
34 | e.clientY > canvasRect.bottom
35 | ) {
36 | setCoords({
37 | x: DEFAULT_CURSOR_POSITION,
38 | y: DEFAULT_CURSOR_POSITION,
39 | });
40 | return;
41 | }
42 | }
43 |
44 | // set the coordinates of the cursor
45 | setCoords({
46 | x: e.clientX,
47 | y: e.clientY,
48 | });
49 | };
50 |
51 | document.addEventListener("mousemove", updatePosition, false);
52 | document.addEventListener("mouseenter", updatePosition, false);
53 |
54 | return () => {
55 | document.removeEventListener("mousemove", updatePosition);
56 | document.removeEventListener("mouseenter", updatePosition);
57 | };
58 | }, []);
59 |
60 | useEffect(() => {
61 | if (display) {
62 | document.documentElement.classList.add("hide-cursor");
63 | } else {
64 | document.documentElement.classList.remove("hide-cursor");
65 | }
66 | }, [display]);
67 |
68 | if (!display) {
69 | return null;
70 | }
71 |
72 | return (
73 | // Portal.Root is used to render a component outside of its parent component
74 |
75 |
81 |
82 | );
83 | };
84 |
--------------------------------------------------------------------------------
/components/comments/comments-overlay.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ThreadData } from "@liveblocks/client";
4 | import { useCallback, useRef } from "react";
5 |
6 | import { useMaxZIndex } from "@/lib/use-max-zindex";
7 | import {
8 | ThreadMetadata,
9 | useEditThreadMetadata,
10 | useThreads,
11 | } from "@/liveblocks.config";
12 |
13 | import { PinnedThread } from "./pinned-thread";
14 |
15 | type OverlayThreadProps = {
16 | thread: ThreadData;
17 | maxZIndex: number;
18 | };
19 |
20 | export const CommentsOverlay = () => {
21 | /**
22 | * We're using the useThreads hook to get the list of threads
23 | * in the room.
24 | *
25 | * useThreads: https://liveblocks.io/docs/api-reference/liveblocks-react#useThreads
26 | */
27 | const { threads } = useThreads();
28 |
29 | // get the max z-index of a thread
30 | const maxZIndex = useMaxZIndex();
31 |
32 | return (
33 |
34 | {threads
35 | .filter((thread) => !thread.metadata.resolved)
36 | .map((thread) => (
37 |
42 | ))}
43 |
44 | );
45 | };
46 |
47 | const OverlayThread = ({ thread, maxZIndex }: OverlayThreadProps) => {
48 | /**
49 | * We're using the useEditThreadMetadata hook to edit the metadata
50 | * of a thread.
51 | *
52 | * useEditThreadMetadata: https://liveblocks.io/docs/api-reference/liveblocks-react#useEditThreadMetadata
53 | */
54 | const editThreadMetadata = useEditThreadMetadata();
55 |
56 | /**
57 | * We're using the useUser hook to get the user of the thread.
58 | *
59 | * useUser: https://liveblocks.io/docs/api-reference/liveblocks-react#useUser
60 | */
61 |
62 | // We're using a ref to get the thread element to position it
63 | const threadRef = useRef(null);
64 |
65 | // If other thread(s) above, increase z-index on last element updated
66 | const handleIncreaseZIndex = useCallback(() => {
67 | if (maxZIndex === thread.metadata.zIndex) {
68 | return;
69 | }
70 |
71 | // Update the z-index of the thread in the room
72 | editThreadMetadata({
73 | threadId: thread.id,
74 | metadata: {
75 | zIndex: maxZIndex + 1,
76 | },
77 | });
78 | }, [thread, editThreadMetadata, maxZIndex]);
79 |
80 | return (
81 |
89 | {/* render the thread */}
90 |
91 |
92 | );
93 | };
94 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import jsPDF from "jspdf";
3 | import { twMerge } from "tailwind-merge";
4 |
5 | const adjectives = [
6 | "Happy",
7 | "Creative",
8 | "Energetic",
9 | "Lively",
10 | "Dynamic",
11 | "Radiant",
12 | "Joyful",
13 | "Vibrant",
14 | "Cheerful",
15 | "Sunny",
16 | "Sparkling",
17 | "Bright",
18 | "Shining",
19 | ];
20 |
21 | const animals = [
22 | "Dolphin",
23 | "Tiger",
24 | "Elephant",
25 | "Penguin",
26 | "Kangaroo",
27 | "Panther",
28 | "Lion",
29 | "Cheetah",
30 | "Giraffe",
31 | "Hippopotamus",
32 | "Monkey",
33 | "Panda",
34 | "Crocodile",
35 | ];
36 |
37 | export function cn(...inputs: ClassValue[]) {
38 | return twMerge(clsx(inputs));
39 | }
40 |
41 | export function generateRandomName(): string {
42 | const randomAdjective =
43 | adjectives[Math.floor(Math.random() * adjectives.length)];
44 | const randomAnimal = animals[Math.floor(Math.random() * animals.length)];
45 |
46 | return `${randomAdjective} ${randomAnimal}`;
47 | }
48 |
49 | export const getShapeInfo = (shapeType: string) => {
50 | switch (shapeType) {
51 | case "rect":
52 | return {
53 | icon: "/rectangle.svg",
54 | name: "Rectangle",
55 | };
56 |
57 | case "circle":
58 | return {
59 | icon: "/circle.svg",
60 | name: "Circle",
61 | };
62 |
63 | case "triangle":
64 | return {
65 | icon: "/triangle.svg",
66 | name: "Triangle",
67 | };
68 |
69 | case "line":
70 | return {
71 | icon: "/line.svg",
72 | name: "Line",
73 | };
74 |
75 | case "i-text":
76 | return {
77 | icon: "/text.svg",
78 | name: "Text",
79 | };
80 |
81 | case "image":
82 | return {
83 | icon: "/image.svg",
84 | name: "Image",
85 | };
86 |
87 | case "freeform":
88 | return {
89 | icon: "/freeform.svg",
90 | name: "Free Drawing",
91 | };
92 |
93 | default:
94 | return {
95 | icon: "/rectangle.svg",
96 | name: shapeType,
97 | };
98 | }
99 | };
100 |
101 | export const exportToPdf = () => {
102 | const canvas = document.querySelector("canvas");
103 |
104 | if (!canvas) return;
105 |
106 | // use jspdf
107 | const doc = new jsPDF({
108 | orientation: "landscape",
109 | unit: "px",
110 | format: [canvas.width, canvas.height],
111 | });
112 |
113 | // get the canvas data url
114 | const data = canvas.toDataURL();
115 |
116 | // add the image to the pdf
117 | doc.addImage(data, "PNG", 0, 0, canvas.width, canvas.height);
118 |
119 | // download the pdf
120 | doc.save("canvas.pdf");
121 | };
122 |
--------------------------------------------------------------------------------
/components/comments/pinned-thread.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ThreadData } from "@liveblocks/client";
4 | import { Thread } from "@liveblocks/react-comments";
5 | import Image from "next/image";
6 | import { useMemo, useState } from "react";
7 |
8 | import { ThreadMetadata } from "@/liveblocks.config";
9 |
10 | type PinnedThreadProps = {
11 | thread: ThreadData;
12 | onFocus: (threadId: string) => void;
13 | };
14 |
15 | export const PinnedThread = ({
16 | thread,
17 | onFocus,
18 | ...props
19 | }: PinnedThreadProps) => {
20 | // Open pinned threads that have just been created
21 | const startMinimized = useMemo(
22 | () => Number(new Date()) - Number(new Date(thread.createdAt)) > 100,
23 | [thread]
24 | );
25 |
26 | const [minimized, setMinimized] = useState(startMinimized);
27 |
28 | /**
29 | * memoize the result of this function so that it doesn't change on every render but only when the thread changes
30 | * Memo is used to optimize performance and avoid unnecessary re-renders.
31 | *
32 | * useMemo: https://react.dev/reference/react/useMemo
33 | */
34 |
35 | const memoizedContent = useMemo(
36 | () => (
37 | {
41 | onFocus(thread.id);
42 |
43 | // check if click is on/in the composer
44 | if (
45 | e.target &&
46 | e.target.classList.contains("lb-icon") &&
47 | e.target.classList.contains("lb-button-icon")
48 | ) {
49 | return;
50 | }
51 |
52 | setMinimized(!minimized);
53 | }}
54 | >
55 |
59 |
69 |
70 | {!minimized ? (
71 |
72 | {
76 | e.stopPropagation();
77 | }}
78 | />
79 |
80 | ) : null}
81 |
82 | ),
83 | // eslint-disable-next-line react-hooks/exhaustive-deps
84 | [thread.comments.length, minimized]
85 | );
86 |
87 | return <>{memoizedContent}>;
88 | };
89 |
--------------------------------------------------------------------------------
/components/shapes-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import type { ShapesMenuProps } from "@/types/type";
12 |
13 | export const ShapesMenu = ({
14 | item,
15 | activeElement,
16 | handleActiveElement,
17 | handleImageUpload,
18 | imageInputRef,
19 | }: ShapesMenuProps) => {
20 | const isDropdownElem = item.value.some(
21 | (elem) => elem?.value === activeElement.value
22 | );
23 |
24 | return (
25 | <>
26 |
27 |
28 | handleActiveElement(item)}
32 | >
33 |
39 |
40 |
41 |
42 |
43 | {item.value.map((elem) => (
44 | {
47 | handleActiveElement(elem);
48 | }}
49 | className={`flex h-fit justify-between gap-10 rounded-none px-5 py-3 focus:border-none ${
50 | activeElement.value === elem?.value
51 | ? "bg-primary-green"
52 | : "hover:bg-primary-grey-200"
53 | }`}
54 | >
55 |
56 |
65 |
72 | {elem?.name}
73 |
74 |
75 |
76 | ))}
77 |
78 |
79 |
80 |
87 | >
88 | );
89 | };
90 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "figma-clone",
3 | "version": "1.0.1",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "author": {
12 | "name": "Sanidhya Kumar Verma",
13 | "email": "sanidhya.verma12345@gmail.com",
14 | "url": "https://github.com/sanidhyy"
15 | },
16 | "description": "A minimalist Figma clone using Fabric.js and Liveblocks for real-time collaboration.",
17 | "keywords": [
18 | "reactjs",
19 | "nextjs",
20 | "vercel",
21 | "react",
22 | "liveblocks",
23 | "livekit",
24 | "shadcn",
25 | "shadcn-ui",
26 | "radix-ui",
27 | "cn",
28 | "clsx",
29 | "figma-clone",
30 | "realtime-drawing",
31 | "live-chat",
32 | "live-reactions",
33 | "live-updates",
34 | "sonner",
35 | "zustand",
36 | "zod",
37 | "lucide-react",
38 | "next-themes",
39 | "postcss",
40 | "prettier",
41 | "react-dom",
42 | "tailwindcss",
43 | "tailwindcss-animate",
44 | "ui/ux",
45 | "js",
46 | "javascript",
47 | "typescript",
48 | "eslint",
49 | "html",
50 | "css"
51 | ],
52 | "license": "MIT",
53 | "repository": {
54 | "type": "git",
55 | "url": "https://github.com/sanidhyy/figma-clone.git"
56 | },
57 | "homepage": "https://github.com/sanidhyy/figma-clone#readme",
58 | "bugs": {
59 | "url": "https://github.com/sanidhyy/figma-clone/issues",
60 | "email": "sanidhya.verma12345@gmail.com"
61 | },
62 | "funding": [
63 | {
64 | "type": "patreon",
65 | "url": "https://www.patreon.com/sanidhy"
66 | },
67 | {
68 | "type": "Buy me a coffee",
69 | "url": "https://www.buymeacoffee.com/sanidhy"
70 | }
71 | ],
72 | "dependencies": {
73 | "@liveblocks/client": "^1.12.0",
74 | "@liveblocks/react": "^1.12.0",
75 | "@liveblocks/react-comments": "^1.12.0",
76 | "@radix-ui/react-context-menu": "^2.2.16",
77 | "@radix-ui/react-dropdown-menu": "^2.1.16",
78 | "@radix-ui/react-label": "^2.1.7",
79 | "@radix-ui/react-select": "^2.2.5",
80 | "@radix-ui/react-slot": "^1.2.4",
81 | "class-variance-authority": "^0.7.1",
82 | "clsx": "^2.1.1",
83 | "fabric": "^5.5.2",
84 | "jspdf": "^3.0.3",
85 | "lucide-react": "^0.552.0",
86 | "react": "19.2.0",
87 | "react-dom": "19.2.0",
88 | "next": "14.2.35",
89 | "tailwind-merge": "^3.3.1",
90 | "tailwindcss-animate": "^1.0.7",
91 | "uuid": "^11.1.0"
92 | },
93 | "devDependencies": {
94 | "@types/fabric": "^5.3.10",
95 | "@types/node": "^24",
96 | "@types/react": "19.2.2",
97 | "@types/react-dom": "19.2.3",
98 | "@types/uuid": "^11.0.0",
99 | "eslint": "9",
100 | "eslint-config-next": "16.0.6",
101 | "eslint-plugin-unused-imports": "^4.3.0",
102 | "postcss": "^8",
103 | "prettier": "^3.6.2",
104 | "prettier-plugin-tailwindcss": "^0.7.1",
105 | "tailwindcss": "^3.4.14",
106 | "typescript": "^5"
107 | },
108 | "trustedDependencies": [
109 | "core-js"
110 | ]
111 | }
112 |
--------------------------------------------------------------------------------
/components/settings/text.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Select,
3 | SelectContent,
4 | SelectItem,
5 | SelectTrigger,
6 | SelectValue,
7 | } from "@/components/ui/select";
8 | import {
9 | fontFamilyOptions,
10 | fontSizeOptions,
11 | fontWeightOptions,
12 | } from "@/constants";
13 |
14 | const selectConfigs = [
15 | {
16 | property: "fontFamily",
17 | placeholder: "Choose a font",
18 | options: fontFamilyOptions,
19 | },
20 | { property: "fontSize", placeholder: "30", options: fontSizeOptions },
21 | {
22 | property: "fontWeight",
23 | placeholder: "Semibold",
24 | options: fontWeightOptions,
25 | },
26 | ];
27 |
28 | type TextProps = {
29 | fontFamily: string;
30 | fontSize: string;
31 | fontWeight: string;
32 | handleInputChange: (property: string, value: string) => void;
33 | };
34 |
35 | export const Text = ({
36 | fontFamily,
37 | fontSize,
38 | fontWeight,
39 | handleInputChange,
40 | }: TextProps) => (
41 |
42 |
Text
43 |
44 |
45 | {RenderSelect({
46 | config: selectConfigs[0],
47 | fontSize,
48 | fontWeight,
49 | fontFamily,
50 | handleInputChange,
51 | })}
52 |
53 |
54 | {selectConfigs.slice(1).map((config) =>
55 | RenderSelect({
56 | config,
57 | fontSize,
58 | fontWeight,
59 | fontFamily,
60 | handleInputChange,
61 | })
62 | )}
63 |
64 |
65 |
66 | );
67 |
68 | type RenderSelectProps = {
69 | config: {
70 | property: string;
71 | placeholder: string;
72 | options: { label: string; value: string }[];
73 | };
74 | fontSize: string;
75 | fontWeight: string;
76 | fontFamily: string;
77 | handleInputChange: (property: string, value: string) => void;
78 | };
79 |
80 | export const RenderSelect = ({
81 | config,
82 | fontSize,
83 | fontWeight,
84 | fontFamily,
85 | handleInputChange,
86 | }: RenderSelectProps) => (
87 | handleInputChange(config.property, value)}
90 | value={
91 | config.property === "fontFamily"
92 | ? fontFamily
93 | : config.property === "fontSize"
94 | ? fontSize
95 | : fontWeight
96 | }
97 | >
98 |
99 |
108 |
109 |
110 | {config.options.map((option) => (
111 |
116 | {option.label}
117 |
118 | ))}
119 |
120 |
121 | );
122 |
--------------------------------------------------------------------------------
/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import { memo } from "react";
5 |
6 | import { NewThread } from "@/components/comments/new-thread";
7 | import { Button } from "@/components/ui/button";
8 | import { ActiveUsers } from "@/components/users/active-users";
9 | import { navElements } from "@/constants";
10 | import type { ActiveElement, NavbarProps } from "@/types/type";
11 |
12 | import { ShapesMenu } from "./shapes-menu";
13 | import Link from "next/link";
14 | import { links } from "@/config";
15 |
16 | export const Navbar = memo(
17 | ({
18 | activeElement,
19 | imageInputRef,
20 | handleImageUpload,
21 | handleActiveElement,
22 | }: NavbarProps) => {
23 | const isActive = (value: string | Array) =>
24 | (activeElement && activeElement.value === value) ||
25 | (Array.isArray(value) &&
26 | value.some((val) => val?.value === activeElement?.value));
27 |
28 | return (
29 |
30 |
31 |
32 |
33 | {navElements.map((item: ActiveElement | any) => (
34 | {
37 | if (Array.isArray(item.value)) return;
38 | handleActiveElement(item);
39 | }}
40 | className={`group flex items-center justify-center px-2.5 py-5
41 | ${
42 | isActive(item.value)
43 | ? "bg-primary-green"
44 | : "hover:bg-primary-grey-200"
45 | }
46 | `}
47 | >
48 | {/* If value is an array means it's a nav element with sub options i.e., dropdown */}
49 | {Array.isArray(item.value) ? (
50 |
57 | ) : item?.value === "comments" ? (
58 | // If value is comments, trigger the NewThread component
59 |
60 |
64 |
70 |
71 |
72 | ) : (
73 |
77 |
83 |
84 | )}
85 |
86 | ))}
87 |
88 |
89 |
90 |
91 |
92 |
99 |
100 |
101 |
102 |
103 | );
104 | },
105 | (prevProps, nextProps) => prevProps.activeElement === nextProps.activeElement
106 | );
107 |
108 | Navbar.displayName = "Navbar";
109 |
--------------------------------------------------------------------------------
/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const COLORS = ["#DC2626", "#D97706", "#059669", "#7C3AED", "#DB2777"];
2 |
3 | export const shapeElements = [
4 | {
5 | icon: "/rectangle.svg",
6 | name: "Rectangle",
7 | value: "rectangle",
8 | },
9 | {
10 | icon: "/circle.svg",
11 | name: "Circle",
12 | value: "circle",
13 | },
14 | {
15 | icon: "/triangle.svg",
16 | name: "Triangle",
17 | value: "triangle",
18 | },
19 | {
20 | icon: "/line.svg",
21 | name: "Line",
22 | value: "line",
23 | },
24 | {
25 | icon: "/image.svg",
26 | name: "Image",
27 | value: "image",
28 | },
29 | {
30 | icon: "/freeform.svg",
31 | name: "Free Drawing",
32 | value: "freeform",
33 | },
34 | ];
35 |
36 | export const navElements = [
37 | {
38 | icon: "/select.svg",
39 | name: "Select",
40 | value: "select",
41 | },
42 | {
43 | icon: "/rectangle.svg",
44 | name: "Rectangle",
45 | value: shapeElements,
46 | },
47 | {
48 | icon: "/text.svg",
49 | value: "text",
50 | name: "Text",
51 | },
52 | {
53 | icon: "/delete.svg",
54 | value: "delete",
55 | name: "Delete",
56 | },
57 | {
58 | icon: "/reset.svg",
59 | value: "reset",
60 | name: "Reset",
61 | },
62 | {
63 | icon: "/comments.svg",
64 | value: "comments",
65 | name: "Comments",
66 | },
67 | ];
68 |
69 | export const defaultNavElement = {
70 | icon: "/select.svg",
71 | name: "Select",
72 | value: "select",
73 | };
74 |
75 | export const directionOptions = [
76 | { label: "Bring to Front", value: "front", icon: "/front.svg" },
77 | { label: "Send to Back", value: "back", icon: "/back.svg" },
78 | ];
79 |
80 | export const fontFamilyOptions = [
81 | { value: "Helvetica", label: "Helvetica" },
82 | { value: "Times New Roman", label: "Times New Roman" },
83 | { value: "Comic Sans MS", label: "Comic Sans MS" },
84 | { value: "Brush Script MT", label: "Brush Script MT" },
85 | ];
86 |
87 | export const fontSizeOptions = [
88 | {
89 | value: "10",
90 | label: "10",
91 | },
92 | {
93 | value: "12",
94 | label: "12",
95 | },
96 | {
97 | value: "14",
98 | label: "14",
99 | },
100 | {
101 | value: "16",
102 | label: "16",
103 | },
104 | {
105 | value: "18",
106 | label: "18",
107 | },
108 | {
109 | value: "20",
110 | label: "20",
111 | },
112 | {
113 | value: "22",
114 | label: "22",
115 | },
116 | {
117 | value: "24",
118 | label: "24",
119 | },
120 | {
121 | value: "26",
122 | label: "26",
123 | },
124 | {
125 | value: "28",
126 | label: "28",
127 | },
128 | {
129 | value: "30",
130 | label: "30",
131 | },
132 | {
133 | value: "32",
134 | label: "32",
135 | },
136 | {
137 | value: "34",
138 | label: "34",
139 | },
140 | {
141 | value: "36",
142 | label: "36",
143 | },
144 | ];
145 |
146 | export const fontWeightOptions = [
147 | {
148 | value: "400",
149 | label: "Normal",
150 | },
151 | {
152 | value: "500",
153 | label: "Semibold",
154 | },
155 | {
156 | value: "600",
157 | label: "Bold",
158 | },
159 | ];
160 |
161 | export const alignmentOptions = [
162 | { value: "left", label: "Align Left", icon: "/align-left.svg" },
163 | {
164 | value: "horizontalCenter",
165 | label: "Align Horizontal Center",
166 | icon: "/align-horizontal-center.svg",
167 | },
168 | { value: "right", label: "Align Right", icon: "/align-right.svg" },
169 | { value: "top", label: "Align Top", icon: "/align-top.svg" },
170 | {
171 | value: "verticalCenter",
172 | label: "Align Vertical Center",
173 | icon: "/align-vertical-center.svg",
174 | },
175 | { value: "bottom", label: "Align Bottom", icon: "/align-bottom.svg" },
176 | ];
177 |
178 | export const shortcuts = [
179 | {
180 | key: "1",
181 | name: "Chat",
182 | shortcut: "/",
183 | },
184 | {
185 | key: "2",
186 | name: "Undo",
187 | shortcut: "⌘ + Z",
188 | },
189 | {
190 | key: "3",
191 | name: "Redo",
192 | shortcut: "⌘ + Y",
193 | },
194 | {
195 | key: "4",
196 | name: "Reactions",
197 | shortcut: "E",
198 | },
199 | ];
200 |
--------------------------------------------------------------------------------
/lib/key-events.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { v4 as uuidv4 } from "uuid";
3 |
4 | import { CustomFabricObject } from "@/types/type";
5 |
6 | export const handleCopy = (canvas: fabric.Canvas) => {
7 | const activeObjects = canvas.getActiveObjects();
8 | if (activeObjects.length > 0) {
9 | // Serialize the selected objects
10 | const serializedObjects = activeObjects.map((obj) => obj.toObject());
11 | // Store the serialized objects in the clipboard
12 | localStorage.setItem("clipboard", JSON.stringify(serializedObjects));
13 | }
14 |
15 | return activeObjects;
16 | };
17 |
18 | export const handlePaste = (
19 | canvas: fabric.Canvas,
20 | syncShapeInStorage: (shape: fabric.Object) => void
21 | ) => {
22 | if (!canvas || !(canvas instanceof fabric.Canvas)) {
23 | console.error("Invalid canvas object. Aborting paste operation.");
24 | return;
25 | }
26 |
27 | // Retrieve serialized objects from the clipboard
28 | const clipboardData = localStorage.getItem("clipboard");
29 |
30 | if (clipboardData) {
31 | try {
32 | const parsedObjects = JSON.parse(clipboardData);
33 | parsedObjects.forEach((objData: fabric.Object) => {
34 | // convert the plain javascript objects retrieved from localStorage into fabricjs objects (deserialization)
35 | fabric.util.enlivenObjects(
36 | [objData],
37 | (enlivenedObjects: fabric.Object[]) => {
38 | enlivenedObjects.forEach((enlivenedObj) => {
39 | // Offset the pasted objects to avoid overlap with existing objects
40 | enlivenedObj.set({
41 | left: enlivenedObj.left || 0 + 20,
42 | top: enlivenedObj.top || 0 + 20,
43 | objectId: uuidv4(),
44 | fill: "#aabbcc",
45 | } as CustomFabricObject);
46 |
47 | canvas.add(enlivenedObj);
48 | syncShapeInStorage(enlivenedObj);
49 | });
50 | canvas.renderAll();
51 | },
52 | "fabric"
53 | );
54 | });
55 | } catch (error) {
56 | console.error("Error parsing clipboard data:", error);
57 | }
58 | }
59 | };
60 |
61 | export const handleDelete = (
62 | canvas: fabric.Canvas,
63 | deleteShapeFromStorage: (id: string) => void
64 | ) => {
65 | const activeObjects = canvas.getActiveObjects();
66 | if (!activeObjects || activeObjects.length === 0) return;
67 |
68 | if (activeObjects.length > 0) {
69 | activeObjects.forEach((obj: CustomFabricObject) => {
70 | if (!obj.objectId) return;
71 | canvas.remove(obj);
72 | deleteShapeFromStorage(obj.objectId);
73 | });
74 | }
75 |
76 | canvas.discardActiveObject();
77 | canvas.requestRenderAll();
78 | };
79 |
80 | // create a handleKeyDown function that listen to different keydown events
81 | export const handleKeyDown = ({
82 | e,
83 | canvas,
84 | undo,
85 | redo,
86 | syncShapeInStorage,
87 | deleteShapeFromStorage,
88 | }: {
89 | e: KeyboardEvent;
90 | canvas: fabric.Canvas | any;
91 | undo: () => void;
92 | redo: () => void;
93 | syncShapeInStorage: (shape: fabric.Object) => void;
94 | deleteShapeFromStorage: (id: string) => void;
95 | }) => {
96 | // Check if the key pressed is ctrl/cmd + c (copy)
97 | if ((e?.ctrlKey || e?.metaKey) && (e.key === "c" || e.key === "C")) {
98 | handleCopy(canvas);
99 | }
100 |
101 | // Check if the key pressed is ctrl/cmd + v (paste)
102 | if ((e?.ctrlKey || e?.metaKey) && (e.key === "v" || e.key === "V")) {
103 | handlePaste(canvas, syncShapeInStorage);
104 | }
105 |
106 | // Check if the key pressed is delete/backspace (delete)
107 | // if (e.keyCode === 8 || e.keyCode === 46) {
108 | // handleDelete(canvas, deleteShapeFromStorage);
109 | // }
110 |
111 | // check if the key pressed is ctrl/cmd + x (cut)
112 | if ((e?.ctrlKey || e?.metaKey) && (e.key === "x" || e.key === "X")) {
113 | handleCopy(canvas);
114 | handleDelete(canvas, deleteShapeFromStorage);
115 | }
116 |
117 | // check if the key pressed is ctrl/cmd + z (undo)
118 | if ((e?.ctrlKey || e?.metaKey) && (e.key === "z" || e.key === "Z")) {
119 | undo();
120 | }
121 |
122 | // check if the key pressed is ctrl/cmd + y (redo)
123 | if ((e?.ctrlKey || e?.metaKey) && (e.key === "y" || e.key === "Y")) {
124 | redo();
125 | }
126 |
127 | if (e.key === "/?" && !e.shiftKey) {
128 | e.preventDefault();
129 | }
130 | };
131 |
--------------------------------------------------------------------------------
/lib/shapes.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { v4 as uuidv4 } from "uuid";
3 |
4 | import {
5 | CustomFabricObject,
6 | ElementDirection,
7 | ImageUpload,
8 | ModifyShape,
9 | } from "@/types/type";
10 |
11 | export const createRectangle = (pointer: PointerEvent) => {
12 | const rect = new fabric.Rect({
13 | left: pointer.x,
14 | top: pointer.y,
15 | width: 100,
16 | height: 100,
17 | fill: "#aabbcc",
18 | objectId: uuidv4(),
19 | } as CustomFabricObject);
20 |
21 | return rect;
22 | };
23 |
24 | export const createTriangle = (pointer: PointerEvent) => {
25 | return new fabric.Triangle({
26 | left: pointer.x,
27 | top: pointer.y,
28 | width: 100,
29 | height: 100,
30 | fill: "#aabbcc",
31 | objectId: uuidv4(),
32 | } as CustomFabricObject);
33 | };
34 |
35 | export const createCircle = (pointer: PointerEvent) => {
36 | return new fabric.Circle({
37 | left: pointer.x,
38 | top: pointer.y,
39 | radius: 100,
40 | fill: "#aabbcc",
41 | objectId: uuidv4(),
42 | } as any);
43 | };
44 |
45 | export const createLine = (pointer: PointerEvent) => {
46 | return new fabric.Line(
47 | [pointer.x, pointer.y, pointer.x + 100, pointer.y + 100],
48 | {
49 | stroke: "#aabbcc",
50 | strokeWidth: 2,
51 | objectId: uuidv4(),
52 | } as CustomFabricObject
53 | );
54 | };
55 |
56 | export const createText = (pointer: PointerEvent, text: string) => {
57 | return new fabric.IText(text, {
58 | left: pointer.x,
59 | top: pointer.y,
60 | fill: "#aabbcc",
61 | fontFamily: "Helvetica",
62 | fontSize: 36,
63 | fontWeight: "400",
64 | objectId: uuidv4(),
65 | } as fabric.ITextOptions);
66 | };
67 |
68 | export const createSpecificShape = (
69 | shapeType: string,
70 | pointer: PointerEvent
71 | ) => {
72 | switch (shapeType) {
73 | case "rectangle":
74 | return createRectangle(pointer);
75 |
76 | case "triangle":
77 | return createTriangle(pointer);
78 |
79 | case "circle":
80 | return createCircle(pointer);
81 |
82 | case "line":
83 | return createLine(pointer);
84 |
85 | case "text":
86 | return createText(pointer, "Tap to Type");
87 |
88 | default:
89 | return null;
90 | }
91 | };
92 |
93 | export const handleImageUpload = ({
94 | file,
95 | canvas,
96 | shapeRef,
97 | syncShapeInStorage,
98 | }: ImageUpload) => {
99 | const reader = new FileReader();
100 |
101 | reader.onload = () => {
102 | fabric.Image.fromURL(reader.result as string, (img) => {
103 | img.scaleToWidth(200);
104 | img.scaleToHeight(200);
105 |
106 | canvas.current.add(img);
107 |
108 | // @ts-ignore
109 | img.objectId = uuidv4();
110 |
111 | shapeRef.current = img;
112 |
113 | syncShapeInStorage(img);
114 | canvas.current.requestRenderAll();
115 | });
116 | };
117 |
118 | reader.readAsDataURL(file);
119 | };
120 |
121 | export const createShape = (
122 | canvas: fabric.Canvas,
123 | pointer: PointerEvent,
124 | shapeType: string
125 | ) => {
126 | if (shapeType === "freeform") {
127 | canvas.isDrawingMode = true;
128 | return null;
129 | }
130 |
131 | return createSpecificShape(shapeType, pointer);
132 | };
133 |
134 | export const modifyShape = ({
135 | canvas,
136 | property,
137 | value,
138 | activeObjectRef,
139 | syncShapeInStorage,
140 | }: ModifyShape) => {
141 | const selectedElement = canvas.getActiveObject();
142 |
143 | if (!selectedElement || selectedElement?.type === "activeSelection") return;
144 |
145 | // if property is width or height, set the scale of the selected element
146 | if (property === "width") {
147 | selectedElement.set("scaleX", 1);
148 | selectedElement.set("width", value);
149 | } else if (property === "height") {
150 | selectedElement.set("scaleY", 1);
151 | selectedElement.set("height", value);
152 | } else {
153 | if (selectedElement[property as keyof object] === value) return;
154 | selectedElement.set(property as keyof object, value);
155 | }
156 |
157 | // set selectedElement to activeObjectRef
158 | activeObjectRef.current = selectedElement;
159 |
160 | syncShapeInStorage(selectedElement);
161 | };
162 |
163 | export const bringElement = ({
164 | canvas,
165 | direction,
166 | syncShapeInStorage,
167 | }: ElementDirection) => {
168 | if (!canvas) return;
169 |
170 | // get the selected element. If there is no selected element or there are more than one selected element, return
171 | const selectedElement = canvas.getActiveObject();
172 |
173 | if (!selectedElement || selectedElement?.type === "activeSelection") return;
174 |
175 | // bring the selected element to the front
176 | if (direction === "front") {
177 | canvas.bringToFront(selectedElement);
178 | } else if (direction === "back") {
179 | canvas.sendToBack(selectedElement);
180 | }
181 |
182 | // canvas.renderAll();
183 | syncShapeInStorage(selectedElement);
184 |
185 | // re-render all objects on the canvas
186 | };
187 |
--------------------------------------------------------------------------------
/types/type.ts:
--------------------------------------------------------------------------------
1 | import { Gradient, Pattern } from "fabric/fabric-impl";
2 |
3 | export enum CursorMode {
4 | Hidden,
5 | Chat,
6 | ReactionSelector,
7 | Reaction,
8 | }
9 |
10 | export type CursorState =
11 | | {
12 | mode: CursorMode.Hidden;
13 | }
14 | | {
15 | mode: CursorMode.Chat;
16 | message: string;
17 | previousMessage: string | null;
18 | }
19 | | {
20 | mode: CursorMode.ReactionSelector;
21 | }
22 | | {
23 | mode: CursorMode.Reaction;
24 | reaction: string;
25 | isPressed: boolean;
26 | };
27 |
28 | export type Reaction = {
29 | value: string;
30 | timestamp: number;
31 | point: { x: number; y: number };
32 | };
33 |
34 | export type ReactionEvent = {
35 | x: number;
36 | y: number;
37 | value: string;
38 | };
39 |
40 | export type ShapeData = {
41 | type: string;
42 | width: number;
43 | height: number;
44 | fill: string | Pattern | Gradient;
45 | left: number;
46 | top: number;
47 | objectId: string | undefined;
48 | };
49 |
50 | export type Attributes = {
51 | width: string;
52 | height: string;
53 | fontSize: string;
54 | fontFamily: string;
55 | fontWeight: string;
56 | fill: string;
57 | stroke: string;
58 | };
59 |
60 | export type ActiveElement = {
61 | name: string;
62 | value: string;
63 | icon: string;
64 | } | null;
65 |
66 | export interface CustomFabricObject
67 | extends fabric.Object {
68 | objectId?: string;
69 | }
70 |
71 | export type ModifyShape = {
72 | canvas: fabric.Canvas;
73 | property: string;
74 | value: any;
75 | activeObjectRef: React.MutableRefObject;
76 | syncShapeInStorage: (shape: fabric.Object) => void;
77 | };
78 |
79 | export type ElementDirection = {
80 | canvas: fabric.Canvas;
81 | direction: string;
82 | syncShapeInStorage: (shape: fabric.Object) => void;
83 | };
84 |
85 | export type ImageUpload = {
86 | file: File;
87 | canvas: React.MutableRefObject;
88 | shapeRef: React.MutableRefObject;
89 | syncShapeInStorage: (shape: fabric.Object) => void;
90 | };
91 |
92 | export type RightSidebarProps = {
93 | elementAttributes: Attributes;
94 | setElementAttributes: React.Dispatch>;
95 | fabricRef: React.RefObject;
96 | activeObjectRef: React.RefObject;
97 | isEditingRef: React.MutableRefObject;
98 | syncShapeInStorage: (obj: any) => void;
99 | };
100 |
101 | export type NavbarProps = {
102 | activeElement: ActiveElement;
103 | imageInputRef: React.MutableRefObject;
104 | handleImageUpload: (e: React.ChangeEvent) => void;
105 | handleActiveElement: (element: ActiveElement) => void;
106 | };
107 |
108 | export type ShapesMenuProps = {
109 | item: {
110 | name: string;
111 | icon: string;
112 | value: Array;
113 | };
114 | activeElement: any;
115 | handleActiveElement: any;
116 | handleImageUpload: any;
117 | imageInputRef: any;
118 | };
119 |
120 | export type CanvasMouseDown = {
121 | options: fabric.IEvent;
122 | canvas: fabric.Canvas;
123 | selectedShapeRef: any;
124 | isDrawing: React.MutableRefObject;
125 | shapeRef: React.MutableRefObject;
126 | };
127 |
128 | export type CanvasMouseMove = {
129 | options: fabric.IEvent;
130 | canvas: fabric.Canvas;
131 | isDrawing: React.MutableRefObject;
132 | selectedShapeRef: any;
133 | shapeRef: any;
134 | syncShapeInStorage: (shape: fabric.Object) => void;
135 | };
136 |
137 | export type CanvasMouseUp = {
138 | canvas: fabric.Canvas;
139 | isDrawing: React.MutableRefObject;
140 | shapeRef: any;
141 | activeObjectRef: React.MutableRefObject;
142 | selectedShapeRef: any;
143 | syncShapeInStorage: (shape: fabric.Object) => void;
144 | setActiveElement: any;
145 | };
146 |
147 | export type CanvasObjectModified = {
148 | options: fabric.IEvent;
149 | syncShapeInStorage: (shape: fabric.Object) => void;
150 | };
151 |
152 | export type CanvasPathCreated = {
153 | options: (fabric.IEvent & { path: CustomFabricObject }) | any;
154 | syncShapeInStorage: (shape: fabric.Object) => void;
155 | };
156 |
157 | export type CanvasSelectionCreated = {
158 | options: fabric.IEvent;
159 | isEditingRef: React.MutableRefObject;
160 | setElementAttributes: React.Dispatch>;
161 | };
162 |
163 | export type CanvasObjectScaling = {
164 | options: fabric.IEvent;
165 | setElementAttributes: React.Dispatch>;
166 | };
167 |
168 | export type RenderCanvas = {
169 | fabricRef: React.MutableRefObject;
170 | canvasObjects: any;
171 | activeObjectRef: any;
172 | };
173 |
174 | export type CursorChatProps = {
175 | cursor: { x: number; y: number };
176 | cursorState: CursorState;
177 | setCursorState: (cursorState: CursorState) => void;
178 | updateMyPresence: (
179 | presence: Partial<{
180 | cursor: { x: number; y: number };
181 | cursorColor: string;
182 | message: string;
183 | }>
184 | ) => void;
185 | };
186 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct - Figma Clone
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to make participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to a positive environment for our
15 | community include:
16 |
17 | - Demonstrating empathy and kindness toward other people
18 | - Being respectful of differing opinions, viewpoints, and experiences
19 | - Giving and gracefully accepting constructive feedback
20 | - Accepting responsibility and apologizing to those affected by our mistakes,
21 | and learning from the experience
22 | - Focusing on what is best not just for us as individuals, but for the
23 | overall community
24 |
25 | Examples of unacceptable behavior include:
26 |
27 | - The use of sexualized language or imagery, and sexual attention or
28 | advances
29 | - Trolling, insulting or derogatory comments, and personal or political attacks
30 | - Public or private harassment
31 | - Publishing others' private information, such as a physical or email
32 | address, without their explicit permission
33 | - Other conduct which could reasonably be considered inappropriate in a
34 | professional setting
35 |
36 | ## Our Responsibilities
37 |
38 | Project maintainers are responsible for clarifying and enforcing our standards of
39 | acceptable behavior and will take appropriate and fair corrective action in
40 | response to any behavior that they deem inappropriate,
41 | threatening, offensive, or harmful.
42 |
43 | Project maintainers have the right and responsibility to remove, edit, or reject
44 | comments, commits, code, wiki edits, issues, and other contributions that are
45 | not aligned to this Code of Conduct, and will
46 | communicate reasons for moderation decisions when appropriate.
47 |
48 | ## Scope
49 |
50 | This Code of Conduct applies within all community spaces, and also applies when
51 | an individual is officially representing the community in public spaces.
52 | Examples of representing our community include using an official e-mail address,
53 | posting via an official social media account, or acting as an appointed
54 | representative at an online or offline event.
55 |
56 | ## Enforcement
57 |
58 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
59 | reported to the community leaders responsible for enforcement at .
60 | All complaints will be reviewed and investigated promptly and fairly.
61 |
62 | All community leaders are obligated to respect the privacy and security of the
63 | reporter of any incident.
64 |
65 | ## Enforcement Guidelines
66 |
67 | Community leaders will follow these Community Impact Guidelines in determining
68 | the consequences for any action they deem in violation of this Code of Conduct:
69 |
70 | ### 1. Correction
71 |
72 | **Community Impact**: Use of inappropriate language or other behavior deemed
73 | unprofessional or unwelcome in the community.
74 |
75 | **Consequence**: A private, written warning from community leaders, providing
76 | clarity around the nature of the violation and an explanation of why the
77 | behavior was inappropriate. A public apology may be requested.
78 |
79 | ### 2. Warning
80 |
81 | **Community Impact**: A violation through a single incident or series
82 | of actions.
83 |
84 | **Consequence**: A warning with consequences for continued behavior. No
85 | interaction with the people involved, including unsolicited interaction with
86 | those enforcing the Code of Conduct, for a specified period of time. This
87 | includes avoiding interactions in community spaces as well as external channels
88 | like social media. Violating these terms may lead to a temporary or
89 | permanent ban.
90 |
91 | ### 3. Temporary Ban
92 |
93 | **Community Impact**: A serious violation of community standards, including
94 | sustained inappropriate behavior.
95 |
96 | **Consequence**: A temporary ban from any sort of interaction or public
97 | communication with the community for a specified period of time. No public or
98 | private interaction with the people involved, including unsolicited interaction
99 | with those enforcing the Code of Conduct, is allowed during this period.
100 | Violating these terms may lead to a permanent ban.
101 |
102 | ### 4. Permanent Ban
103 |
104 | **Community Impact**: Demonstrating a pattern of violation of community
105 | standards, including sustained inappropriate behavior, harassment of an
106 | individual, or aggression toward or disparagement of classes of individuals.
107 |
108 | **Consequence**: A permanent ban from any sort of public interaction within
109 | the community.
110 |
111 | ## Attribution
112 |
113 | This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version
114 | [1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and
115 | [2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md),
116 | and was generated by [contributing-gen](https://github.com/bttger/contributing-gen).
117 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SelectPrimitive from "@radix-ui/react-select";
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Select = SelectPrimitive.Root;
10 |
11 | const SelectGroup = SelectPrimitive.Group;
12 |
13 | const SelectValue = SelectPrimitive.Value;
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ));
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ));
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ));
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName;
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ));
100 | SelectContent.displayName = SelectPrimitive.Content.displayName;
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ));
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ));
135 | SelectItem.displayName = SelectPrimitive.Item.displayName;
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ));
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | };
161 |
--------------------------------------------------------------------------------
/app/app.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { fabric } from "fabric";
4 | import { useEffect, useRef, useState } from "react";
5 |
6 | import { LeftSidebar } from "@/components/left-sidebar";
7 | import { Live } from "@/components/live";
8 | import { Navbar } from "@/components/navbar";
9 | import { RightSidebar } from "@/components/right-sidebar";
10 | import { defaultNavElement } from "@/constants";
11 | import {
12 | handleCanvasMouseDown,
13 | handleCanvasMouseMove,
14 | handleCanvasMouseUp,
15 | handleCanvasObjectModified,
16 | handleCanvasObjectScaling,
17 | handleCanvasSelectionCreated,
18 | handlePathCreated,
19 | handleResize,
20 | initializeFabric,
21 | renderCanvas,
22 | } from "@/lib/canvas";
23 | import { handleDelete, handleKeyDown } from "@/lib/key-events";
24 | import { handleImageUpload } from "@/lib/shapes";
25 | import { useMutation, useRedo, useStorage, useUndo } from "@/liveblocks.config";
26 | import type { ActiveElement, Attributes } from "@/types/type";
27 |
28 | const App = () => {
29 | const canvasRef = useRef(null);
30 | const imageInputRef = useRef(null);
31 | const fabricRef = useRef(null);
32 | const shapeRef = useRef(null);
33 | const selectedShapeRef = useRef(null);
34 | const activeObjectRef = useRef(null);
35 | const isDrawing = useRef(false);
36 | const isEditingRef = useRef(false);
37 |
38 | const [elementAttributes, setElementAttributes] = useState({
39 | width: "",
40 | height: "",
41 | fontSize: "",
42 | fontFamily: "",
43 | fontWeight: "",
44 | fill: "#aabbcc",
45 | stroke: "#aabbcc",
46 | });
47 | const [activeElement, setActiveElement] = useState({
48 | name: "",
49 | value: "",
50 | icon: "",
51 | });
52 |
53 | const undo = useUndo();
54 | const redo = useRedo();
55 | const canvasObjects = useStorage((root) => root.canvasObjects);
56 |
57 | const syncShapeInStorage = useMutation(({ storage }, object) => {
58 | if (!object) return;
59 |
60 | const { objectId } = object;
61 |
62 | const shapeData = object.toJSON();
63 | shapeData.objectId = objectId;
64 |
65 | const canvasObjects = storage.get("canvasObjects");
66 |
67 | canvasObjects.set(objectId, shapeData);
68 | }, []);
69 |
70 | const deleteAllShapes = useMutation(({ storage }) => {
71 | const canvasObjects = storage.get("canvasObjects");
72 |
73 | if (!canvasObjects || canvasObjects.size === 0) return true;
74 |
75 | for (const [key] of canvasObjects.entries()) {
76 | canvasObjects.delete(key);
77 | }
78 |
79 | return canvasObjects.size === 0;
80 | }, []);
81 |
82 | const deleteShapeFromStorage = useMutation(({ storage }, objectId) => {
83 | const canvasObjects = storage.get("canvasObjects");
84 |
85 | canvasObjects.delete(objectId);
86 | }, []);
87 |
88 | const handleActiveElement = (elem: ActiveElement) => {
89 | setActiveElement(elem);
90 |
91 | switch (elem?.value) {
92 | case "reset":
93 | deleteAllShapes();
94 | fabricRef.current?.clear();
95 | setActiveElement(defaultNavElement);
96 |
97 | break;
98 | case "delete":
99 | handleDelete(
100 | fabricRef.current as fabric.Canvas,
101 | deleteShapeFromStorage
102 | );
103 | setActiveElement(defaultNavElement);
104 |
105 | break;
106 | case "image":
107 | imageInputRef.current?.click();
108 | isDrawing.current = false;
109 |
110 | if (fabricRef.current) fabricRef.current.isDrawingMode = false;
111 |
112 | break;
113 | default:
114 | break;
115 | }
116 |
117 | selectedShapeRef.current = elem?.value as string;
118 | };
119 |
120 | useEffect(() => {
121 | const canvas = initializeFabric({ canvasRef, fabricRef });
122 |
123 | canvas.on("mouse:down", (options) => {
124 | handleCanvasMouseDown({
125 | options,
126 | canvas,
127 | isDrawing,
128 | shapeRef,
129 | selectedShapeRef,
130 | });
131 | });
132 |
133 | canvas.on("mouse:move", (options) => {
134 | handleCanvasMouseMove({
135 | options,
136 | canvas,
137 | isDrawing,
138 | shapeRef,
139 | selectedShapeRef,
140 | syncShapeInStorage,
141 | });
142 | });
143 |
144 | canvas.on("mouse:up", () => {
145 | handleCanvasMouseUp({
146 | canvas,
147 | isDrawing,
148 | shapeRef,
149 | selectedShapeRef,
150 | syncShapeInStorage,
151 | setActiveElement,
152 | activeObjectRef,
153 | });
154 | });
155 |
156 | canvas.on("object:modified", (options) => {
157 | handleCanvasObjectModified({
158 | options,
159 | syncShapeInStorage,
160 | });
161 | });
162 |
163 | canvas.on("selection:created", (options) => {
164 | handleCanvasSelectionCreated({
165 | options,
166 | isEditingRef,
167 | setElementAttributes,
168 | });
169 | });
170 |
171 | canvas.on("object:scaling", (options) => {
172 | handleCanvasObjectScaling({
173 | options,
174 | setElementAttributes,
175 | });
176 | });
177 |
178 | canvas.on("path:created", (options) => {
179 | handlePathCreated({
180 | options,
181 | syncShapeInStorage,
182 | });
183 | });
184 |
185 | window.addEventListener("resize", () => {
186 | handleResize({ canvas });
187 | });
188 |
189 | window.addEventListener("keydown", (e: KeyboardEvent) => {
190 | handleKeyDown({
191 | e,
192 | canvas: fabricRef.current,
193 | undo,
194 | redo,
195 | syncShapeInStorage,
196 | deleteShapeFromStorage,
197 | });
198 | });
199 |
200 | return () => {
201 | canvas.dispose();
202 |
203 | window.removeEventListener("resize", () => {
204 | handleResize({
205 | canvas: null,
206 | });
207 | });
208 | };
209 | }, [canvasRef, deleteShapeFromStorage, redo, syncShapeInStorage, undo]);
210 |
211 | useEffect(() => {
212 | renderCanvas({
213 | fabricRef,
214 | canvasObjects,
215 | activeObjectRef,
216 | });
217 | }, [canvasObjects]);
218 |
219 | return (
220 |
221 | {
226 | e.stopPropagation();
227 |
228 | if (!fabricRef?.current || !e.target?.files) return;
229 |
230 | handleImageUpload({
231 | file: e.target.files[0],
232 | canvas: fabricRef as React.MutableRefObject,
233 | shapeRef,
234 | syncShapeInStorage,
235 | });
236 | }}
237 | />
238 |
239 |
240 |
241 |
242 |
243 |
244 |
252 |
253 |
254 | );
255 | };
256 |
257 | export default App;
258 |
--------------------------------------------------------------------------------
/components/ui/context-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
5 | import { Check, ChevronRight, Circle } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const ContextMenu = ContextMenuPrimitive.Root;
10 |
11 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
12 |
13 | const ContextMenuGroup = ContextMenuPrimitive.Group;
14 |
15 | const ContextMenuPortal = ContextMenuPrimitive.Portal;
16 |
17 | const ContextMenuSub = ContextMenuPrimitive.Sub;
18 |
19 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
20 |
21 | const ContextMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean;
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ));
40 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
41 |
42 | const ContextMenuSubContent = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef
45 | >(({ className, ...props }, ref) => (
46 |
54 | ));
55 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
56 |
57 | const ContextMenuContent = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
62 |
70 |
71 | ));
72 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
73 |
74 | const ContextMenuItem = React.forwardRef<
75 | React.ElementRef,
76 | React.ComponentPropsWithoutRef & {
77 | inset?: boolean;
78 | }
79 | >(({ className, inset, ...props }, ref) => (
80 |
89 | ));
90 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
91 |
92 | const ContextMenuCheckboxItem = React.forwardRef<
93 | React.ElementRef,
94 | React.ComponentPropsWithoutRef
95 | >(({ className, children, checked, ...props }, ref) => (
96 |
105 |
106 |
107 |
108 |
109 |
110 | {children}
111 |
112 | ));
113 | ContextMenuCheckboxItem.displayName =
114 | ContextMenuPrimitive.CheckboxItem.displayName;
115 |
116 | const ContextMenuRadioItem = React.forwardRef<
117 | React.ElementRef,
118 | React.ComponentPropsWithoutRef
119 | >(({ className, children, ...props }, ref) => (
120 |
128 |
129 |
130 |
131 |
132 |
133 | {children}
134 |
135 | ));
136 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
137 |
138 | const ContextMenuLabel = React.forwardRef<
139 | React.ElementRef,
140 | React.ComponentPropsWithoutRef & {
141 | inset?: boolean;
142 | }
143 | >(({ className, inset, ...props }, ref) => (
144 |
153 | ));
154 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
155 |
156 | const ContextMenuSeparator = React.forwardRef<
157 | React.ElementRef,
158 | React.ComponentPropsWithoutRef
159 | >(({ className, ...props }, ref) => (
160 |
165 | ));
166 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
167 |
168 | const ContextMenuShortcut = ({
169 | className,
170 | ...props
171 | }: React.HTMLAttributes) => {
172 | return (
173 |
180 | );
181 | };
182 | ContextMenuShortcut.displayName = "ContextMenuShortcut";
183 |
184 | export {
185 | ContextMenu,
186 | ContextMenuTrigger,
187 | ContextMenuContent,
188 | ContextMenuItem,
189 | ContextMenuCheckboxItem,
190 | ContextMenuRadioItem,
191 | ContextMenuLabel,
192 | ContextMenuSeparator,
193 | ContextMenuShortcut,
194 | ContextMenuGroup,
195 | ContextMenuPortal,
196 | ContextMenuSub,
197 | ContextMenuSubContent,
198 | ContextMenuSubTrigger,
199 | ContextMenuRadioGroup,
200 | };
201 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5 | import { Check, ChevronRight, Circle } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root;
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean;
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ));
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName;
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ));
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName;
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ));
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean;
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ));
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ));
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName;
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ));
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean;
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ));
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ));
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | );
181 | };
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | };
201 |
--------------------------------------------------------------------------------
/components/live.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from "react";
2 |
3 | import { Comments } from "@/components/comments/comments";
4 | import { CursorChat } from "@/components/cursor/cursor-chat";
5 | import { LiveCursors } from "@/components/cursor/live-cursors";
6 | import { FlyingReaction } from "@/components/reaction/flying-reaction";
7 | import { ReactionSelector } from "@/components/reaction/reaction-button";
8 | import {
9 | ContextMenu,
10 | ContextMenuContent,
11 | ContextMenuItem,
12 | ContextMenuTrigger,
13 | } from "@/components/ui/context-menu";
14 | import { shortcuts } from "@/constants";
15 | import { useInterval } from "@/hooks/use-interval";
16 | import {
17 | useBroadcastEvent,
18 | useEventListener,
19 | useMyPresence,
20 | } from "@/liveblocks.config";
21 | import { CursorMode, type Reaction, type CursorState } from "@/types/type";
22 |
23 | type LiveProps = {
24 | canvasRef: React.MutableRefObject;
25 | undo: () => void;
26 | redo: () => void;
27 | };
28 |
29 | export const Live = ({ canvasRef, undo, redo }: LiveProps) => {
30 | const broadcast = useBroadcastEvent();
31 | const [{ cursor }, updateMyPresence] = useMyPresence();
32 |
33 | const [reaction, setReaction] = useState([]);
34 | const [cursorState, setCursorState] = useState({
35 | mode: CursorMode.Hidden,
36 | });
37 |
38 | useInterval(() => {
39 | setReaction((reaction) => {
40 | return reaction.filter((react) => react.timestamp > Date.now() - 4000);
41 | });
42 | }, 1000);
43 |
44 | useInterval(() => {
45 | if (
46 | cursor &&
47 | cursorState.mode === CursorMode.Reaction &&
48 | cursorState.isPressed
49 | ) {
50 | setReaction((prevReaction) => {
51 | return prevReaction.concat([
52 | {
53 | point: { x: cursor.x, y: cursor.y },
54 | value: cursorState.reaction,
55 | timestamp: Date.now(),
56 | },
57 | ]);
58 | });
59 |
60 | broadcast({
61 | x: cursor.x,
62 | y: cursor.y,
63 | value: cursorState.reaction,
64 | });
65 | }
66 | }, 100);
67 |
68 | const handlePointerMove = useCallback(
69 | (e: React.PointerEvent) => {
70 | e.preventDefault();
71 |
72 | if (cursor === null || cursorState.mode !== CursorMode.ReactionSelector) {
73 | const x = e.clientX - e.currentTarget.getBoundingClientRect().x;
74 | const y = e.clientY - e.currentTarget.getBoundingClientRect().y;
75 |
76 | updateMyPresence({
77 | cursor: { x, y },
78 | });
79 | }
80 | },
81 | [cursor, cursorState.mode, updateMyPresence]
82 | );
83 |
84 | const handlePointerLeave = useCallback(
85 | (_e: React.PointerEvent) => {
86 | setCursorState({ mode: CursorMode.Hidden });
87 |
88 | updateMyPresence({
89 | cursor: null,
90 | message: null,
91 | });
92 | },
93 | [updateMyPresence]
94 | );
95 |
96 | const handlePointerDown = useCallback(
97 | (e: React.PointerEvent) => {
98 | const x = e.clientX - e.currentTarget.getBoundingClientRect().x;
99 | const y = e.clientY - e.currentTarget.getBoundingClientRect().y;
100 |
101 | updateMyPresence({
102 | cursor: { x, y },
103 | });
104 |
105 | setCursorState((prevCursorState) => {
106 | if (prevCursorState.mode === CursorMode.Reaction) {
107 | return { ...prevCursorState, isPressed: true };
108 | } else {
109 | return prevCursorState;
110 | }
111 | });
112 | },
113 | [updateMyPresence]
114 | );
115 |
116 | const handlePointerUp = useCallback(
117 | (_e: React.PointerEvent) => {
118 | setCursorState((prevCursorState) => {
119 | if (prevCursorState.mode === CursorMode.Reaction) {
120 | return { ...prevCursorState, isPressed: true };
121 | } else {
122 | return prevCursorState;
123 | }
124 | });
125 | },
126 | [setCursorState]
127 | );
128 |
129 | const setReactions = useCallback((reaction: string) => {
130 | setCursorState({
131 | mode: CursorMode.Reaction,
132 | reaction,
133 | isPressed: false,
134 | });
135 | }, []);
136 |
137 | useEffect(() => {
138 | const onKeyUp = (e: KeyboardEvent) => {
139 | if (e.key === "/") {
140 | setCursorState({
141 | mode: CursorMode.Chat,
142 | previousMessage: "",
143 | message: "",
144 | });
145 | }
146 |
147 | if (e.key === "Escape") {
148 | updateMyPresence({ message: "" });
149 | setCursorState({ mode: CursorMode.Hidden });
150 | }
151 |
152 | if (e.key === "e" || e.key === "E") {
153 | setCursorState({
154 | mode: CursorMode.ReactionSelector,
155 | });
156 | }
157 | };
158 | const onKeyDown = (e: KeyboardEvent) => {
159 | if (e.key === "/") {
160 | e.preventDefault();
161 | }
162 | };
163 |
164 | window.addEventListener("keyup", onKeyUp);
165 | window.addEventListener("keydown", onKeyDown);
166 |
167 | return () => {
168 | window.removeEventListener("keyup", onKeyUp);
169 | window.removeEventListener("keydown", onKeyDown);
170 | };
171 | }, [updateMyPresence]);
172 |
173 | const handleContextMenuClick = useCallback(
174 | (key: string) => {
175 | switch (key) {
176 | case "Chat":
177 | setCursorState({
178 | mode: CursorMode.Chat,
179 | previousMessage: null,
180 | message: "",
181 | });
182 |
183 | break;
184 | case "Undo":
185 | undo();
186 | break;
187 | case "Redo":
188 | redo();
189 | break;
190 | case "Reactions":
191 | setCursorState({
192 | mode: CursorMode.ReactionSelector,
193 | });
194 | break;
195 | default:
196 | break;
197 | }
198 | },
199 | [redo, undo]
200 | );
201 |
202 | useEventListener((eventData) => {
203 | const event = eventData.event;
204 |
205 | setReaction((prevReaction) => {
206 | return prevReaction.concat([
207 | {
208 | point: { x: event.x, y: event.y },
209 | value: event.value,
210 | timestamp: Date.now(),
211 | },
212 | ]);
213 | });
214 | });
215 |
216 | return (
217 |
218 |
226 |
227 |
228 | {cursor && (
229 |
235 | )}
236 |
237 | {cursorState.mode === CursorMode.ReactionSelector && (
238 |
239 | )}
240 |
241 | {reaction.map((react) => (
242 |
249 | ))}
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 | {shortcuts.map((shortcut) => (
258 | handleContextMenuClick(shortcut.name)}
261 | className="right-menu-item"
262 | >
263 | {shortcut.name}
264 | {shortcut.shortcut}
265 |
266 | ))}
267 |
268 |
269 | );
270 | };
271 |
--------------------------------------------------------------------------------
/components/comments/new-thread.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ComposerSubmitComment } from "@liveblocks/react-comments/primitives";
4 | import * as Portal from "@radix-ui/react-portal";
5 | import { Slot } from "@radix-ui/react-slot";
6 | import {
7 | FormEvent,
8 | type PropsWithChildren,
9 | useCallback,
10 | useEffect,
11 | useRef,
12 | useState,
13 | } from "react";
14 |
15 | import { useMaxZIndex } from "@/lib/use-max-zindex";
16 | import { useCreateThread } from "@/liveblocks.config";
17 |
18 | import { NewThreadCursor } from "./new-thread-cursor";
19 | import { PinnedComposer } from "./pinned-composer";
20 |
21 | type ComposerCoords = null | { x: number; y: number };
22 |
23 | export const NewThread = ({ children }: PropsWithChildren) => {
24 | // set state to track if we're placing a new comment or not
25 | const [creatingCommentState, setCreatingCommentState] = useState<
26 | "placing" | "placed" | "complete"
27 | >("complete");
28 |
29 | /**
30 | * We're using the useCreateThread hook to create a new thread.
31 | *
32 | * useCreateThread: https://liveblocks.io/docs/api-reference/liveblocks-react#useCreateThread
33 | */
34 | const createThread = useCreateThread();
35 |
36 | // get the max z-index of a thread
37 | const maxZIndex = useMaxZIndex();
38 |
39 | // set state to track the coordinates of the composer (liveblocks comment editor)
40 | const [composerCoords, setComposerCoords] = useState(null);
41 |
42 | // set state to track the last pointer event
43 | const lastPointerEvent = useRef(null);
44 |
45 | // set state to track if user is allowed to use the composer
46 | const [allowUseComposer, setAllowUseComposer] = useState(false);
47 | const allowComposerRef = useRef(allowUseComposer);
48 | allowComposerRef.current = allowUseComposer;
49 |
50 | useEffect(() => {
51 | // If composer is already placed, don't do anything
52 | if (creatingCommentState === "complete") {
53 | return;
54 | }
55 |
56 | // Place a composer on the screen
57 | const newComment = (e: MouseEvent) => {
58 | e.preventDefault();
59 |
60 | // If already placed, click outside to close composer
61 | if (creatingCommentState === "placed") {
62 | // check if the click event is on/inside the composer
63 | const isClickOnComposer = ((e as any)._savedComposedPath = e
64 | .composedPath()
65 | .some((el: any) => {
66 | return el.classList?.contains("lb-composer-editor-actions");
67 | }));
68 |
69 | // if click is inisde/on composer, don't do anything
70 | if (isClickOnComposer) {
71 | return;
72 | }
73 |
74 | // if click is outside composer, close composer
75 | if (!isClickOnComposer) {
76 | setCreatingCommentState("complete");
77 | return;
78 | }
79 | }
80 |
81 | // First click sets composer down
82 | setCreatingCommentState("placed");
83 | setComposerCoords({
84 | x: e.clientX,
85 | y: e.clientY,
86 | });
87 | };
88 |
89 | document.documentElement.addEventListener("click", newComment);
90 |
91 | return () => {
92 | document.documentElement.removeEventListener("click", newComment);
93 | };
94 | }, [creatingCommentState]);
95 |
96 | useEffect(() => {
97 | // If dragging composer, update position
98 | const handlePointerMove = (e: PointerEvent) => {
99 | // Prevents issue with composedPath getting removed
100 | (e as any)._savedComposedPath = e.composedPath();
101 | lastPointerEvent.current = e;
102 | };
103 |
104 | document.documentElement.addEventListener("pointermove", handlePointerMove);
105 |
106 | return () => {
107 | document.documentElement.removeEventListener(
108 | "pointermove",
109 | handlePointerMove
110 | );
111 | };
112 | }, []);
113 |
114 | // Set pointer event from last click on body for use later
115 | useEffect(() => {
116 | if (creatingCommentState !== "placing") {
117 | return;
118 | }
119 |
120 | const handlePointerDown = (e: PointerEvent) => {
121 | // if composer is already placed, don't do anything
122 | if (allowComposerRef.current) {
123 | return;
124 | }
125 |
126 | // Prevents issue with composedPath getting removed
127 | (e as any)._savedComposedPath = e.composedPath();
128 | lastPointerEvent.current = e;
129 | setAllowUseComposer(true);
130 | };
131 |
132 | // Right click to cancel placing
133 | const handleContextMenu = (e: Event) => {
134 | if (creatingCommentState === "placing") {
135 | e.preventDefault();
136 | setCreatingCommentState("complete");
137 | }
138 | };
139 |
140 | document.documentElement.addEventListener("pointerdown", handlePointerDown);
141 | document.documentElement.addEventListener("contextmenu", handleContextMenu);
142 |
143 | return () => {
144 | document.documentElement.removeEventListener(
145 | "pointerdown",
146 | handlePointerDown
147 | );
148 | document.documentElement.removeEventListener(
149 | "contextmenu",
150 | handleContextMenu
151 | );
152 | };
153 | }, [creatingCommentState]);
154 |
155 | // On composer submit, create thread and reset state
156 | const handleComposerSubmit = useCallback(
157 | ({ body }: ComposerSubmitComment, event: FormEvent) => {
158 | event.preventDefault();
159 | event.stopPropagation();
160 |
161 | // Get your canvas element
162 | const overlayPanel = document.querySelector("#canvas");
163 |
164 | // if there's no composer coords or last pointer event, meaning the user hasn't clicked yet, don't do anything
165 | if (!composerCoords || !lastPointerEvent.current || !overlayPanel) {
166 | return;
167 | }
168 |
169 | // Set coords relative to the top left of your canvas
170 | const { top, left } = overlayPanel.getBoundingClientRect();
171 | const x = composerCoords.x - left;
172 | const y = composerCoords.y - top;
173 |
174 | // create a new thread with the composer coords and cursor selectors
175 | createThread({
176 | body,
177 | metadata: {
178 | x,
179 | y,
180 | resolved: false,
181 | zIndex: maxZIndex + 1,
182 | },
183 | });
184 |
185 | setComposerCoords(null);
186 | setCreatingCommentState("complete");
187 | setAllowUseComposer(false);
188 | },
189 | [createThread, composerCoords, maxZIndex]
190 | );
191 |
192 | return (
193 | <>
194 | {/**
195 | * Slot is used to wrap the children of the NewThread component
196 | * to allow us to add a click event listener to the children
197 | *
198 | * Slot: https://www.radix-ui.com/primitives/docs/utilities/slot
199 | *
200 | * Disclaimer: We don't have to download this package specifically,
201 | * it's already included when we install Shadcn
202 | */}
203 |
205 | setCreatingCommentState(
206 | creatingCommentState !== "complete" ? "complete" : "placing"
207 | )
208 | }
209 | style={{ opacity: creatingCommentState !== "complete" ? 0.7 : 1 }}
210 | >
211 | {children}
212 |
213 |
214 | {/* if composer coords exist and we're placing a comment, render the composer */}
215 | {composerCoords && creatingCommentState === "placed" ? (
216 | /**
217 | * Portal.Root is used to render the composer outside of the NewThread component to avoid z-index issuess
218 | *
219 | * Portal.Root: https://www.radix-ui.com/primitives/docs/utilities/portal
220 | */
221 |
229 |
230 |
231 | ) : null}
232 |
233 | {/* Show the customizing cursor when placing a comment. The one with comment shape */}
234 |
235 | >
236 | );
237 | };
238 |
--------------------------------------------------------------------------------
/public/freeform.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Contributing to Figma Clone
4 |
5 | First off, thanks for taking the time to contribute! ❤️
6 |
7 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
8 |
9 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
10 | >
11 | > - Star the project
12 | > - Tweet about it
13 | > - Refer this project in your project's readme
14 | > - Mention the project at local meetups and tell your friends/colleagues
15 |
16 |
17 |
18 | ## Table of Contents
19 |
20 | - [Code of Conduct](#code-of-conduct)
21 | - [I Have a Question](#i-have-a-question)
22 | - [I Want To Contribute](#i-want-to-contribute)
23 | - [Reporting Bugs](#reporting-bugs)
24 | - [Suggesting Enhancements](#suggesting-enhancements)
25 | - [Your First Code Contribution](#your-first-code-contribution)
26 | - [Improving The Documentation](#improving-the-documentation)
27 | - [Styleguides](#styleguides)
28 | - [Commit Messages](#commit-messages)
29 | - [Join The Project Team](#join-the-project-team)
30 |
31 | ## Code of Conduct
32 |
33 | This project and everyone participating in it is governed by the
34 | [Figma Clone Code of Conduct](https://github.com/sanidhyy/figma-cloneblob/master/CODE_OF_CONDUCT.md).
35 | By participating, you are expected to uphold this code. Please report unacceptable behavior
36 | to .
37 |
38 | ## I Have a Question
39 |
40 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/sanidhyy/figma-clone/wiki).
41 |
42 | Before you ask a question, it is best to search for existing [Issues](https://github.com/sanidhyy/figma-clone/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
43 |
44 | If you then still feel the need to ask a question and need clarification, we recommend the following:
45 |
46 | - Open an [Issue](https://github.com/sanidhyy/figma-clone/issues/new).
47 | - Provide as much context as you can about what you're running into.
48 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
49 |
50 | We will then take care of the issue as soon as possible.
51 |
52 | ## I Want To Contribute
53 |
54 | > ### Legal Notice
55 | >
56 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
57 |
58 | ### Reporting Bugs
59 |
60 |
61 |
62 | #### Before Submitting a Bug Report
63 |
64 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
65 |
66 | - Make sure that you are using the latest version.
67 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/sanidhyy/figma-clone/wiki). If you are looking for support, you might want to check [this section](#i-have-a-question)).
68 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/sanidhyy/figma-cloneissues?q=label%3Abug).
69 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
70 | - Collect information about the bug:
71 | - Stack trace (Traceback)
72 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
73 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
74 | - Possibly your input and the output
75 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions?
76 |
77 |
78 |
79 | #### How Do I Submit a Good Bug Report?
80 |
81 | > You must never report security related issues, vulnerabilities or bugs including sensitive information to the issue tracker, or elsewhere in public. Instead sensitive bugs must be sent by email to .
82 |
83 | We use GitHub issues to track bugs and errors. If you run into an issue with the project:
84 |
85 | - Open an [Issue](https://github.com/sanidhyy/figma-clone/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)
86 | - Explain the behavior you would expect and the actual behavior.
87 | - Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
88 | - Provide the information you collected in the previous section.
89 |
90 | Once it's filed:
91 |
92 | - The project team will label the issue accordingly.
93 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
94 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
95 |
96 | ### Suggesting Enhancements
97 |
98 | This section guides you through submitting an enhancement suggestion for Figma Clone, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
99 |
100 |
101 |
102 | #### Before Submitting an Enhancement
103 |
104 | - Make sure that you are using the latest version.
105 | - Read the [documentation](https://github.com/sanidhyy/figma-clone/wiki) carefully and find out if the functionality is already covered, maybe by an individual configuration.
106 | - Perform a [search](https://github.com/sanidhyy/figma-clone/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
107 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
108 |
109 |
110 |
111 | #### How Do I Submit a Good Enhancement Suggestion?
112 |
113 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/sanidhyy/figma-clone/issues).
114 |
115 | - Use a **clear and descriptive title** for the issue to identify the suggestion.
116 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
117 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
118 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux.
119 | - **Explain why this enhancement would be useful** to most Figma Clone users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
120 |
121 |
122 |
123 | ## Attribution
124 |
125 | This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)!
126 |
--------------------------------------------------------------------------------
/.github/images/stats.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
122 |
123 |
124 |
125 | 99
126 | Performance
127 |
128 |
129 |
130 |
131 | 100
132 | Accessibility
133 |
134 |
135 |
136 |
137 | 96
138 | Best Practices
139 |
140 |
141 |
142 |
143 | 100
144 | SEO
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 | Progressive
211 | Web App
212 |
213 |
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 | 0-49
227 | 50-89
228 | 90-100
229 |
230 |
231 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # A minimalist Figma clone using Fabric.js and Liveblocks for real-time collaboration.
4 |
5 | 
6 |
7 | [](https://github.com/sanidhyy "Ask Me Anything!")
8 | [](https://github.com/sanidhyy/figma-clone/blob/main/LICENSE "GitHub license")
9 | [](https://github.com/sanidhyy/figma-clone/commits/main "Maintenance")
10 | [](https://github.com/sanidhyy/figma-clone/branches "GitHub branches")
11 | [](https://github.com/sanidhyy/figma-clone/commits "Github commits")
12 | [](https://github.com/sanidhyy/figma-clone/issues "GitHub issues")
13 | [](https://github.com/sanidhyy/figma-clone/pulls "GitHub pull requests")
14 | [](https://clone-figmaa.netlify.app/ "Netlify Status")
15 |
16 |
17 |
18 |
19 |
20 |
21 | # :notebook_with_decorative_cover: Table of Contents
22 |
23 |
24 |
25 | - [Folder Structure](#bangbang-folder-structure)
26 | - [Getting Started](#toolbox-getting-started)
27 | - [Screenshots](#camera-screenshots)
28 | - [Tech Stack](#gear-tech-stack)
29 | - [Stats](#wrench-stats)
30 | - [Contribute](#raised_hands-contribute)
31 | - [Acknowledgements](#gem-acknowledgements)
32 | - [Buy Me a Coffee](#coffee-buy-me-a-coffee)
33 | - [Follow Me](#rocket-follow-me)
34 | - [Learn More](#books-learn-more)
35 | - [Deploy on Netlify](#page_with_curl-deploy-on-netlify)
36 | - [Give A Star](#star-give-a-star)
37 | - [Star History](#star2-star-history)
38 | - [Give A Star](#star-give-a-star)
39 |
40 |
41 |
42 | ## :bangbang: Folder Structure
43 |
44 | Here is the folder structure of this app.
45 |
46 | ```bash
47 | figma-clone/
48 | |- app/
49 | |-- app.tsx
50 | |-- apple-icon.png
51 | |-- favicon.ico
52 | |-- globals.css
53 | |-- icon1.png
54 | |-- icon2.png
55 | |-- layout.tsx
56 | |-- page.tsx
57 | |-- room.tsx
58 | |- components/
59 | |-- comments/
60 | |-- cursor/
61 | |-- reaction/
62 | |-- settings/
63 | |-- ui/
64 | |-- users/
65 | |-- left-sidebar.tsx
66 | |-- live.tsx
67 | |-- loader.tsx
68 | |-- navbar.tsx
69 | |-- right-sidebar.tsx
70 | |-- shapes-menu.tsx
71 | |- config/
72 | |-- index.ts
73 | |- constants/
74 | |-- index.ts
75 | |- hooks/
76 | |-- use-interval.ts
77 | |- lib/
78 | |-- canvas.ts
79 | |-- key-events.ts
80 | |-- shapes.ts
81 | |-- use-max-zindex.ts
82 | |-- utils.ts
83 | |- public/
84 | |- scripts/
85 | |-- prod.ts
86 | |-- reset.ts
87 | |-- seed.ts
88 | |- store/
89 | |-- use-exit-modal.ts
90 | |-- use-hearts-modal.ts
91 | |-- use-practice-modal.ts
92 | |- types/
93 | |-- declaration.d.ts
94 | |-- type.ts
95 | |- .env.local
96 | |- .env.example
97 | |- .eslintrc.js
98 | |- .gitignore
99 | |- .prettierrc.json
100 | |- components.json
101 | |- environment.d.ts
102 | |- liveblocks.config.ts
103 | |- next.config.mjs
104 | |- package-lock.json
105 | |- package.json
106 | |- postcss.config.js
107 | |- tailwind.config.ts
108 | |- tsconfig.json
109 | ```
110 |
111 |
112 |
113 | ## :toolbox: Getting Started
114 |
115 | 1. Make sure **Git** and **NodeJS** is installed.
116 | 2. Clone this repository to your local computer.
117 | 3. Create `.env.local` file in **root** directory.
118 | 4. Contents of `.env.local`:
119 |
120 | ```env
121 | # .env.local
122 |
123 | # disable next.js telemetry
124 | NEXT_TELEMETRY_DISABLED=1
125 |
126 | # liveblocks api key
127 | NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=pk_dev_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
128 |
129 | ```
130 |
131 | ### 5. Set Up Liveblocks
132 |
133 | 1. **Create a Liveblocks Account**:
134 |
135 | - If you don't have a Liveblocks account, sign up at [Liveblocks](https://liveblocks.io/).
136 |
137 | 2. **Create a New Project**:
138 | - After logging in, navigate to the Liveblocks dashboard.
139 | - Click on "Create Project" to set up a new project for Figma-Clone.
140 | - Provide a name for your project and create it.
141 |
142 | ### 6. Obtain the Liveblocks Public Key
143 |
144 | 1. **Navigate to the Project Settings**:
145 |
146 | - In your Liveblocks dashboard, select the project you created for Figma-Clone.
147 | - Go to the "Settings" or "API Keys" section.
148 |
149 | 2. **Copy the Public Key**:
150 |
151 | - You will find the "Public Key" under the API Keys section. Copy the public key that starts with `pk_dev_` and replace placeholder value in `NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY` with the copied value.
152 |
153 | 3. Save and Secure:
154 |
155 | - Save the changes to the `.env.local` file.
156 |
157 | 4. Install Project Dependencies using `npm install --legacy-peer-deps` or `yarn install --legacy-peer-deps`.
158 |
159 | 5. Now app is fully configured 👍 and you can start using this app using either one of `npm run dev` or `yarn dev`.
160 |
161 | **NOTE:** Please make sure to keep your API keys and configuration values secure and do not expose them publicly.
162 |
163 | ## :camera: Screenshots
164 |
165 | 
166 |
167 | 
168 |
169 | 
170 |
171 | ## :gear: Tech Stack
172 |
173 | [](https://react.dev/ "React JS") [](https://nextjs.org/ "Next JS") [](https://www.typescriptlang.org/ "Typescript") [](https://tailwindcss.com/ "Tailwind CSS") [](https://netlify.app/ "Netlify")
174 |
175 | ## :wrench: Stats
176 |
177 | [](https://pagespeed.web.dev/analysis?url=https://clone-figmaa.netlify.app/ "Stats for Figma Clone")
178 |
179 | ## :raised_hands: Contribute
180 |
181 | You might encounter some bugs while using this app. You are more than welcome to contribute. Just submit changes via pull request and I will review them before merging. Make sure you follow community guidelines.
182 |
183 | ## :gem: Acknowledgements
184 |
185 | Useful resources and dependencies that are used in Figma Clone.
186 |
187 | - [@liveblocks/client](https://www.npmjs.com/package/@liveblocks/client): ^1.12.0
188 | - [@liveblocks/react](https://www.npmjs.com/package/@liveblocks/react): ^1.12.0
189 | - [@liveblocks/react-comments](https://www.npmjs.com/package/@liveblocks/react-comments): ^1.12.0
190 | - [@radix-ui/react-context-menu](https://www.npmjs.com/package/@radix-ui/react-context-menu): ^2.1.5
191 | - [@radix-ui/react-dropdown-menu](https://www.npmjs.com/package/@radix-ui/react-dropdown-menu): ^2.0.6
192 | - [@radix-ui/react-label](https://www.npmjs.com/package/@radix-ui/react-label): ^2.0.2
193 | - [@radix-ui/react-select](https://www.npmjs.com/package/@radix-ui/react-select): ^2.0.0
194 | - [@radix-ui/react-slot](https://www.npmjs.com/package/@radix-ui/react-slot): ^1.0.2
195 | - [class-variance-authority](https://www.npmjs.com/package/class-variance-authority): ^0.7.0
196 | - [clsx](https://www.npmjs.com/package/clsx): ^2.1.1
197 | - [fabric](https://www.npmjs.com/package/fabric): ^5.3.0
198 | - [jspdf](https://www.npmjs.com/package/jspdf): ^2.5.1
199 | - [lucide-react](https://www.npmjs.com/package/lucide-react): ^0.379.0
200 | - [next](https://www.npmjs.com/package/next): 14.2.3
201 | - [react](https://www.npmjs.com/package/react): ^18
202 | - [react-dom](https://www.npmjs.com/package/react-dom): ^18
203 | - [tailwind-merge](https://www.npmjs.com/package/tailwind-merge): ^2.3.0
204 | - [tailwindcss-animate](https://www.npmjs.com/package/tailwindcss-animate): ^1.0.7
205 | - [uuid](https://www.npmjs.com/package/uuid): ^9.0.1
206 | - [@types/fabric](https://www.npmjs.com/package/@types/fabric): ^5.3.7
207 | - [@types/node](https://www.npmjs.com/package/@types/node): ^20
208 | - [@types/react](https://www.npmjs.com/package/@types/react): ^18
209 | - [@types/react-dom](https://www.npmjs.com/package/@types/react-dom): ^18
210 | - [@types/uuid](https://www.npmjs.com/package/@types/uuid): ^9.0.8
211 | - [eslint](https://www.npmjs.com/package/eslint): ^8
212 | - [eslint-config-next](https://www.npmjs.com/package/eslint-config-next): 14.2.3
213 | - [postcss](https://www.npmjs.com/package/postcss): ^8
214 | - [tailwindcss](https://www.npmjs.com/package/tailwindcss): ^3.4.1
215 | - [typescript](https://www.npmjs.com/package/typescript): ^5
216 |
217 | ## :coffee: Buy Me a Coffee
218 |
219 | [ ](https://www.buymeacoffee.com/sanidhy "Buy me a Coffee")
220 |
221 | ## :rocket: Follow Me
222 |
223 | [](https://github.com/sanidhyy "Follow Me")
224 | [](https://twitter.com/intent/tweet?text=Check+out+this+amazing+app:&url=https%3A%2F%2Fgithub.com%2Fsanidhyy%2Ffigma-clone "Tweet about this project")
225 | [](https://www.youtube.com/@OPGAMER./?sub_confirmation=1 "Subscribe to my YouTube Channel")
226 |
227 | ## :books: Learn More
228 |
229 | To learn more about Next.js, take a look at the following resources:
230 |
231 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
232 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
233 |
234 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
235 |
236 | ## :page_with_curl: Deploy on Netlify
237 |
238 | The simplest way to deploy your React.js app is to use the [Netlify Platform](https://app.netlify.com/start) - a powerful platform for modern web projects.
239 |
240 | Explore the [Netlify deployment documentation](https://docs.netlify.com/site-deploys/create-deploys) for step-by-step instructions on deploying your React.js app on Netlify.
241 |
242 | Happy coding, and feel free to share your thoughts and improvements with the [Netlify community](https://community.netlify.com)!
243 |
244 | ## :star: Give A Star
245 |
246 | You can also give this repository a star to show more people and they can use this repository.
247 |
248 | ## :star2: Star History
249 |
250 |
251 |
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 | (back to top )
260 |
--------------------------------------------------------------------------------
/lib/canvas.ts:
--------------------------------------------------------------------------------
1 | import { fabric } from "fabric";
2 | import { v4 as uuid4 } from "uuid";
3 |
4 | import { defaultNavElement } from "@/constants";
5 | import {
6 | CanvasMouseDown,
7 | CanvasMouseMove,
8 | CanvasMouseUp,
9 | CanvasObjectModified,
10 | CanvasObjectScaling,
11 | CanvasPathCreated,
12 | CanvasSelectionCreated,
13 | RenderCanvas,
14 | } from "@/types/type";
15 |
16 | import { createSpecificShape } from "./shapes";
17 |
18 | // initialize fabric canvas
19 | export const initializeFabric = ({
20 | fabricRef,
21 | canvasRef,
22 | }: {
23 | fabricRef: React.MutableRefObject;
24 | canvasRef: React.MutableRefObject;
25 | }) => {
26 | // get canvas element
27 | const canvasElement = document.getElementById("canvas");
28 |
29 | // create fabric canvas
30 | const canvas = new fabric.Canvas(canvasRef.current, {
31 | width: canvasElement?.clientWidth,
32 | height: canvasElement?.clientHeight,
33 | });
34 |
35 | // set canvas reference to fabricRef so we can use it later anywhere outside canvas listener
36 | fabricRef.current = canvas;
37 |
38 | return canvas;
39 | };
40 |
41 | // instantiate creation of custom fabric object/shape and add it to canvas
42 | export const handleCanvasMouseDown = ({
43 | options,
44 | canvas,
45 | selectedShapeRef,
46 | isDrawing,
47 | shapeRef,
48 | }: CanvasMouseDown) => {
49 | // get pointer coordinates
50 | const pointer = canvas.getPointer(options.e);
51 |
52 | /**
53 | * get target object i.e., the object that is clicked
54 | * findtarget() returns the object that is clicked
55 | *
56 | * findTarget: http://fabricjs.com/docs/fabric.Canvas.html#findTarget
57 | */
58 | const target = canvas.findTarget(options.e, false);
59 |
60 | // set canvas drawing mode to false
61 | canvas.isDrawingMode = false;
62 |
63 | // if selected shape is freeform, set drawing mode to true and return
64 | if (selectedShapeRef.current === "freeform") {
65 | isDrawing.current = true;
66 | canvas.isDrawingMode = true;
67 | canvas.freeDrawingBrush.width = 5;
68 | return;
69 | }
70 |
71 | canvas.isDrawingMode = false;
72 |
73 | // if target is the selected shape or active selection, set isDrawing to false
74 | if (
75 | target &&
76 | (target.type === selectedShapeRef.current ||
77 | target.type === "activeSelection")
78 | ) {
79 | isDrawing.current = false;
80 |
81 | // set active object to target
82 | canvas.setActiveObject(target);
83 |
84 | /**
85 | * setCoords() is used to update the controls of the object
86 | * setCoords: http://fabricjs.com/docs/fabric.Object.html#setCoords
87 | */
88 | target.setCoords();
89 | } else {
90 | isDrawing.current = true;
91 |
92 | // create custom fabric object/shape and set it to shapeRef
93 | shapeRef.current = createSpecificShape(
94 | selectedShapeRef.current,
95 | pointer as any
96 | );
97 |
98 | // if shapeRef is not null, add it to canvas
99 | if (shapeRef.current) {
100 | // add: http://fabricjs.com/docs/fabric.Canvas.html#add
101 | canvas.add(shapeRef.current);
102 | }
103 | }
104 | };
105 |
106 | // handle mouse move event on canvas to draw shapes with different dimensions
107 | export const handleCanvasMouseMove = ({
108 | options,
109 | canvas,
110 | isDrawing,
111 | selectedShapeRef,
112 | shapeRef,
113 | syncShapeInStorage,
114 | }: CanvasMouseMove) => {
115 | // if selected shape is freeform, return
116 | if (!isDrawing.current) return;
117 | if (selectedShapeRef.current === "freeform") return;
118 |
119 | canvas.isDrawingMode = false;
120 |
121 | // get pointer coordinates
122 | const pointer = canvas.getPointer(options.e);
123 |
124 | // depending on the selected shape, set the dimensions of the shape stored in shapeRef in previous step of handelCanvasMouseDown
125 | // calculate shape dimensions based on pointer coordinates
126 | switch (selectedShapeRef?.current) {
127 | case "rectangle":
128 | shapeRef.current?.set({
129 | width: pointer.x - (shapeRef.current?.left || 0),
130 | height: pointer.y - (shapeRef.current?.top || 0),
131 | });
132 | break;
133 |
134 | case "circle":
135 | shapeRef.current.set({
136 | radius: Math.abs(pointer.x - (shapeRef.current?.left || 0)) / 2,
137 | });
138 | break;
139 |
140 | case "triangle":
141 | shapeRef.current?.set({
142 | width: pointer.x - (shapeRef.current?.left || 0),
143 | height: pointer.y - (shapeRef.current?.top || 0),
144 | });
145 | break;
146 |
147 | case "line":
148 | shapeRef.current?.set({
149 | x2: pointer.x,
150 | y2: pointer.y,
151 | });
152 | break;
153 |
154 | case "image":
155 | shapeRef.current?.set({
156 | width: pointer.x - (shapeRef.current?.left || 0),
157 | height: pointer.y - (shapeRef.current?.top || 0),
158 | });
159 |
160 | default:
161 | break;
162 | }
163 |
164 | // render objects on canvas
165 | // renderAll: http://fabricjs.com/docs/fabric.Canvas.html#renderAll
166 | canvas.renderAll();
167 |
168 | // sync shape in storage
169 | if (shapeRef.current?.objectId) {
170 | syncShapeInStorage(shapeRef.current);
171 | }
172 | };
173 |
174 | // handle mouse up event on canvas to stop drawing shapes
175 | export const handleCanvasMouseUp = ({
176 | canvas,
177 | isDrawing,
178 | shapeRef,
179 | activeObjectRef,
180 | selectedShapeRef,
181 | syncShapeInStorage,
182 | setActiveElement,
183 | }: CanvasMouseUp) => {
184 | isDrawing.current = false;
185 | if (selectedShapeRef.current === "freeform") return;
186 |
187 | // sync shape in storage as drawing is stopped
188 | syncShapeInStorage(shapeRef.current);
189 |
190 | // set everything to null
191 | shapeRef.current = null;
192 | activeObjectRef.current = null;
193 | selectedShapeRef.current = null;
194 |
195 | // if canvas is not in drawing mode, set active element to default nav element after 700ms
196 | if (!canvas.isDrawingMode) {
197 | setTimeout(() => {
198 | setActiveElement(defaultNavElement);
199 | }, 700);
200 | }
201 | };
202 |
203 | // update shape in storage when object is modified
204 | export const handleCanvasObjectModified = ({
205 | options,
206 | syncShapeInStorage,
207 | }: CanvasObjectModified) => {
208 | const target = options.target;
209 | if (!target) return;
210 |
211 | if (target?.type == "activeSelection") {
212 | // fix this
213 | } else {
214 | syncShapeInStorage(target);
215 | }
216 | };
217 |
218 | // update shape in storage when path is created when in freeform mode
219 | export const handlePathCreated = ({
220 | options,
221 | syncShapeInStorage,
222 | }: CanvasPathCreated) => {
223 | // get path object
224 | const path = options.path;
225 | if (!path) return;
226 |
227 | // set unique id to path object
228 | path.set({
229 | objectId: uuid4(),
230 | });
231 |
232 | // sync shape in storage
233 | syncShapeInStorage(path);
234 | };
235 |
236 | // check how object is moving on canvas and restrict it to canvas boundaries
237 | export const handleCanvasObjectMoving = ({
238 | options,
239 | }: {
240 | options: fabric.IEvent;
241 | }) => {
242 | // get target object which is moving
243 | const target = options.target as fabric.Object;
244 |
245 | // target.canvas is the canvas on which the object is moving
246 | const canvas = target.canvas as fabric.Canvas;
247 |
248 | // set coordinates of target object
249 | target.setCoords();
250 |
251 | // restrict object to canvas boundaries (horizontal)
252 | if (target && target.left) {
253 | target.left = Math.max(
254 | 0,
255 | Math.min(
256 | target.left,
257 | (canvas.width || 0) - (target.getScaledWidth() || target.width || 0)
258 | )
259 | );
260 | }
261 |
262 | // restrict object to canvas boundaries (vertical)
263 | if (target && target.top) {
264 | target.top = Math.max(
265 | 0,
266 | Math.min(
267 | target.top,
268 | (canvas.height || 0) - (target.getScaledHeight() || target.height || 0)
269 | )
270 | );
271 | }
272 | };
273 |
274 | // set element attributes when element is selected
275 | export const handleCanvasSelectionCreated = ({
276 | options,
277 | isEditingRef,
278 | setElementAttributes,
279 | }: CanvasSelectionCreated) => {
280 | // if user is editing manually, return
281 | if (isEditingRef.current) return;
282 |
283 | // if no element is selected, return
284 | if (!options?.selected) return;
285 |
286 | // get the selected element
287 | const selectedElement = options?.selected[0] as fabric.Object;
288 |
289 | // if only one element is selected, set element attributes
290 | if (selectedElement && options.selected.length === 1) {
291 | // calculate scaled dimensions of the object
292 | const scaledWidth = selectedElement?.scaleX
293 | ? selectedElement?.width! * selectedElement?.scaleX
294 | : selectedElement?.width;
295 |
296 | const scaledHeight = selectedElement?.scaleY
297 | ? selectedElement?.height! * selectedElement?.scaleY
298 | : selectedElement?.height;
299 |
300 | setElementAttributes({
301 | width: scaledWidth?.toFixed(0).toString() || "",
302 | height: scaledHeight?.toFixed(0).toString() || "",
303 | fill: selectedElement?.fill?.toString() || "",
304 | stroke: selectedElement?.stroke || "",
305 | // @ts-ignore
306 | fontSize: selectedElement?.fontSize || "",
307 | // @ts-ignore
308 | fontFamily: selectedElement?.fontFamily || "",
309 | // @ts-ignore
310 | fontWeight: selectedElement?.fontWeight || "",
311 | });
312 | }
313 | };
314 |
315 | // update element attributes when element is scaled
316 | export const handleCanvasObjectScaling = ({
317 | options,
318 | setElementAttributes,
319 | }: CanvasObjectScaling) => {
320 | const selectedElement = options.target;
321 |
322 | // calculate scaled dimensions of the object
323 | const scaledWidth = selectedElement?.scaleX
324 | ? selectedElement?.width! * selectedElement?.scaleX
325 | : selectedElement?.width;
326 |
327 | const scaledHeight = selectedElement?.scaleY
328 | ? selectedElement?.height! * selectedElement?.scaleY
329 | : selectedElement?.height;
330 |
331 | setElementAttributes((prev) => ({
332 | ...prev,
333 | width: scaledWidth?.toFixed(0).toString() || "",
334 | height: scaledHeight?.toFixed(0).toString() || "",
335 | }));
336 | };
337 |
338 | // render canvas objects coming from storage on canvas
339 | export const renderCanvas = ({
340 | fabricRef,
341 | canvasObjects,
342 | activeObjectRef,
343 | }: RenderCanvas) => {
344 | // clear canvas
345 | fabricRef.current?.clear();
346 |
347 | // render all objects on canvas
348 | Array.from(canvasObjects, ([objectId, objectData]) => {
349 | /**
350 | * enlivenObjects() is used to render objects on canvas.
351 | * It takes two arguments:
352 | * 1. objectData: object data to render on canvas
353 | * 2. callback: callback function to execute after rendering objects
354 | * on canvas
355 | *
356 | * enlivenObjects: http://fabricjs.com/docs/fabric.util.html#.enlivenObjectEnlivables
357 | */
358 | fabric.util.enlivenObjects(
359 | [objectData],
360 | (enlivenedObjects: fabric.Object[]) => {
361 | enlivenedObjects.forEach((enlivenedObj) => {
362 | // if element is active, keep it in active state so that it can be edited further
363 | if (activeObjectRef.current?.objectId === objectId) {
364 | fabricRef.current?.setActiveObject(enlivenedObj);
365 | }
366 |
367 | // add object to canvas
368 | fabricRef.current?.add(enlivenedObj);
369 | });
370 | },
371 | /**
372 | * specify namespace of the object for fabric to render it on canvas
373 | * A namespace is a string that is used to identify the type of
374 | * object.
375 | *
376 | * Fabric Namespace: http://fabricjs.com/docs/fabric.html
377 | */
378 | "fabric"
379 | );
380 | });
381 |
382 | fabricRef.current?.renderAll();
383 | };
384 |
385 | // resize canvas dimensions on window resize
386 | export const handleResize = ({ canvas }: { canvas: fabric.Canvas | null }) => {
387 | const canvasElement = document.getElementById("canvas");
388 |
389 | if (!canvasElement) return;
390 | if (!canvas) return;
391 |
392 | canvas.setDimensions({
393 | width: canvasElement.clientWidth,
394 | height: canvasElement.clientHeight,
395 | });
396 | };
397 |
398 | // zoom canvas on mouse scroll
399 | export const handleCanvasZoom = ({
400 | options,
401 | canvas,
402 | }: {
403 | options: fabric.IEvent & { e: WheelEvent };
404 | canvas: fabric.Canvas;
405 | }) => {
406 | const delta = options.e?.deltaY;
407 | let zoom = canvas.getZoom();
408 |
409 | // allow zooming to min 20% and max 100%
410 | const minZoom = 0.2;
411 | const maxZoom = 1;
412 | const zoomStep = 0.001;
413 |
414 | // calculate zoom based on mouse scroll wheel with min and max zoom
415 | zoom = Math.min(Math.max(minZoom, zoom + delta * zoomStep), maxZoom);
416 |
417 | // set zoom to canvas
418 | // zoomToPoint: http://fabricjs.com/docs/fabric.Canvas.html#zoomToPoint
419 | canvas.zoomToPoint({ x: options.e.offsetX, y: options.e.offsetY }, zoom);
420 |
421 | options.e.preventDefault();
422 | options.e.stopPropagation();
423 | };
424 |
--------------------------------------------------------------------------------