نحوه ایجاد یک بوم به سبک Figma / Miro با React و TypeScript
Miro و Figma ابزارهایی از نوع بوم مشارکتی آنلاین هستند که در طول همه گیری بسیار محبوب شدند.
بهجای چسباندن یادداشتهای آن بر روی یک دیوار فیزیکی، اکنون میتوانید پست مجازی آن (و مجموعهای گیجکننده از چیزهای دیگر) را به یک بوم مجازی اضافه کنید. این به تیمها اجازه میدهد تا بهطور مجازی به روشهایی همکاری کنند که از دنیای فیزیکی احساس آشنایی داشته باشند.
Figma و Miro محصولات بزرگ و بالغی هستند و HTML و CSS را به طور کامل دور می زنند. آنها از فناوریهایی مانند WebAssembly، WebGL، C++ و موارد مشابه استفاده میکنند، به دلیل الزامات عملکرد بسیار سخت.
اما میتوانیم با استفاده از React، TypeScript و چند بسته، آپشن های مشابهی از نوع بوم مجازی، بدون پیچیدگی ایجاد کنیم. ما قصد داریم از یک نوع "کارت" پشتیبانی کنیم، که فقط حاوی متن ساده است، تا این راهنما مختصر باشد، اما گسترش راه حل برای پشتیبانی از موارد استفاده دقیق تر آسان است.
عملکردی که ما قصد داریم پیاده سازی کنیم این است:
کشیدن کارت ها در اطراف بوم
اضافه کردن کارت های جدید به بوم
حرکت و بزرگنمایی بوم
این مقاله یک راهنمای گام به گام است که نحوه ایجاد این ویژگی ها را توضیح می دهد و کد همراه در GitHub با دموهای زنده وجود دارد.
راه حل ساخته شده خراش ما به سرعت Figma یا Miro نخواهد بود، اما اگر نیازهای شما ساده تر است، احتمالاً کافی است.
تحلیل اجمالی پروژه
ما از DndKit برای کشیدن و رها کردن و D3 Zoom برای حرکت و بزرگنمایی استفاده خواهیم کرد. کار کردن با هر دوی این ابزارها برایم لذت بخش بود.
کد در GitHub است و یک نسخه آزمایشی زنده وجود دارد تا بتوانید آن را امتحان کنید.
تنها 4 جزء ( App
، Canvas
، Draggable
و Addable
) و تنها حدود 250 خط کد وجود دارد.
این راهنما برای توسعه دهندگان React / TypeScript سطح متوسط طراحی شده است. اگر قبلاً کامپوننتها ، هوکها ، نحوه تفکر در React ، وضعیت ، refهای قابل تغییر و مواردی از این قبیل را درک کرده باشید، بسیار آسانتر خواهد بود.
خوب، اکنون برای اینکه ببینیم چگونه می توانید این را بسازید، بیایید وارد آن شویم.
مرحله 1 - نحوه کشیدن کارت ها در اطراف بوم
برای شروع، از DndKit برای کشیدن کارت ها در اطراف بوم استفاده می کنیم. ابزارهایی را که برای ساختن پروژه نیاز داریم نصب می کنیم، یک نوع Card
ساده ایجاد می کنیم و اجزای ساده App
، Canvas
و Draggable
را ایجاد می کنیم.
مؤلفه App
وضعیت card
را ذخیره می کند و Canvas
را ارائه می دهد.
مولفه Canvas
با DndKit یکپارچه می شود، وضعیت card
را به روز می کند و cards
به عنوان اجزای Draggable
تبدیل می کند.
مولفه Draggable
با DndKit ادغام می شود و از استایل CSS برای قرارگیری صحیح خود بر روی بوم استفاده می کند.
در اینجا تصویری از آنچه که ما در این بخش خواهیم ساخت ارائه شده است، و همچنین یک نسخه نمایشی زنده وجود دارد که می توانید خودتان آن را امتحان کنید:
راه اندازی پروژه
ما یک پروژه جدید با Vite ایجاد می کنیم و DndKit را با استفاده از دستورات زیر نصب می کنیم:
npm create vite@latest figma-miro-canvas -- --template react-ts npm install npm install @dnd-kit/core
App.tsx
مؤلفه App
وضعیت card
را ذخیره می کند و Canvas
را ارائه می دهد.
ما یک نوع Card
اضافه می کنیم، در این دمو کارت ها به سادگی حاوی متنی خواهند بود. UniqueIdentifier
از DndKit است و ترسناک به نظر می رسد، اما فقط یک String | number
. Coordinates
نیز از DndKit می آید و حاوی موقعیت x و y است.
export type Card = { id: UniqueIdentifier; coordinates: Coordinates; text: string; };
سپس باید چند کارت ایجاد کنیم و آنها را به بوم منتقل کنیم.
export const App = () => { const [cards, setCards] = useState<Card[]>([ { id: "Hello", coordinates: { x: 0, y: 0 }, text: "Hello" }, ]); return (<Canvas cards={cards} />); }
Canvas.tsx
مولفه Canvas
با DndKit یکپارچه می شود، وضعیت card
را به روز می کند و cards
به عنوان اجزای Draggable
تبدیل می کند.
cards
و setCards
بهعنوان لوازم جانبی میگیرد، زیرا حالت در بالای درخت در App
ذخیره میشود. این در حال حاضر به شدت ضروری نیست، اما در مراحل بعدی مفید است.
type Props = { cards: Card[]; setCards: (cards: Card[]) => void; }
ما تابعی را برای فراخوانی setCards
با به روز رسانی پس از اتمام عملیات کشیدن اضافه می کنیم. این به سادگی فاصله کشیدن / دلتا را به مقادیر x و y برای کارتی که در حال کشیدن است اضافه می کند.
DragEndEvent
از DndKit می آید و شامل آیتم active
در حال کشیدن می شود، پس ما می توانیم از آن برای تحلیل اینکه کدام card
را به روز کنیم استفاده کنیم.
const updateDraggedCardPosition = ({ delta, active }: DragEndEvent) => { if (!delta.x && !delta.y) return; setCards( cards.map((card) => { if (card.id === active.id) { return { ...card, coordinates: { x: card.coordinates.x + delta.x, y: card.coordinates.y + delta.y, }, }; } return card; }) ); };
یک div
برای نمایش بوم، یک DndContext
(از DndKit) اضافه می کنیم و برای هر کارت یک Draggable
ارائه می کنیم. ما تابع جدید خود را با رویداد onDragEnd
از DndContext
متصل می کنیم تا وضعیت card
پس از یک عملیات کشیدن موفقیت آمیز به روز شود.
<div className="canvas"> <DndContext onDragEnd={updateDraggedCardPosition}> {cards.map((card) => ( <Draggable card={card} key={card.id} /> ))} </DndContext> </div>
Draggable.tsx
مولفه Draggable
با DndKit ادغام می شود و از استایل CSS ( position
، top
و left
) استفاده می کند تا خود را به درستی روی بوم قرار دهد.
useDraggable
از DndKit میآید، و ما کورکورانه attributes
، listeners
و setNodeRef
را که باز میگرداند به div
ما ارسال میکنیم، که به آن اجازه میدهد به رویدادهای onClick
و مواردی از این قبیل پاسخ دهد.
ما همچنین از transform
DndKit برای اعمال CSS استفاده می کنیم تا کارت را در موقعیتی تغییر یافته در زمانی که کشیدن در حال انجام است، ارائه دهیم. کمی گیج کننده، نام ویژگی CSS نیز transform
نامیده می شود.
export const Draggable = ({ card }: { card: Card }) => { // hook up to DndKit const { attributes, listeners, setNodeRef, transform } = useDraggable({ id: card.id, }); return ( <div className="card" style={{ // position card on canvas position: "absolute", top: `${card.coordinates.y}px`, left: `${card.coordinates.x}px`, // temporary change to this position when dragging ...(transform ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0px)`, } : {}), }} ref={setNodeRef} {...listeners} {...attributes} > {card.text} </div> ); };
App.css
در نهایت میتوانیم یک استایل CSS اضافه کنیم تا همه چیز بهطور مبهم قابل قبول به نظر برسد. این کاملاً ضروری نیست، اما حتی با وجود مهارت های طراحی محدود من، بسیار بهتر به نظر می رسد.
مرحله 2 - نحوه اضافه کردن کارت های جدید به بوم
در این مرحله، یک کامپوننت Addable
جدید ایجاد میکنیم، تا کارتهایی را نشان دهد که در حال حاضر روی بوم نیستند، اما میتوان آنها را روی آن کشید.
ما App
بهروزرسانی میکنیم تا یک div
"سینی" اضافه کنیم تا حاوی این کارتهای Addable
جدید باشد. ما همچنین یک DndContext دیگر اضافه میکنیم (این DndContext
جدید با کشیدن کشیدن از سینی به بوم، و DndContext
موجود در Canvas
، کشیدن کارتها را در اطراف بوم کنترل میکند)، و به رویدادهای آن متصل میشود. این به ما اجازه میدهد تا زمانی که کارتهای Addable
روی بوم کشیده میشوند، وضعیت را بهروزرسانی کنیم.
ما Canvas
بهروزرسانی میکنیم تا آن را به یک هدف DndKit تبدیل کنیم تا کارتهای Addable
روی آن رها شوند.
در اینجا عملکردی است که در این بخش اضافه خواهیم کرد، و همچنین یک نسخه آزمایشی زنده وجود دارد که می توانید خودتان آن را امتحان کنید:
App.tsx
مؤلفه App
اکنون به یک div
«سینی» نیاز دارد تا حاوی کارتهای Addable
باشد.
همچنین به DndContext دیگری نیاز دارد (این DndContext
جدید کشیدن کشیدن از سینی به بوم را کنترل می کند، و DndContext
موجود در Canvas
کشیدن کارت ها را در اطراف بوم کنترل می کند). باید به رویدادهای خود متصل شود تا زمانی که کارتهای Addable
روی بوم کشیده میشوند، وضعیت را بهروزرسانی کنیم.
ما فهرست ی از کارت ها را اضافه می کنیم تا در سینی ظاهر شوند:
const trayCards = [ // the coordinates aren't used for the tray cards, we could create a new type without them { id: "World", coordinates: { x: 0, y: 0 }, text: "World" }, ];
ما یک تابع اضافه می کنیم تا موقعیت روی بوم را در پایان عملیات کشیدن دراپ مشخص کنیم. این باید موقعیت اولیه کارت، فاصله کشیدن / دلتا و موقعیت بالای سمت چپ بوم را بداند. همه این جزئیات توسط DndKit DragEndEvent
ارائه شده است.
const calculateCanvasPosition = ( initialRect: ClientRect, over: Over, delta: Translate ): Coordinates => ({ x: initialRect.left + delta.x - (over?.rect?.left ?? 0), y: initialRect.top + delta.y - (over?.rect?.top ?? 0), });
حالت را برای ذخیره کارت سینی در حال کشیدن به همراه عملکردی برای به روز رسانی وضعیت cards
پس از کشیدن / رها کردن از سینی اضافه می کنیم. DragEndEvent
از DndKit می آید و شامل آیتم active
در حال کشیدن می شود، پس می توانیم از آن برای ایجاد یک card
جدید و اضافه کردن آن به آرایه cards
استفاده کنیم. ما همچنین تحلیل هایی را انجام می دهیم تا مطمئن شویم که "بوم" هدف رها شده است و ما تمام داده های مورد نیاز را داریم.
const [draggedTrayCardId, setDraggedTrayCardId] = useState<UniqueIdentifier | null>(null); const addDraggedTrayCardToCanvas = ({ over, active, delta }: DragEndEvent) => { setDraggedTrayCardId(null); if (over?.id !== "canvas") return; if (!active.rect.current.initial) return; setCards([ ...cards, { id: active.id, coordinates: calculateCanvasPosition( active.rect.current.initial, over, delta ), text: active.id.toString(), }, ]); };
ما DndContext
جدید، اضافی، یک div
برای نمایش سینی و یک DragOverlay
اضافه می کنیم.
کامپوننت DragOverlay
از DndKit می آید و ما کارت سینی را که داخل آن کشیده می شود رندر می کنیم. کار سخت نشان دادن کارت سینی را در حالی که در حال کشیدن است انجام می دهد. استفاده از آن با کشیدن / رها کردن بسیار راحت است، اما در هنگام کشیدن به اندازه کافی مفید نیست، به همین دلیل است که ما قبلاً هنگام کشیدن کارت ها در اطراف بوم از آن استفاده نکردیم.
<DndContext onDragStart={({ active }) => setDraggedTrayCardId(active.id)} onDragEnd={addDraggedTrayCardToCanvas} > <div className="tray"> {trayCards.map((trayCard) => { // this line removes the card from the tray if it's on the canvas if (cards.find((card) => card.id === trayCard.id)) return null; return <Addable card={trayCard} key={trayCard.id} />; })} </div> <Canvas cards={cards} setCards={setCards} /> <DragOverlay> {/* this works because the id of the card is the same as the text in this example so we can just render the id inside a div. In more complex cases you would have a component to render the card, and use that here. */} <div className="trayOverlayCard">{draggedTrayCardId}</div> </DragOverlay> </DndContext>
Addable.tsx
جزء Addable
با DndKit ادغام می شود و برای نشان دادن کارت هایی استفاده می شود که در حال حاضر روی بوم نیستند، اما می توان آنها را روی آن کشید.
کامپوننت را با DndKit متصل می کنیم و متن کارت را در یک div
رندر می کنیم. useDraggable
از DndKit میآید، و ما کورکورانه attributes
، listeners
و setNodeRef
را که برمیگرداند به div
ما ارسال میکنیم. این به آن اجازه می دهد تا به رویدادهای onClick
و مواردی از این قبیل پاسخ دهد.
export const Addable = ({ card } : { card: Card; }) => { const { attributes, listeners, setNodeRef } = useDraggable({ card.id, }); return ( <div className="trayCard" ref={setNodeRef} {...listeners} {...attributes}> {card.text} </div> ); };
Canvas.tsx
Canvas
اکنون باید با DndKit ادغام شود تا آن را به یک هدف رهاسازی تبدیل کند تا بتوان کارت های Addable
را روی آن انداخت.
ما بوم را به عنوان یک هدف در حال سقوط با DndKit متصل می کنیم. useDroppable
از DndKit می آید، و ما فقط ref را به div
خود منتقل می کنیم. این به این دلیل است که DndKit بتواند آن را شناسایی کند و زمانی که روی آن کشیده می شود، id
آن را به دست آورد.
const { setNodeRef } = useDroppable({ id: "canvas" });
<div className="canvas" ref={setNodeRef} ... > ... </div>
مرحله 3 - نحوه حرکت به اطراف و بزرگنمایی و بزرگنمایی از روی بوم
در این مرحله نهایی، d3-zoom را نصب می کنیم، آن را به بوم متصل می کنیم و سپس برخی از محاسبات و استایل ها را به روز می کنیم تا همه چیز در جای درست ظاهر شود.
ما App
را به روز می کنیم تا transform
(پان و بزرگنمایی بوم) را از d3-zoom ذخیره کنیم و سبک DragOverlay
و calculateCanvasPosition
را برای در نظر گرفتن transform
به روز می کنیم.
ما Canvas
بهروزرسانی میکنیم تا با d3-zoom ادغام شود و از استایل CSS برای در نظر گرفتن transform
استفاده کنیم.
ما Draggable
با استفاده از استایل CSS بهروزرسانی میکنیم تا transform
در نظر بگیریم، چه در حالت ثابت و چه در حین کشیدن.
d3-zoom رویدادهای ماوس و اشاره گر را برای حرکت و بزرگنمایی به صورت خودکار کنترل می کند، پس نیازی به اضافه کردن کدی برای آن نداریم (اما اگر می خواهید دکمه "Zoom In" یا موارد مشابه را اضافه کنید، انجام این کار آسان است).
در اینجا چیزی است که ما در این بخش می سازیم، و همچنین یک نسخه نمایشی زنده وجود دارد که می توانید خودتان آن را امتحان کنید:
قبل از ادامه، مطمئن شوید که d3-zoom را نصب کرده اید:
npm install d3-zoom npm install --save-dev @types/d3-zoom
App.tsx
اکنون App
باید transform
(پان و بزرگنمایی بوم) را از d3-zoom ذخیره کند و سبک DragOverlay
و calculateCanvasPosition
را برای در نظر گرفتن transform
به روز کند.
ما transform
فعلی را از d3-zoom ذخیره می کنیم. این هم پان ( transform.x
و transform.y
) و هم بزرگنمایی ( transform.k
) را نشان میدهد.
const [transform, setTransform] = useState(zoomIdentity);
ما CSS را به DragOverlay
اضافه می کنیم، به طوری که کارت هایی که از سینی کشیده می شوند به همان اندازه ای باشند که روی بوم هستند.
style={{ transformOrigin: "top left", transform: `scale(${transform.k})`, }}
تابع accountCanvasPosition را به روز می کنیم زیرا اکنون باید بزرگنمایی بوم ( transform.k
) و همچنین موقعیت بالا سمت چپ را در نظر بگیرد.
const calculateCanvasPosition = ( initialRect: ClientRect, over: Over, delta: Translate, transform: ZoomTransform ): Coordinates => ({ x: (initialRect.left + delta.x - (over?.rect?.left ?? 0) - transform.x) / transform.k, y: (initialRect.top + delta.y - (over?.rect?.top ?? 0) - transform.y) / transform.k, });
Canvas.tsx
Canvas
اکنون باید با d3-zoom ادغام شود و از استایل CSS برای در نظر گرفتن transform
از d3-zoom استفاده کند.
ما props را برای transform
و setTransform
اضافه می کنیم (اینها را از App.tsx منتقل می کنیم).
type Props = { cards: Card[]; setCards: (cards: Card[]) => void; transform: ZoomTransform; setTransform(transform: ZoomTransform): void; }
ما d3-zoom را وصل می کنیم. هم DndKit و هم d3-zoom به یک مرجع برای عنصر نیاز دارند، پس ما canvasRef
و updateAndForwardRef
را ایجاد می کنیم که به هر دوی آنها اجازه می دهد به یک HTMLDivElement
اشاره کنند.
d3-zoom یک کتابخانه جاوا اسکریپت است، نه یک مؤلفه React، به همین دلیل است که ما باید از کدهای باطنی زیر استفاده کنیم، مانند useMemo
و useLayoutEffect
(البته هر دوی اینها را تقریباً در هر پایگاه کد React با اندازه معقول مشاهده خواهید کرد).
const canvasRef = useRef<HTMLDivElement | null>(null); const updateAndForwardRef = (div: HTMLDivElement) => { canvasRef.current = div; setNodeRef(div); }; // create the d3 zoom object, and useMemo to retain it for rerenders const zoomBehavior = useMemo(() => zoom<HTMLDivElement, unknown>(), []); // update the transform when d3 zoom notifies of a change. const updateTransform = useCallback( ({ transform }: { transform: ZoomTransform }) => { setTransform(transform); }, [setTransform] ); useLayoutEffect(() => { if (!canvasRef.current) return; // get transform change notifications from d3 zoom zoomBehavior.on("zoom", updateTransform); // attach d3 zoom to the canvas div element, which will handle // mousewheel, gesture and drag events automatically for pan / zoom select<HTMLDivElement, unknown>(canvasRef.current).call(zoomBehavior); }, [zoomBehavior, canvasRef, updateTransform] );
ما یک بسته بندی / پنجره در اطراف بوم اضافه می کنیم. Div بوم اکنون حرکت می کند و بزرگنمایی می کند ( پس به مقدار زیادی در صفحه حرکت می کند)، پس آن را در یک div دیگری با موقعیت و اندازه ثابت قرار می دهیم و هر سرریزی را پنهان می کنیم، به طوری که یک "پنجره" داشته باشیم که قسمت مربوطه را نشان می دهد. بوم
همچنین سبکهای CSS را به بوم اضافه میکنیم تا پان و بزرگنمایی را در نظر بگیریم، از تابع جدید updateAndForwardRef
استفاده میکنیم و ref
را از بوم به پنجره بوم منتقل میکنیم.
<div ref={updateAndForwardRef} className="canvasWindow"> <div className="canvas" style={{ // apply the transform from d3 transformOrigin: "top left", transform: `translate3d(${transform.x}px, ${transform.y}px, ${transform.k}px)`, position: "relative", height: "300px", }} > ... </div> </div>
Draggable.tsx
اکنون Draggable
به استایل CSS متفاوتی نیاز دارد تا transform
d3-zoom را در نظر بگیرد، چه در حالت ثابت و چه در حین کشیدن.
ما یک پایه برای تبدیل d3-zoom اضافه می کنیم که آن را canvasTransform
می نامیم، زیرا در حال حاضر از یک متغیر transform
از DndKit استفاده می کنیم.
type Props = { card: Card; canvasTransform: ZoomTransform; }
ما CSS را بهروزرسانی میکنیم تا پانل و بزرگنمایی بوم را در نظر بگیرد. ما باید به دو مورد رسیدگی کنیم، هم زمانی که در حال کشیدن است و هم زمانی که نیست.
style={{ position: "absolute", top: `${card.coordinates.y * canvasTransform.k}px`, left: `${card.coordinates.x * canvasTransform.k}px`, transformOrigin: "top left", ...(transform ? { // temporary change to this position when dragging transform: `translate3d(${transform.x}px, ${transform.y}px, 0px) scale(${canvasTransform.k})`, } : { // zoom to canvas zoom transform: `scale(${canvasTransform.k})`, }), }}
ما همچنین حباب کردن رویداد onPointerDown
را به سمت بوم متوقف می کنیم، در غیر این صورت توسط d3-zoom مدیریت می شود و به عنوان یک درخواست برای شروع کشیدن تفسیر می شود، که منجر به کشیدن بوم و کارت به طور همزمان می شود (یک اثر جالب اما نامطلوب !)
onPointerDown={(e) => { listeners?.onPointerDown?.(e); e.preventDefault(); }}
نتیجه
مقداری پیچیدگی در محاسبات مختلف موقعیت / تبدیل وجود دارد، اما خیلی هم دیوانه کننده نیست و فقط دو وابستگی برای نصب وجود دارد.
تنها چهار مؤلفه ( App
، Canvas
، Draggable
و Addable
) و تنها حدود 250 خط کد برای همه آنها وجود دارد که به نظر می رسد مقدار بسیار کمی برای همه عملکردها باشد.
این نسخه ی نمایشی بسیار ساده است، اما شامل بسیاری از قابلیت های بوم مجازی است، و استفاده از آن به عنوان پایه و ساختن چیزهای پیچیده تر در بالا آسان است.
ارسال نظر