此存储库包含与我们的 YouTube 频道 JavaScript Mastery 上提供的深入教程相对应的代码。
如果你更喜欢视觉学习,这是你的完美资源。按照我们的教程学习如何以适合初学者的方式逐步构建此类项目!
一个简约的 Figma 克隆,用于展示如何使用 fabric.js 在画布上添加真实世界的功能,例如与光标聊天、评论、 React 和绘图设计(形状、图像上传)的实时协作。
如果你刚入门并需要帮助或遇到任何错误,请加入我们活跃的 Discord 社区,拥有超过 27k+ 名成员。这是一个人们互相帮助的地方。
👉 多光标、光标聊天和 React :通过显示单个光标、启用实时聊天和交互式通信的 React ,允许多个用户同时进行协作。
👉 活动用户:显示协作环境中当前活动用户的列表,提供对当前参与人员的可见性。
👉 注释气泡:使用户能够将注释附加到画布上的特定元素,从而促进对设计组件的交流和反馈。
👉 创建不同的形状:为用户提供在画布上生成各种形状的工具,允许多样化的设计元素
👉 上传图像:将图像导入画布,扩展设计中的视觉内容范围
👉 自定义:允许用户调整设计元素的属性,从而在自定义和微调视觉组件方面提供灵活性
👉 自由形式绘图:使用户能够在画布上自由绘图,促进艺术表达和创意设计。
👉 撤消/重做:提供撤消(撤消)或恢复(重做)先前操作的功能,从而为设计决策提供灵活性
👉 键盘操作:允许用户利用键盘快捷键进行各种操作,包括复制、粘贴、删除和触发打开光标聊天、 React 等功能的快捷键,从而提高效率和可访问性。
👉 历史记录:按时间顺序查看画布上所做的操作和更改的历史记录,以帮助进行项目管理和版本控制。
👉 删除、缩放、移动、清除、导出画布:提供一系列用于管理设计元素的功能,包括删除、缩放、移动、清除画布以及导出最终设计以供外部使用。
还有很多,包括代码架构、高级 React 钩子和可重用性
按照以下步骤在计算机上本地设置项目。
先决条件
请确保你的计算机上安装了以下软件:
克隆存储库
git clone https://github.com/JavaScript-Mastery-Pro/figma-ts.git
cd figma-ts
安装
使用 npm 安装项目依赖项:
npm install
设置环境变量
创建一个在项目根目录中命名的新文件,并添加以下内容:
.env.local
NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY=
将占位符值替换为你的实际 Liveblocks 凭据。你可以通过在 Liveblocks 网站上注册来获取这些凭据。
运行项目
npm run dev
在浏览器中打开 http://localhost:3000 以查看项目。
tailwind.config.ts
import type { Config } from "tailwindcss";
const config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
primary: {
black: "#14181F",
green: "#56FFA6",
grey: {
100: "#2B303B",
200: "#202731",
300: "#C4D3ED",
},
},
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
export default config;
app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "@liveblocks/react-comments/styles.css";
* {
font-family:
work sans,
sans-serif;
}
@layer utilities {
.no-ring {
@apply outline-none ring-0 ring-offset-0 focus:ring-0 focus:ring-offset-0 focus-visible:ring-offset-0 !important;
}
.input-ring {
@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;
}
.right-menu-content {
@apply flex w-80 flex-col gap-y-1 border-none bg-primary-black py-4 text-white !important;
}
.right-menu-item {
@apply flex justify-between px-3 py-2 hover:bg-primary-grey-200 !important;
}
}
NewThread
"use client";
import {
FormEvent,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Slot } from "@radix-ui/react-slot";
import * as Portal from "@radix-ui/react-portal";
import { ComposerSubmitComment } from "@liveblocks/react-comments/primitives";
import { useCreateThread } from "@/liveblocks.config";
import { useMaxZIndex } from "@/lib/useMaxZIndex";
import PinnedComposer from "./PinnedComposer";
import NewThreadCursor from "./NewThreadCursor";
type ComposerCoords = null | { x: number; y: number };
type Props = {
children: ReactNode;
};
export const NewThread = ({ children }: Props) => {
// set state to track if we're placing a new comment or not
const [creatingCommentState, setCreatingCommentState] = useState<
"placing" | "placed" | "complete"
>("complete");
/**
* We're using the useCreateThread hook to create a new thread.
*
* useCreateThread: https://liveblocks.io/docs/api-reference/liveblocks-react#useCreateThread
*/
const createThread = useCreateThread();
// get the max z-index of a thread
const maxZIndex = useMaxZIndex();
// set state to track the coordinates of the composer (liveblocks comment editor)
const [composerCoords, setComposerCoords] = useState<ComposerCoords>(null);
// set state to track the last pointer event
const lastPointerEvent = useRef<PointerEvent>();
// set state to track if user is allowed to use the composer
const [allowUseComposer, setAllowUseComposer] = useState(false);
const allowComposerRef = useRef(allowUseComposer);
allowComposerRef.current = allowUseComposer;
useEffect(() => {
// If composer is already placed, don't do anything
if (creatingCommentState === "complete") {
return;
}
// Place a composer on the screen
const newComment = (e: MouseEvent) => {
e.preventDefault();
// If already placed, click outside to close composer
if (creatingCommentState === "placed") {
// check if the click event is on/inside the composer
const isClickOnComposer = ((e as any)._savedComposedPath = e
.composedPath()
.some((el: any) => {
return el.classList?.contains("lb-composer-editor-actions");
}));
// if click is inisde/on composer, don't do anything
if (isClickOnComposer) {
return;
}
// if click is outside composer, close composer
if (!isClickOnComposer) {
setCreatingCommentState("complete");
return;
}
}
// First click sets composer down
setCreatingCommentState("placed");
setComposerCoords({
x: e.clientX,
y: e.clientY,
});
};
document.documentElement.addEventListener("click", newComment);
return () => {
document.documentElement.removeEventListener("click", newComment);
};
}, [creatingCommentState]);
useEffect(() => {
// If dragging composer, update position
const handlePointerMove = (e: PointerEvent) => {
// Prevents issue with composedPath getting removed
(e as any)._savedComposedPath = e.composedPath();
lastPointerEvent.current = e;
};
document.documentElement.addEventListener("pointermove", handlePointerMove);
return () => {
document.documentElement.removeEventListener(
"pointermove",
handlePointerMove
);
};
}, []);
// Set pointer event from last click on body for use later
useEffect(() => {
if (creatingCommentState !== "placing") {
return;
}
const handlePointerDown = (e: PointerEvent) => {
// if composer is already placed, don't do anything
if (allowComposerRef.current) {
return;
}
// Prevents issue with composedPath getting removed
(e as any)._savedComposedPath = e.composedPath();
lastPointerEvent.current = e;
setAllowUseComposer(true);
};
// Right click to cancel placing
const handleContextMenu = (e: Event) => {
if (creatingCommentState === "placing") {
e.preventDefault();
setCreatingCommentState("complete");
}
};
document.documentElement.addEventListener("pointerdown", handlePointerDown);
document.documentElement.addEventListener("contextmenu", handleContextMenu);
return () => {
document.documentElement.removeEventListener(
"pointerdown",
handlePointerDown
);
document.documentElement.removeEventListener(
"contextmenu",
handleContextMenu
);
};
}, [creatingCommentState]);
// On composer submit, create thread and reset state
const handleComposerSubmit = useCallback(
({ body }: ComposerSubmitComment, event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
event.stopPropagation();
// Get your canvas element
const overlayPanel = document.querySelector("#canvas");
// if there's no composer coords or last pointer event, meaning the user hasn't clicked yet, don't do anything
if (!composerCoords || !lastPointerEvent.current || !overlayPanel) {
return;
}
// Set coords relative to the top left of your canvas
const { top, left } = overlayPanel.getBoundingClientRect();
const x = composerCoords.x - left;
const y = composerCoords.y - top;
// create a new thread with the composer coords and cursor selectors
createThread({
body,
metadata: {
x,
y,
resolved: false,
zIndex: maxZIndex + 1,
},
});
setComposerCoords(null);
setCreatingCommentState("complete");
setAllowUseComposer(false);
},
[createThread, composerCoords, maxZIndex]
);
return (
<>
{/**
* Slot is used to wrap the children of the NewThread component
* to allow us to add a click event listener to the children
*
* Slot: https://www.radix-ui.com/primitives/docs/utilities/slot
*
* Disclaimer: We don't have to download this package specifically,
* it's already included when we install Shadcn
*/}
<Slot
onClick={() =>
setCreatingCommentState(
creatingCommentState !== "complete" ? "complete" : "placing"
)
}
style={{ opacity: creatingCommentState !== "complete" ? 0.7 : 1 }}
>
{children}
</Slot>
{/* if composer coords exist and we're placing a comment, render the composer */}
{composerCoords && creatingCommentState === "placed" ? (
/**
* Portal.Root is used to render the composer outside of the NewThread component to avoid z-index issuess
*
* Portal.Root: https://www.radix-ui.com/primitives/docs/utilities/portal
*/
<Portal.Root
className='absolute left-0 top-0'
style={{
pointerEvents: allowUseComposer ? "initial" : "none",
transform: `translate(${composerCoords.x}px, ${composerCoords.y}px)`,
}}
data-hide-cursors
>
<PinnedComposer onComposerSubmit={handleComposerSubmit} />
</Portal.Root>
) : null}
{/* Show the customizing cursor when placing a comment. The one with comment shape */}
<NewThreadCursor display={creatingCommentState === "placing"} />
</>
);
};
PinnedComposer
"use client";
import Image from "next/image";
import { Composer, ComposerProps } from "@liveblocks/react-comments";
type Props = {
onComposerSubmit: ComposerProps["onComposerSubmit"];
};
const PinnedComposer = ({ onComposerSubmit, ...props }: Props) => {
return (
<div className="absolute flex gap-4" {...props}>
<div className="select-none relative w-9 h-9 shadow rounded-tl-md rounded-tr-full rounded-br-full rounded-bl-full bg-white flex justify-center items-center">
<Image
src={`https://liveblocks.io/avatars/avatar-${Math.floor(Math.random() * 30)}.png`}
alt="someone"
width={28}
height={28}
className="rounded-full"
/>
</div>
<div className="shadow bg-white rounded-lg flex flex-col text-sm min-w-96 overflow-hidden p-2">
{/**
* We're using the Composer component to create a new comment.
* Liveblocks provides a Composer component that allows to
* create/edit/delete comments.
*
* Composer: https://liveblocks.io/docs/api-reference/liveblocks-react-comments#Composer
*/}
<Composer
onComposerSubmit={onComposerSubmit}
autoFocus={true}
onKeyUp={(e) => {
e.stopPropagation()
}}
/>
</div>
</div>
);
};
export default PinnedComposer;
NewThreadCursor
"use client";
import { useEffect, useState } from "react";
import * as Portal from "@radix-ui/react-portal";
const DEFAULT_CURSOR_POSITION = -10000;
// display a custom cursor when placing a new thread
const NewThreadCursor = ({ display }: { display: boolean }) => {
const [coords, setCoords] = useState({
x: DEFAULT_CURSOR_POSITION,
y: DEFAULT_CURSOR_POSITION,
});
useEffect(() => {
const updatePosition = (e: MouseEvent) => {
// get canvas element
const canvas = document.getElementById("canvas");
if (canvas) {
/**
* getBoundingClientRect returns the size of an element and its position relative to the viewport
*
* getBoundingClientRect: https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
*/
const canvasRect = canvas.getBoundingClientRect();
// check if the mouse is outside the canvas
// if so, hide the custom comment cursor
if (
e.clientX < canvasRect.left ||
e.clientX > canvasRect.right ||
e.clientY < canvasRect.top ||
e.clientY > canvasRect.bottom
) {
setCoords({
x: DEFAULT_CURSOR_POSITION,
y: DEFAULT_CURSOR_POSITION,
});
return;
}
}
// set the coordinates of the cursor
setCoords({
x: e.clientX,
y: e.clientY,
});
};
document.addEventListener("mousemove", updatePosition, false);
document.addEventListener("mouseenter", updatePosition, false);
return () => {
document.removeEventListener("mousemove", updatePosition);
document.removeEventListener("mouseenter", updatePosition);
};
}, []);
useEffect(() => {
if (display) {
document.documentElement.classList.add("hide-cursor");
} else {
document.documentElement.classList.remove("hide-cursor");
}
}, [display]);
if (!display) {
return null;
}
return (
// Portal.Root is used to render a component outside of its parent component
<Portal.Root>
<div
className="pointer-events-none fixed left-0 top-0 h-9 w-9 cursor-grab select-none rounded-bl-full rounded-br-full rounded-tl-md rounded-tr-full bg-white shadow-2xl"
style={{
transform: `translate(${coords.x}px, ${coords.y}px)`,
}}
/>
</Portal.Root>
);
};
export default NewThreadCursor;
CommentsOverlay
"use client";
import { useCallback, useRef } from "react";
import { ThreadData } from "@liveblocks/client";
import { ThreadMetadata, useEditThreadMetadata, useThreads, useUser } from "@/liveblocks.config";
import { useMaxZIndex } from "@/lib/useMaxZIndex";
import { PinnedThread } from "./PinnedThread";
type OverlayThreadProps = {
thread: ThreadData<ThreadMetadata>;
maxZIndex: number;
};
export const CommentsOverlay = () => {
/**
* We're using the useThreads hook to get the list of threads
* in the room.
*
* useThreads: https://liveblocks.io/docs/api-reference/liveblocks-react#useThreads
*/
const { threads } = useThreads();
// get the max z-index of a thread
const maxZIndex = useMaxZIndex();
return (
<div>
{threads
.filter((thread) => !thread.metadata.resolved)
.map((thread) => (
<OverlayThread key={thread.id} thread={thread} maxZIndex={maxZIndex} />
))}
</div>
);
};
const OverlayThread = ({ thread, maxZIndex }: OverlayThreadProps) => {
/**
* We're using the useEditThreadMetadata hook to edit the metadata
* of a thread.
*
* useEditThreadMetadata: https://liveblocks.io/docs/api-reference/liveblocks-react#useEditThreadMetadata
*/
const editThreadMetadata = useEditThreadMetadata();
/**
* We're using the useUser hook to get the user of the thread.
*
* useUser: https://liveblocks.io/docs/api-reference/liveblocks-react#useUser
*/
const { isLoading } = useUser(thread.comments[0].userId);
// We're using a ref to get the thread element to position it
const threadRef = useRef<HTMLDivElement>(null);
// If other thread(s) above, increase z-index on last element updated
const handleIncreaseZIndex = useCallback(() => {
if (maxZIndex === thread.metadata.zIndex) {
return;
}
// Update the z-index of the thread in the room
editThreadMetadata({
threadId: thread.id,
metadata: {
zIndex: maxZIndex + 1,
},
});
}, [thread, editThreadMetadata, maxZIndex]);
if (isLoading) {
return null;
}
return (
<div
ref={threadRef}
id={`thread-${thread.id}`}
className="absolute left-0 top-0 flex gap-5"
style={{
transform: `translate(${thread.metadata.x}px, ${thread.metadata.y}px)`,
}}
>
{/* render the thread */}
<PinnedThread thread={thread} onFocus={handleIncreaseZIndex} />
</div>
);
};
PinnedThread
"use client";
import Image from "next/image";
import { useMemo, useState } from "react";
import { ThreadData } from "@liveblocks/client";
import { Thread } from "@liveblocks/react-comments";
import { ThreadMetadata } from "@/liveblocks.config";
type Props = {
thread: ThreadData<ThreadMetadata>;
onFocus: (threadId: string) => void;
};
export const PinnedThread = ({ thread, onFocus, ...props }: Props) => {
// Open pinned threads that have just been created
const startMinimized = useMemo(
() => Number(new Date()) - Number(new Date(thread.createdAt)) > 100,
[thread]
);
const [minimized, setMinimized] = useState(startMinimized);
/**
* memoize the result of this function so that it doesn't change on every render but only when the thread changes
* Memo is used to optimize performance and avoid unnecessary re-renders.
*
* useMemo: https://react.dev/reference/react/useMemo
*/
const memoizedContent = useMemo(
() => (
<div
className='absolute flex cursor-pointer gap-4'
{...props}
onClick={(e: any) => {
onFocus(thread.id);
// check if click is on/in the composer
if (
e.target &&
e.target.classList.contains("lb-icon") &&
e.target.classList.contains("lb-button-icon")
) {
return;
}
setMinimized(!minimized);
}}
>
<div
className='relative flex h-9 w-9 select-none items-center justify-center rounded-bl-full rounded-br-full rounded-tl-md rounded-tr-full bg-white shadow'
data-draggable={true}
>
<Image
src={`https://liveblocks.io/avatars/avatar-${Math.floor(Math.random() * 30)}.png`}
alt='Dummy Name'
width={28}
height={28}
draggable={false}
className='rounded-full'
/>
</div>
{!minimized ? (
<div className='flex min-w-60 flex-col overflow-hidden rounded-lg bg-white text-sm shadow'>
<Thread
thread={thread}
indentCommentContent={false}
onKeyUp={(e) => {
e.stopPropagation();
}}
/>
</div>
) : null}
</div>
),
[thread.comments.length, minimized]
);
return <>{memoizedContent}</>;
};
通过 Next.js 14 专业课程提高你的技能
喜欢创建这个项目吗?深入了解我们的 PRO 课程,获得更丰富的学习体验。它们包含详细的解释、很酷的功能和练习,以提高你的技能。试一试吧!
通过专家培训计划加速你的职业之旅
如果你渴望的不仅仅是一门课程,还想了解我们如何学习和应对技术挑战,请参加我们的个性化大师班。我们涵盖最佳实践、不同的网络技能,并提供指导以增强你的信心。让我们一起学习,一起成长!