Weave.js

Room page

Learn how to set up the Room page on Next.js

In this guide, we will build the Room page:

Room page

This page renders the collaborative canvas once a user joins the room.

Prerequisites

Before you begin, ensure that you have completed Set up the Enter Room page.

Step by step

To set up the Room page in your Next.js project, follow these steps:

Define the page component

To set up the Room page component, follow these steps:

  • On the app folder, create a folder named room.

  • On the app/room folder, create a folder named [roomId].

  • On the app/room/[roomId] folder, create a file named page.tsx with the following content:

    app/room/[roomId]/page.tsx
    import { Room } from "@/components/room/room";
    
    export default function RoomPage() {
      return <Room />;
    }

Install the UI building block components

Set up the necessary Shadcn/UI components for the Room page.

We will be using the Shadcn/UI CLI to install the UI components. Accept all the defaults from the CLI options.

In the root directory of your project, follow these steps:

  • Install the Tooltip component:

    npx shadcn@latest add tooltip
  • Install the Dropdown Menu component:

    npx shadcn@latest add dropdown-menu
  • Install the Avatar component:

    npx shadcn@latest add avatar

Define a constants file

Let's define a set of constants that will define the Weave.js Nodes, Plugins, and Actions that we will use.

Follow these steps:

  • On the components folder, create a folder named utils.

  • On the components/utils folder, create a file named constants.ts with the following content:

    components/utils/constants.ts
    import {
      WeaveMoveToolAction,
      WeaveSelectionToolAction,
      WeaveRectangleToolAction,
      WeaveZoomOutToolAction,
      WeaveZoomInToolAction,
      WeaveExportNodeToolAction,
      WeaveExportStageToolAction,
      WeaveFitToScreenToolAction,
      WeaveFitToSelectionToolAction,
      WeaveNodesSnappingPlugin,
      WeaveStageNode,
      WeaveLayerNode,
      WeaveGroupNode,
      WeaveRectangleNode,
    } from "@inditextech/weave-sdk"; // (1)
    
    const FONTS = [
      {
        id: "Arial",
        name: "Arial, sans-serif",
      },
      {
        id: "Helvetica",
        name: "Helvetica, sans-serif",
      },
      {
        id: "TimesNewRoman",
        name: "Times New Roman, serif",
      },
    ]; // (2)
    
    const NODES = [
      new WeaveStageNode(),
      new WeaveLayerNode(),
      new WeaveGroupNode(),
      new WeaveRectangleNode(),
    ]; // (3)
    
    const ACTIONS = [
      new WeaveMoveToolAction(),
      new WeaveSelectionToolAction(),
      new WeaveRectangleToolAction(),
      new WeaveZoomOutToolAction(),
      new WeaveZoomInToolAction(),
      new WeaveFitToScreenToolAction(),
      new WeaveFitToSelectionToolAction(),
      new WeaveExportNodeToolAction(),
      new WeaveExportStageToolAction(),
    ]; // (4)
    
    const CUSTOM_PLUGINS = [new WeaveNodesSnappingPlugin()]; // (5)
    
    export { FONTS, NODES, ACTIONS, CUSTOM_PLUGINS };

    File explanation:

    • (1): Import the Weave.js SDK dependency.
    • (2): Set up the fonts available on Weave.js.
    • (3): Define the nodes we will use in this example.
    • (4): Define the actions we will use in this example.
    • (5): Define the custom plugins we will use in this example. By default the WeaveProvider pre-defines a set of plugins. In this case, we can override them or add extra plugins.

Define the page React hooks

Let's define the hooks with the logic for the Room page. This way, we separate the component logic from the UI logic.

To start, create a hooks folder on the root directory.


useHandleRouteParams hook

On the hooks folder, create a file named use-handle-route-params.ts with the following content:

hooks/use-handle-route-params.ts
import { useCollaborationRoom } from "@/store/store";
import { useParams, useSearchParams } from "next/navigation";
import React from "react";

function useHandleRouteParams() {
  const [loadedParams, setLoadedParams] = React.useState(false);
  const setRoom = useCollaborationRoom((state) => state.setRoom);
  const params = useParams<{ roomId: string }>();
  const searchParams = useSearchParams();
  const setUser = useCollaborationRoom((state) => state.setUser);

  React.useEffect(() => {
    const roomId = params.roomId;
    const userName = searchParams.get("userName");
    if (roomId && userName) {
      setRoom(roomId);
      setUser({
        name: userName,
        email: `${userName}@weave.js`,
      });
    }
    setLoadedParams(true);
  }, [params.roomId, searchParams, setRoom, setUser]);

  return {
    loadedParams,
  }
}

export default useHandleRouteParams;

This hook helps us handling the route params with Next.js to extract the roomId where we want to connect to.


useGetWebsocketsStore hook

On the hooks folder, create a file named use-get-websockets-store.ts with the following content:

hooks/use-get-websockets-store.ts
import { useCollaborationRoom } from "@/store/store";
import { useWeave } from "@inditextech/weave-react";
import { WeaveUser } from "@inditextech/weave-types";
import {
  WeaveStoreWebsockets,
  WeaveStoreWebsocketsConnectionStatus,
} from "@inditextech/weave-store-websockets/client";
import React from "react";

function useGetWebsocketsStore({
  loadedParams,
  getUser,
}: {
  loadedParams: boolean;
  getUser: () => WeaveUser;
}) {
  const room = useCollaborationRoom((state) => state.room);
  const setConnectionStatus = useWeave((state) => state.setConnectionStatus);

  const onConnectionStatusChangeHandler = React.useCallback(
    (status: WeaveStoreWebsocketsConnectionStatus) => {
      setConnectionStatus(status);
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );

  const websocketStore = React.useMemo(() => {
    if (loadedParams && room) {
      return new WeaveStoreWebsockets(
        {
          getUser,
          undoManagerOptions: {
            captureTimeout: 500,
          },
        },
        {
          roomId: room,
          wsOptions: {
            serverUrl: `http://localhost:3000/sync/rooms`,
          },
          callbacks: {
            onConnectionStatusChange: onConnectionStatusChangeHandler,
          },
        }
      );
    }

    return null;
  }, [getUser, loadedParams, onConnectionStatusChangeHandler, room]);

  return websocketStore;
}

export default useGetWebsocketsStore;

This hook is a helper to setup the Websocket Store of Weave.js.


useContextMenu

On the hooks folder, create a file named use-context-menu.tsx with the following content:

hooks/use-context-menu.tsx
import {
  Weave,
  WeaveContextMenuPlugin,
  WeaveCopyPasteNodesPlugin,
  WeaveExportNodeActionParams,
} from "@inditextech/weave-sdk";
import { WeaveSelection } from "@inditextech/weave-types";
import {
  Copy,
  ClipboardCopy,
  ClipboardPaste,
  Group,
  Ungroup,
  Trash,
  SendToBack,
  BringToFront,
  ArrowUp,
  ArrowDown,
  ImageDown,
} from "lucide-react";
import { useCollaborationRoom } from "@/store/store";
import React from "react";
import { ContextMenuOption } from "@/components/context-menu/context-menu";

function useContextMenu() {
  const setContextMenuShow = useCollaborationRoom(
    (state) => state.setContextMenuShow
  );
  const setContextMenuPosition = useCollaborationRoom(
    (state) => state.setContextMenuPosition
  );
  const setContextMenuOptions = useCollaborationRoom(
    (state) => state.setContextMenuOptions
  );

  const getContextMenu = React.useCallback(
    ({
      actInstance,
      actActionActive,
      canUnGroup,
      nodes,
      canGroup,
    }: {
      actInstance: Weave;
      actActionActive: string | undefined;
      canUnGroup: boolean;
      canGroup: boolean;
      nodes: WeaveSelection[];
    }): ContextMenuOption[] => {
      const options: ContextMenuOption[] = [];

      if (nodes.length > 0) {
        // DUPLICATE
        options.push({
          id: "duplicate",
          type: "button",
          label: (
            <div className="w-full flex justify-between items-center">
              <div>Duplicate</div>
            </div>
          ),
          icon: <Copy size={16} />,
          disabled: nodes.length > 1,
          onClick: async () => {
            if (nodes.length === 1) {
              const weaveCopyPasteNodesPlugin =
                actInstance.getPlugin<WeaveCopyPasteNodesPlugin>(
                  "copyPasteNodes"
                );
              if (weaveCopyPasteNodesPlugin) {
                await weaveCopyPasteNodesPlugin.copy();
                weaveCopyPasteNodesPlugin.paste();
              }
              setContextMenuShow(false);
            }
          },
        });
      }
      if (nodes.length > 0) {
        // SEPARATOR
        options.push({
          id: "div--1",
          type: "divider",
        });
      }
      if (nodes.length > 0) {
        // EXPORT
        options.push({
          id: "export",
          type: "button",
          label: (
            <div className="w-full flex justify-between items-center">
              <div>Export as image</div>
            </div>
          ),
          icon: <ImageDown size={16} />,
          disabled: nodes.length > 1,
          onClick: () => {
            if (nodes.length === 1) {
              actInstance.triggerAction<WeaveExportNodeActionParams>(
                "exportNodeTool",
                {
                  node: nodes[0].instance,
                  options: {
                    padding: 20,
                    pixelRatio: 2,
                  },
                }
              );
            }
            setContextMenuShow(false);
          },
        });
      }
      if (nodes.length > 0) {
        // SEPARATOR
        options.push({
          id: "div-0",
          type: "divider",
        });
        // COPY
      }
      // COPY
      options.push({
        id: "copy",
        type: "button",
        label: (
          <div className="w-full flex justify-between items-center">
            <div>Copy</div>
          </div>
        ),
        icon: <ClipboardCopy size={16} />,
        disabled: !["selectionTool"].includes(actActionActive ?? ""),
        onClick: async () => {
          const weaveCopyPasteNodesPlugin =
            actInstance.getPlugin<WeaveCopyPasteNodesPlugin>("copyPasteNodes");
          if (weaveCopyPasteNodesPlugin) {
            await weaveCopyPasteNodesPlugin.copy();
          }
          setContextMenuShow(false);
        },
      });
      // PASTE
      options.push({
        id: "paste",
        type: "button",
        label: (
          <div className="w-full flex justify-between items-center">
            <div>Paste</div>
          </div>
        ),
        icon: <ClipboardPaste size={16} />,
        disabled: !["selectionTool"].includes(actActionActive ?? ""),
        onClick: () => {
          const weaveCopyPasteNodesPlugin =
            actInstance.getPlugin<WeaveCopyPasteNodesPlugin>("copyPasteNodes");
          if (weaveCopyPasteNodesPlugin) {
            return weaveCopyPasteNodesPlugin.paste();
          }
          setContextMenuShow(false);
        },
      });
      if (nodes.length > 0) {
        // SEPARATOR
        options.push({
          id: "div-1",
          type: "divider",
        });
      }
      if (nodes.length > 0) {
        // BRING TO FRONT
        options.push({
          id: "bring-to-front",
          type: "button",
          label: (
            <div className="w-full flex justify-between items-center">
              <div>Bring to front</div>
            </div>
          ),
          icon: <BringToFront size={16} />,
          disabled: nodes.length !== 1,
          onClick: () => {
            actInstance.bringToFront(nodes[0].instance);
            setContextMenuShow(false);
          },
        });
        // MOVE UP
        options.push({
          id: "move-up",
          type: "button",
          label: (
            <div className="w-full flex justify-between items-center">
              <div>Move up</div>
            </div>
          ),
          icon: <ArrowUp size={16} />,
          disabled: nodes.length !== 1,
          onClick: () => {
            actInstance.moveUp(nodes[0].instance);
            setContextMenuShow(false);
          },
        });
        // MOVE DOWN
        options.push({
          id: "move-down",
          type: "button",
          label: (
            <div className="w-full flex justify-between items-center">
              <div>Move down</div>
            </div>
          ),
          icon: <ArrowDown size={16} />,
          disabled: nodes.length !== 1,
          onClick: () => {
            actInstance.moveDown(nodes[0].instance);
            setContextMenuShow(false);
          },
        });
        // SEND TO BACK
        options.push({
          id: "send-to-back",
          type: "button",
          label: (
            <div className="w-full flex justify-between items-center">
              <div>Send to back</div>
            </div>
          ),
          icon: <SendToBack size={16} />,
          disabled: nodes.length !== 1,
          onClick: () => {
            actInstance.sendToBack(nodes[0].instance);
            setContextMenuShow(false);
          },
        });
      }
      if (nodes.length > 0) {
        options.push({
          id: "div-2",
          type: "divider",
        });
      }
      if (nodes.length > 0) {
        // GROUP
        options.push({
          id: "group",
          type: "button",
          label: (
            <div className="w-full flex justify-between items-center">
              <div>Group</div>
            </div>
          ),
          icon: <Group size={16} />,
          disabled: !canGroup,
          onClick: () => {
            actInstance.group(nodes.map((n) => n.node));
            setContextMenuShow(false);
          },
        });
        // UNGROUP
        options.push({
          id: "ungroup",
          type: "button",
          label: (
            <div className="w-full flex justify-between items-center">
              <div>Un-group</div>
            </div>
          ),
          icon: <Ungroup size={16} />,
          disabled: !canUnGroup,
          onClick: () => {
            actInstance.unGroup(nodes[0].node);
            setContextMenuShow(false);
          },
        });
      }
      if (nodes.length > 0) {
        // SEPARATOR
        options.push({
          id: "div-3",
          type: "divider",
        });
      }
      if (nodes.length > 0) {
        // DELETE
        options.push({
          id: "delete",
          type: "button",
          label: (
            <div className="w-full flex justify-between items-center">
              <div>Delete</div>
            </div>
          ),
          icon: <Trash size={16} />,
          onClick: () => {
            for (const node of nodes) {
              actInstance.removeNode(node.node);
            }

            setContextMenuShow(false);
          },
        });
      }

      return options;
    },
    [setContextMenuShow]
  );

  const onNodeMenu = React.useCallback(
    (
      actInstance: Weave,
      nodes: WeaveSelection[],
      point: { x: number; y: number },
      visible: boolean
    ) => {
      const canGroup = nodes.length > 1;
      const canUnGroup = nodes.length === 1 && nodes[0].node.type === "group";

      const actActionActive = actInstance.getActiveAction();

      setContextMenuShow(visible);
      setContextMenuPosition(point);

      const contextMenu = getContextMenu({
        actInstance,
        actActionActive,
        canUnGroup,
        nodes,
        canGroup,
      });
      setContextMenuOptions(contextMenu);
    },
    [
      getContextMenu,
      setContextMenuOptions,
      setContextMenuPosition,
      setContextMenuShow,
    ]
  );

  const contextMenu = React.useMemo(
    () =>
      new WeaveContextMenuPlugin(
        {
          xOffset: 10,
          yOffset: 10,
        },
        {
          onNodeMenu,
        }
      ),
    [onNodeMenu]
  );

  return { contextMenu };
}

export default useContextMenu;

Define the page React components

Set up the following directories to organize our components:

  • On the components folder, create a room folder.
  • On the components folder, create an overlays folder.
  • On the components folder, create a context-menu folder.

Room

On the components/room folder, create a file named room.tsx with the following content:

components/room/room.tsx
"use client";

import React from "react";
import { useRouter } from "next/navigation";
import { WeaveUser, WEAVE_INSTANCE_STATUS } from "@inditextech/weave-types";
import { useCollaborationRoom } from "@/store/store";
import { useWeave, WeaveProvider } from "@inditextech/weave-react";
import { RoomLayout } from "./room.layout";
import { RoomLoader } from "./room.loader";
import useGetWebsocketsStore from "@/hooks/use-get-websockets-store";
import useHandleRouteParams from "@/hooks/use-handle-route-params";
import {
  FONTS,
  NODES,
  ACTIONS,
  CUSTOM_PLUGINS,
} from "@/components/utils/constants";

const statusMap = {
  ["idle"]: "Idle",
  ["starting"]: "Starting...",
  ["loadingFonts"]: "Fetching custom fonts...",
  ["running"]: "Running",
};

export const Room = () => {
  const router = useRouter();

  const status = useWeave((state) => state.status);

  const room = useCollaborationRoom((state) => state.room);
  const user = useCollaborationRoom((state) => state.user);

  const { loadedParams } = useHandleRouteParams();

  const getUser = React.useCallback(() => {
    return user as WeaveUser;
  }, [user]);

  const loadingDescription = React.useMemo(() => {
    if (!loadedParams) {
      return "Fetching room parameters...";
    }
    if (status !== WEAVE_INSTANCE_STATUS.RUNNING) {
      return statusMap[status];
    }
    if (status === WEAVE_INSTANCE_STATUS.RUNNING) {
      return "Fetching room content...";
    }

    return "";
  }, [loadedParams, status]);

  const websocketsStore = useGetWebsocketsStore({
    loadedParams,
    getUser,
  });

  if ((!room || !user) && loadedParams) {
    router.push("/error?errorCode=room-required-parameters");
    return null;
  }

  return (
    <>
      {(!loadedParams || status !== WEAVE_INSTANCE_STATUS.RUNNING) && (
        <RoomLoader
          roomId={room ? room : "-"}
          content="LOADING ROOM"
          description={loadingDescription}
        />
      )}
      {loadedParams && room && websocketsStore && (
        <WeaveProvider
          containerId="weave"
          getUser={getUser}
          store={websocketsStore}
          fonts={FONTS}
          nodes={NODES}
          actions={ACTIONS}
          customPlugins={CUSTOM_PLUGINS}
        >
          <RoomLayout />
        </WeaveProvider>
      )}
    </>
  );
};

RoomLayout

On the components/room folder, create a file named room.layout.tsx with the following content:

components/room/room.layout.tsx
"use client";

import React from "react";
import { WEAVE_INSTANCE_STATUS } from "@inditextech/weave-types";
import { useWeave, useWeaveEvents } from "@inditextech/weave-react";
import { useCollaborationRoom } from "@/store/store";
import { ContextMenuRender } from "@/components/context-menu/context-menu";
import { RoomInformationOverlay } from "@/components/overlays/room-information-overlay";
import { RoomUsersOverlay } from "@/components/overlays/room-users-overlay";
import { ToolsOverlay } from "@/components/overlays/tools-overlay";
import { ZoomHandlerOverlay } from "@/components/overlays/zoom-handler-overlay";

export const RoomLayout = () => {
  useWeaveEvents();

  const instance = useWeave((state) => state.instance);
  const actualAction = useWeave((state) => state.actions.actual);
  const status = useWeave((state) => state.status);

  const contextMenuShow = useCollaborationRoom(
    (state) => state.contextMenu.show
  );
  const contextMenuPosition = useCollaborationRoom(
    (state) => state.contextMenu.position
  );
  const contextMenuOptions = useCollaborationRoom(
    (state) => state.contextMenu.options
  );
  const setContextMenuShow = useCollaborationRoom(
    (state) => state.setContextMenuShow
  );

  React.useEffect(() => {
    if (
      instance &&
      status === WEAVE_INSTANCE_STATUS.RUNNING &&
      actualAction !== "selectionTool"
    ) {
      instance.triggerAction("selectionTool");
    }
  }, [instance, status]);

  return (
    <div className="w-full h-full relative flex outline-transparent">
      <div className="w-full h-full">
        <div id="weave" className="w-full h-full outline-transparent"></div>
        {status === WEAVE_INSTANCE_STATUS.RUNNING && (
          <>
            <ContextMenuRender
              show={contextMenuShow}
              onChanged={(show: boolean) => {
                setContextMenuShow(show);
              }}
              position={contextMenuPosition}
              options={contextMenuOptions}
            />
            <RoomInformationOverlay />
            <RoomUsersOverlay />
            <ToolsOverlay />
            <ZoomHandlerOverlay />
          </>
        )}
      </div>
    </div>
  );
};

RoomLoader

On the components/room folder, create a file named room.loader.tsx with the following content:

components/room/room.loader.tsx
"use client";

type RoomLoaderProps = {
  roomId?: string;
  content: string;
  description?: string;
};

export function RoomLoader({
  roomId,
  content,
  description,
}: Readonly<RoomLoaderProps>) {
  return (
    <div
      className="w-full h-full bg-white flex justify-center items-center overflow-hidden absolute z-[1000]"
    >
      <div className="absolute bottom-0 left-0 right-0 h-full flex justify-center items-center">
        <div className="flex flex-col items-center justify-center space-y-4 p-4">
          <div className="flex flex-col justify-center items-center text-black gap-3">
            <div className="font-noto-sans font-extralight text-2xl uppercase">
              <span>{content}</span>
            </div>

            {roomId && (
              <div className="font-noto-sans text-2xl font-semibold">
                <span>{roomId}</span>
              </div>
            )}
              {description && (
                <div className="font-noto-sans-mono text-xl">
                  <span key={description}>
                    {description}
                  </span>
                </div>
              )}
          </div>
        </div>
      </div>
    </div>
  );
}

ConnectedUsers

On the components/overlays folder, create a file named connected-users.tsx with the following content:

components/overlays/connected-users.tsx
"use client";

import React from "react";
import Avatar from "boring-avatars";
import { Avatar as AvatarUI, AvatarFallback } from "@/components/ui/avatar";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { useCollaborationRoom } from "@/store/store";
import { ChevronDown } from "lucide-react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useWeave } from "@inditextech/weave-react";
import { cn } from "@/lib/utils";

export const ConnectedUsers = () => {
  const connectedUsers = useWeave((state) => state.users);

  const user = useCollaborationRoom((state) => state.user);

  const [menuOpen, setMenuOpen] = React.useState(false);

  const connectedUserKey = React.useMemo(() => {
    const filterOwnUser = Object.keys(connectedUsers).filter(
      (actUser) => actUser === user?.name
    );
    return filterOwnUser?.[0];
  }, [user, connectedUsers]);

  const { showUsers, restUsers } = React.useMemo(() => {
    const filterOwnUser = Object.keys(connectedUsers).filter(
      (actUser) => actUser !== user?.name
    );
    return {
      showUsers: filterOwnUser.slice(0, 4),
      restUsers: filterOwnUser.slice(4),
    };
  }, [user, connectedUsers]);

  console.log({ connectedUsers });

  if (Object.keys(connectedUsers).length === 0) {
    return null;
  }

  return (
    <div className="w-full min-h-[40px] flex gap-1 justify-between items-center">
      <TooltipProvider delayDuration={300}>
        <div className="w-full flex gap-1 justify-start items-center">
          {connectedUserKey && (
            <Tooltip>
              <TooltipTrigger asChild>
                <button className="cursor-pointer pointer-events-auto">
                  <AvatarUI className="w-[32px] h-[32px]">
                    <AvatarFallback>
                      <Avatar name={user?.name} variant="beam" />
                    </AvatarFallback>
                  </AvatarUI>
                </button>
              </TooltipTrigger>
              <TooltipContent side="bottom" className="rounded-none">
                <p className="font-noto-sans-mono text-xs">{user?.name}</p>
              </TooltipContent>
            </Tooltip>
          )}
          {showUsers.map((user) => {
            const userInfo = connectedUsers[user];
            return (
              <Tooltip key={user}>
                <TooltipTrigger asChild>
                  <button className="cursor-pointer pointer-events-auto">
                    <AvatarUI className="w-[32px] h-[32px]">
                      <AvatarFallback>
                        <Avatar name={userInfo?.name} variant="beam" />
                      </AvatarFallback>
                    </AvatarUI>
                  </button>
                </TooltipTrigger>
                <TooltipContent side="bottom">
                  <p className="font-noto-sans-mono text-sm">{userInfo.name}</p>
                </TooltipContent>
              </Tooltip>
            );
          })}
          {restUsers.length > 0 && (
            <>
              <Tooltip>
                <TooltipTrigger asChild>
                  <DropdownMenu
                    onOpenChange={(open: boolean) => setMenuOpen(open)}
                  >
                    <DropdownMenuTrigger
                      className={cn(
                        " pointer-events-auto rounded-none cursor-pointer p-2 hover:bg-accent focus:outline-none",
                        {
                          ["bg-accent"]: menuOpen,
                          ["bg-white"]: !menuOpen,
                        }
                      )}
                    >
                      <ChevronDown className="rounded-none" />
                    </DropdownMenuTrigger>
                    <DropdownMenuContent
                      align="end"
                      side="bottom"
                      alignOffset={0}
                      sideOffset={4}
                      className="font-noto-sans-mono rounded-none"
                    >
                      {restUsers.map((user) => {
                        const userInfo = connectedUsers[user];
                        return (
                          <DropdownMenuItem
                            key={user}
                            className="text-foreground focus:bg-white hover:rounded-none"
                          >
                            <AvatarUI className="w-[32px] h-[32px]">
                              <AvatarFallback>
                                <Avatar name={userInfo?.name} variant="beam" />
                              </AvatarFallback>
                            </AvatarUI>
                            {userInfo?.name}
                          </DropdownMenuItem>
                        );
                      })}
                    </DropdownMenuContent>
                  </DropdownMenu>
                </TooltipTrigger>
                <TooltipContent side="bottom">
                  <p className="font-noto-sans-mono text-sm">More users</p>
                </TooltipContent>
              </Tooltip>
            </>
          )}
        </div>
        <div className="flex justify-start items-center gap-1">
          <div className="w-full flex justify-start gap-2 items-center text-center font-noto-sans-mono text-xs px-2">
            <div className="px-2 py-1 bg-accent">
              {Object.keys(connectedUsers).length}
            </div>
            <div className="text-left">users</div>
          </div>
        </div>
      </TooltipProvider>
    </div>
  );
};

ConnectionStatus

On the components/overlays folder, create a file named connection-status.tsx with the following content:

components/overlays/connection-status.tsx
"use client";

import React from "react";
import { Cloud, CloudCog, CloudAlert } from "lucide-react";
import { WEAVE_STORE_WEBSOCKETS_CONNECTION_STATUS } from "@inditextech/weave-store-websockets/client";
import { cn } from "@/lib/utils";

type ConnectionStatusProps = {
  weaveConnectionStatus: string;
};

export const ConnectionStatus = ({
  weaveConnectionStatus,
}: Readonly<ConnectionStatusProps>) => {
  return (
    <div className="flex">
      <div
        className={cn(
          "bg-light-background-1 p-2 flex justify-center items-center rounded-full",
          {
            ["bg-emerald-200 text-black"]:
              weaveConnectionStatus ===
              WEAVE_STORE_WEBSOCKETS_CONNECTION_STATUS.CONNECTED,
            ["bg-sky-300 text-white"]:
              weaveConnectionStatus ===
              WEAVE_STORE_WEBSOCKETS_CONNECTION_STATUS.CONNECTING,
            ["bg-rose-300 text-white"]:
              weaveConnectionStatus ===
              WEAVE_STORE_WEBSOCKETS_CONNECTION_STATUS.DISCONNECTED,
          }
        )}
      >
        {weaveConnectionStatus ===
          WEAVE_STORE_WEBSOCKETS_CONNECTION_STATUS.CONNECTED && (
          <Cloud size={20} />
        )}
        {weaveConnectionStatus ===
          WEAVE_STORE_WEBSOCKETS_CONNECTION_STATUS.CONNECTING && (
          <CloudCog size={20} />
        )}
        {weaveConnectionStatus ===
          WEAVE_STORE_WEBSOCKETS_CONNECTION_STATUS.DISCONNECTED && (
          <CloudAlert size={20} />
        )}
      </div>
    </div>
  );
};

RoomInformationOverlay

On the components/overlays folder, create a file named room-information-overlay.tsx with the following content:

components/overlays/room-information-overlay.tsx
"use client";

import React from "react";
import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { useWeave } from "@inditextech/weave-react";
import { useCollaborationRoom } from "@/store/store";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

import {
  Image as ImageIcon,
  LogOut,
  ChevronDown,
  ChevronUp,
  Grid2X2PlusIcon,
  Grid2x2XIcon,
  Grid3X3Icon,
  GripIcon,
  CheckIcon,
} from "lucide-react";
import {
  WEAVE_GRID_TYPES,
  WeaveExportStageActionParams,
  WeaveStageGridPlugin,
  WeaveStageGridType,
} from "@inditextech/weave-sdk";
import { ConnectionStatus } from "./connection-status";
import { DropdownMenuLabel } from "@radix-ui/react-dropdown-menu";

export function RoomInformationOverlay() {
  const router = useRouter();

  const instance = useWeave((state) => state.instance);
  const weaveConnectionStatus = useWeave((state) => state.connection.status);

  const showUI = useCollaborationRoom((state) => state.ui.show);
  const room = useCollaborationRoom((state) => state.room);

  const [menuOpen, setMenuOpen] = React.useState(false);
  const [gridEnabled, setGridEnabled] = React.useState(true);
  const [gridType, setGridType] = React.useState<WeaveStageGridType>(
    WEAVE_GRID_TYPES.LINES
  );

  const handleToggleGrid = React.useCallback(() => {
    if (instance && instance.isPluginEnabled("stageGrid")) {
      instance.disablePlugin("stageGrid");
      setGridEnabled(instance.isPluginEnabled("stageGrid"));
      return;
    }
    if (instance && !instance.isPluginEnabled("stageGrid")) {
      instance.enablePlugin("stageGrid");
      setGridEnabled(instance.isPluginEnabled("stageGrid"));
      return;
    }
  }, [instance]);

  const handleSetGridType = React.useCallback(
    (type: WeaveStageGridType) => {
      if (instance) {
        (instance.getPlugin("stageGrid") as WeaveStageGridPlugin)?.setType(
          type
        );
        setGridType(type);
      }
    },
    [instance]
  );

  const handleExportToImage = React.useCallback(() => {
    if (instance) {
      instance.triggerAction<WeaveExportStageActionParams>("exportStageTool", {
        options: {
          padding: 20,
          pixelRatio: 2,
        },
      });
    }
  }, [instance]);

  React.useEffect(() => {
    if (instance) {
      setGridEnabled(instance.isPluginEnabled("stageGrid"));
    }
  }, [instance]);

  React.useEffect(() => {
    if (instance) {
      const stageGridPlugin = instance.getPlugin(
        "stageGrid"
      ) as WeaveStageGridPlugin;
      setGridType(stageGridPlugin?.getType());
    }
  }, [instance]);

  // const handleExportToPdf = React.useCallback(() => {
  //   if (instance) {
  //     instance.triggerAction<WeaveExportStageActionParams>("exportStageTool", {
  //       options: {
  //         padding: 20,
  //         pixelRatio: 2,
  //       },
  //     });
  //   }
  // }, [instance]);

  const handleExitRoom = React.useCallback(() => {
    router.push("/");
  }, [router]);

  if (!showUI) {
    return null;
  }

  return (
    <div className="pointer-events-none absolute top-2 left-2 flex gap-1 justify-center items-center">
      <div className="bg-white border border-zinc-200 shadow-lg flex justify-start items-center gap-0 pr-1">
        <div className="flex justify-start items-center p-1 gap-1">
          <DropdownMenu onOpenChange={(open: boolean) => setMenuOpen(open)}>
            <DropdownMenuTrigger
              className={cn(
                "pointer-events-auto rounded-none cursor-pointer p-1 px-3 hover:bg-accent focus:outline-none",
                {
                  ["bg-accent"]: menuOpen,
                  ["bg-white"]: !menuOpen,
                }
              )}
            >
              <div className="flex justify-start items-center gap-2 font-noto-sans-mono text-foreground !normal-case min-h-[32px]">
                <div className="font-noto-sans text-lg font-extralight">
                  {room}
                </div>
                {menuOpen ? <ChevronUp /> : <ChevronDown />}
              </div>
            </DropdownMenuTrigger>
            <DropdownMenuContent
              align="start"
              side="bottom"
              alignOffset={0}
              sideOffset={4}
              className="font-noto-sans-mono rounded-none"
            >
              <DropdownMenuLabel className="px-2 py-1 pt-2 text-zinc-600 text-xs">
                Grid Visibility
              </DropdownMenuLabel>
              <DropdownMenuItem
                className="text-foreground cursor-pointer hover:rounded-none"
                onClick={handleToggleGrid}
              >
                {!gridEnabled && (
                  <>
                    <Grid2X2PlusIcon /> Enable
                  </>
                )}
                {gridEnabled && (
                  <>
                    <Grid2x2XIcon /> Disable
                  </>
                )}
              </DropdownMenuItem>
              <DropdownMenuLabel className="px-2 py-1 pt-2 text-zinc-600 text-xs">
                Grid Kind
              </DropdownMenuLabel>
              <DropdownMenuItem
                disabled={
                  !gridEnabled ||
                  (gridEnabled && gridType === WEAVE_GRID_TYPES.DOTS)
                }
                className="text-foreground cursor-pointer hover:rounded-none"
                onClick={() => {
                  handleSetGridType(WEAVE_GRID_TYPES.DOTS);
                }}
              >
                <div className="w-full flex justify-between items-center">
                  <div className="w-full flex justify-start items-center gap-2">
                    <GripIcon size={16} /> Dots
                  </div>
                  {gridType === WEAVE_GRID_TYPES.DOTS && <CheckIcon />}
                </div>
              </DropdownMenuItem>
              <DropdownMenuItem
                disabled={
                  !gridEnabled ||
                  (gridEnabled && gridType === WEAVE_GRID_TYPES.LINES)
                }
                className="text-foreground cursor-pointer hover:rounded-none"
                onClick={() => {
                  handleSetGridType(WEAVE_GRID_TYPES.LINES);
                }}
              >
                <div className="w-full flex justify-between items-center">
                  <div className="w-full flex justify-start items-center gap-2">
                    <Grid3X3Icon size={16} /> Lines
                  </div>
                  {gridType === WEAVE_GRID_TYPES.LINES && <CheckIcon />}
                </div>
              </DropdownMenuItem>
              <DropdownMenuSeparator />
              <DropdownMenuLabel className="px-2 py-1 pt-2 text-zinc-600 text-xs">
                Exporting
              </DropdownMenuLabel>
              <DropdownMenuItem
                className="text-foreground cursor-pointer hover:rounded-none"
                onClick={handleExportToImage}
              >
                <ImageIcon /> Stage to image
              </DropdownMenuItem>
              {/* <DropdownMenuItem
                className="text-foreground cursor-pointer hover:rounded-none"
                onClick={handleExportToPdf}
              >
                <FileText />
                Export to PDF
              </DropdownMenuItem> */}
              <DropdownMenuSeparator />
              <DropdownMenuItem
                className="text-foreground cursor-pointer hover:rounded-none"
                onClick={handleExitRoom}
              >
                <LogOut /> Exit room
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
          <ConnectionStatus weaveConnectionStatus={weaveConnectionStatus} />
        </div>
      </div>
    </div>
  );
}

RoomUsersOverlay

On the components/overlays folder, create a file named room-users-overlay.tsx with the following content:

components/overlays/room-users-overlay.tsx
"use client";

import React from "react";
import { ConnectedUsers } from "./connected-users";
import { useCollaborationRoom } from "@/store/store";

export function RoomUsersOverlay() {
  const showUI = useCollaborationRoom((state) => state.ui.show);

  if (!showUI) {
    return null;
  }

  return (
    <div
      className="pointer-events-none absolute top-2 right-2 flex flex-col gap-1 justify-center items-center"
    >
      <div className="w-[320px] min-h-[50px] p-2 py-1 bg-white border border-zinc-200 shadow-lg flex flex-col justify-start items-center">
        <div className="w-full min-h-[40px] h-full flex flex-col justify-between items-center gap-2">
          <ConnectedUsers />
        </div>
      </div>
    </div>
  );
}

ToolbarButton

On the components/overlays folder, create a file named toolbar-button.tsx with the following content:

components/overlays/toolbar-button.tsx
"use client";

import React from "react";
import { cn } from "@/lib/utils";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";

type ToolbarButtonProps = {
  icon: React.ReactNode;
  onClick: () => void;
  active?: boolean;
  disabled?: boolean;
  label?: React.ReactNode;
  tooltipSide?: "top" | "bottom" | "left" | "right";
  tooltipAlign?: "start" | "center" | "end";
};

export function ToolbarButton({
  icon,
  label = "tool",
  onClick,
  disabled = false,
  active = false,
  tooltipSide = "right",
  tooltipAlign = "center",
}: Readonly<ToolbarButtonProps>) {
  return (
    <TooltipProvider delayDuration={300}>
      <Tooltip>
        <TooltipTrigger asChild>
          <button
            className={cn(
              "pointer-events-auto relative cursor-pointer hover:text-black hover:bg-accent px-2 py-2 flex justify-center items-center",
              {
                ["bg-zinc-700 text-white"]: active,
                ["pointer-events-none cursor-default text-black opacity-50"]:
                  disabled,
              }
            )}
            disabled={disabled}
            onClick={onClick}
          >
            {icon}
          </button>
        </TooltipTrigger>
        <TooltipContent
          side={tooltipSide}
          align={tooltipAlign}
          className="rounded-none"
        >
          {label}
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
}

Toolbar

On the components/overlays folder, create a file named toolbar.tsx with the following content:

components/overlays/toolbar.tsx
"use client";

import { cn } from "@/lib/utils";
import React from "react";

type ToolbarProps = {
  children: React.ReactNode;
  orientation?: "horizontal" | "vertical";
};

export const Toolbar = ({
  children,
  orientation = "vertical",
}: Readonly<ToolbarProps>) => {
  return (
    <div
      className={cn(
        "pointer-events-none gap-[1px] shadow-lg px-1 py-1 bg-white border border-light-border-3 pointer-events-auto",
        {
          ["flex"]: orientation === "horizontal",
          ["flex flex-col"]: orientation === "vertical",
        }
      )}
    >
      {children}
    </div>
  );
};

ToolsOverlay

On the components/overlays folder, create a file named tools-overlay.tsx with the following content:

components/overlays/tools-overlay.tsx
"use client";

import React from "react";
import { Square, MousePointer, Hand } from "lucide-react";
import { useWeave } from "@inditextech/weave-react";
import { ToolbarButton } from "./toolbar-button";
import { Toolbar } from "./toolbar";
import { useCollaborationRoom } from "@/store/store";

export function ToolsOverlay() {
  const instance = useWeave((state) => state.instance);
  const actualAction = useWeave((state) => state.actions.actual);

  const showUI = useCollaborationRoom((state) => state.ui.show);

  const triggerTool = React.useCallback(
    (toolName: string) => {
      if (instance && actualAction !== toolName) {
        instance.triggerAction(toolName);
      }
      if (instance && actualAction === toolName) {
        instance.cancelAction(toolName);
      }
    },
    [instance, actualAction]
  );

  if (!showUI) {
    return null;
  }

  return (
    <div className="pointer-events-none absolute top-[calc(50px+16px)] left-2 bottom-2 flex flex-col gap-2 justify-center items-center">
      <Toolbar>
        <ToolbarButton
          icon={<Hand />}
          active={actualAction === "moveTool"}
          onClick={() => triggerTool("moveTool")}
          label={
            <div className="flex gap-3 justify-start items-center">
              <p>Move</p>
            </div>
          }
        />
        <ToolbarButton
          icon={<MousePointer />}
          active={actualAction === "selectionTool"}
          onClick={() => triggerTool("selectionTool")}
          label={
            <div className="flex gap-3 justify-start items-center">
              <p>Selection</p>
            </div>
          }
        />
        <ToolbarButton
          icon={<Square />}
          active={actualAction === "rectangleTool"}
          onClick={() => triggerTool("rectangleTool")}
          label={
            <div className="flex gap-3 justify-start items-center">
              <p>Add a rectangle</p>
            </div>
          }
        />
      </Toolbar>
    </div>
  );
}

ZoomHandlerOverlay

On the components/overlays folder, create a file named zoom-handler-overlay.tsx with the following content:

components/overlays/zoom-handler-overlay.tsx
"use client";

import React from "react";
import { ToolbarButton } from "./toolbar-button";
import {
  Fullscreen,
  Maximize,
  ZoomIn,
  ZoomOut,
  Braces,
  Undo,
  Redo,
} from "lucide-react";
import { useWeave } from "@inditextech/weave-react";
import { useCollaborationRoom } from "@/store/store";

export function ZoomHandlerOverlay() {
  const instance = useWeave((state) => state.instance);
  const actualAction = useWeave((state) => state.actions.actual);
  const selectedNodes = useWeave((state) => state.selection.nodes);
  const canUndo = useWeave((state) => state.undoRedo.canUndo);
  const canRedo = useWeave((state) => state.undoRedo.canRedo);

  const zoomValue = useWeave((state) => state.zoom.value);
  const canZoomIn = useWeave((state) => state.zoom.canZoomIn);
  const canZoomOut = useWeave((state) => state.zoom.canZoomOut);

  const showUI = useCollaborationRoom((state) => state.ui.show);

  const handleTriggerActionWithParams = React.useCallback(
    (actionName: string, params: unknown) => {
      if (instance) {
        const triggerSelection = actualAction === "selectionTool";
        instance.triggerAction(actionName, params);
        if (triggerSelection) {
          instance.triggerAction("selectionTool");
        }
      }
    },
    [instance, actualAction]
  );

  const handlePrintToConsoleState = React.useCallback(() => {
    if (instance) {
      // eslint-disable-next-line no-console
      console.log({
        appState: JSON.parse(JSON.stringify(instance.getStore().getState())),
      });
    }
  }, [instance]);

  if (!showUI) {
    return null;
  }

  return (
    <div className="pointer-events-none absolute bottom-2 left-2 right-2 flex gap- justify-between items-center">
      <div className="flex gap-2 justify-start items-center">
        <div className="bg-white border border-zinc-200 shadow-lg p-1 flex justify-between items-center">
          <div className="w-full grid grid-cols-[auto_1fr]">
            <div className="flex justify-start items-center gap-1">
              <ToolbarButton
                icon={<Undo />}
                disabled={!canUndo}
                onClick={() => {
                  if (instance) {
                    const actualStore = instance.getStore();
                    actualStore.undoStateStep();
                  }
                }}
                label={
                  <div className="flex flex-col gap-2 justify-start items-end">
                    <p>Undo latest changes</p>
                  </div>
                }
                tooltipSide="top"
                tooltipAlign="start"
              />
              <ToolbarButton
                icon={<Redo />}
                disabled={!canRedo}
                onClick={() => {
                  if (instance) {
                    const actualStore = instance.getStore();
                    actualStore.redoStateStep();
                  }
                }}
                label={
                  <div className="flex gap-3 justify-start items-center">
                    <p>Redo latest changes</p>
                  </div>
                }
                tooltipSide="top"
                tooltipAlign="start"
              />
            </div>
          </div>
        </div>
        <div className="bg-white border border-zinc-200 shadow-lg p-1 flex justify-between items-center">
          <div className="w-full grid grid-cols-[auto_1fr]">
            <div className="flex justify-start items-center gap-1">
              <ToolbarButton
                icon={<Braces />}
                onClick={handlePrintToConsoleState}
                label={
                  <div className="flex flex-col gap-2 justify-start items-end">
                    <p>Print model state to browser console</p>
                  </div>
                }
                tooltipSide="top"
                tooltipAlign="start"
              />
            </div>
          </div>
        </div>
      </div>
      <div className="flex justify-end gap-2 items-center">
        <div className="min-w-[320px] w-[320px] gap-1 p-1 bg-white border border-zinc-200 shadow-lg flex justify-end items-center">
          <div className="w-full grid grid-cols-[auto_1fr]">
            <div className="flex justify-start items-center gap-1">
              <ToolbarButton
                icon={<ZoomIn />}
                disabled={!canZoomIn}
                onClick={() => {
                  handleTriggerActionWithParams("zoomInTool", {
                    previousAction: actualAction,
                  });
                }}
                label={
                  <div className="flex flex-col gap-2 justify-start items-end">
                    {" "}
                    <p>Zoom in</p>
                  </div>
                }
                tooltipSide="top"
                tooltipAlign="end"
              />
              <ToolbarButton
                icon={<ZoomOut />}
                disabled={!canZoomOut}
                onClick={() => {
                  handleTriggerActionWithParams("zoomOutTool", {
                    previousAction: actualAction,
                  });
                }}
                label={
                  <div className="flex flex-col gap-2 justify-start items-end">
                    <p>Zoom out</p>
                  </div>
                }
                tooltipSide="top"
                tooltipAlign="end"
              />
              <ToolbarButton
                icon={<Maximize />}
                onClick={() => {
                  handleTriggerActionWithParams("fitToScreenTool", {
                    previousAction: actualAction,
                  });
                }}
                label={
                  <div className="flex flex-col gap-2 justify-start items-end">
                    <p>Fit to screen</p>
                  </div>
                }
                tooltipSide="top"
                tooltipAlign="end"
              />
              <ToolbarButton
                icon={<Fullscreen />}
                disabled={selectedNodes.length === 0}
                onClick={() => {
                  handleTriggerActionWithParams("fitToSelectionTool", {
                    previousAction: actualAction,
                  });
                }}
                label={
                  <div className="flex flex-col gap-2 justify-start items-end">
                    <p>Fit to selection</p>
                  </div>
                }
                tooltipSide="top"
                tooltipAlign="end"
              />
            </div>
            <div className="w-full px-4 font-noto-sans-mono flex justify-end items-center text-muted-foreground">
              {parseFloat(`${zoomValue * 100}`).toFixed(2)}%
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

ContextMenu

On the components/context-menu folder, create a file named context-menu with the following content:

components/context-menu/context-menu.tsx
"use client";

import React from "react";
import { cn } from "@/lib/utils";

type ContextMenuButtonProps = {
  label: React.ReactNode;
  icon?: React.ReactNode;
  disabled?: boolean;
  onClick: () => void;
};

export type ContextMenuOption = {
  id: string;
  type: "button" | "divider";
} & (
  | {
      type: "button";
      label: string | React.ReactNode;
      icon?: React.ReactNode;
      disabled?: boolean;
      onClick: () => void;
    }
  | {
      type: "divider";
    }
);

type ContextMenuProps = {
  show: boolean;
  onChanged: (show: boolean) => void;
  position: { x: number; y: number };
  options: ContextMenuOption[];
};

function ContextMenuButton({
  label,
  icon,
  disabled,
  onClick,
}: Readonly<ContextMenuButtonProps>) {
  return (
    <button
      className={cn(
        "!cursor-pointer w-[calc(100%-8px)] flex justify-between items-center gap-2 font-noto-sans-mono text-sm text-left whitespace-nowrap m-1 text-foreground px-2 py-1.5",
        {
          ["hover:bg-accent"]: !disabled,
          ["!cursor-default hover:bg-white text-muted-foreground"]: disabled,
        }
      )}
      disabled={disabled}
      onClick={onClick}
    >
      {icon} <div className="w-full">{label}</div>
    </button>
  );
}

export const ContextMenuRender = ({
  show,
  onChanged,
  position,
  options,
}: Readonly<ContextMenuProps>) => {
  const ref = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    if (ref.current && show) {
      const boundingRect = ref.current.getBoundingClientRect();
      let X = boundingRect.x;
      let Y = boundingRect.y;

      X = Math.max(
        20,
        Math.min(X, window.innerWidth - boundingRect.width - 20)
      );
      Y = Math.max(
        20,
        Math.min(Y, window.innerHeight - boundingRect.height - 20)
      );

      ref.current.style.top = `${Y}px`;
      ref.current.style.left = `${X}px`;
    }
  }, [show]);

  React.useEffect(() => {
    function checkIfClickedOutside(e: MouseEvent) {
      if (
        ref.current &&
        e.target !== ref.current &&
        !ref.current.contains(e.target as Node)
      ) {
        ref.current.style.display = `none`;
        onChanged(false);
      }
    }

    function checkIfTouchOutside(e: TouchEvent) {
      if (
        ref.current &&
        e.target !== ref.current &&
        !ref.current.contains(e.target as Node)
      ) {
        ref.current.style.display = `none`;
        onChanged(false);
      }
    }

    window.addEventListener("click", checkIfClickedOutside);
    window.addEventListener("touchstart", checkIfTouchOutside);

    return () => {
      window.removeEventListener("click", checkIfClickedOutside);
      window.removeEventListener("touchstart", checkIfTouchOutside);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div
      ref={ref}
      className="fixed w-[300px] bg-white flex flex-col border border-zinc-200 shadow-lg"
      style={{
        display: show ? "block" : "none",
        top: `${position.y}px`,
        left: `${position.x}px`,
        zIndex: 10,
      }}
    >
      {options.map((option) => {
        if (option.type === "button") {
          return (
            <ContextMenuButton
              key={option.id}
              label={option.label}
              icon={option.icon}
              disabled={option.disabled ?? false}
              onClick={option.onClick}
            />
          );
        }
        if (option.type === "divider") {
          return (
            <div key={option.id} className="w-full h-[1px] bg-accent"></div>
          );
        }
      })}
    </div>
  );
};

Add Context Menu support to the global state

Add the following changes to the store/store.ts file, to maintain the state of the Context Menu.

store/store.ts
import { Vector2d } from "konva/lib/types"; 
import { create } from "zustand";
import { ContextMenuOption } from "@/components/context-menu/context-menu"; 

type ShowcaseUser = {
  name: string;
  email: string;
};

interface CollaborationRoomState {
  ui: {
    show: boolean;
  };
  user: ShowcaseUser | undefined;
  room: string | undefined;
  contextMenu: { 
    show: boolean; 
    position: Vector2d; 
    options: ContextMenuOption[]; 
  }; 
  setShowUi: (newShowUI: boolean) => void;
  setUser: (newUser: ShowcaseUser | undefined) => void;
  setRoom: (newRoom: string | undefined) => void;
  setContextMenuShow: (newContextMenuShow: boolean) => void; 
  setContextMenuPosition: (newContextMenuPosition: Vector2d) => void; 
  setContextMenuOptions: (newContextMenuOptions: ContextMenuOption[]) => void; 
}

export const useCollaborationRoom = create<CollaborationRoomState>()((set) => ({
  ui: {
    show: true,
  },
  user: undefined,
  room: undefined,
  contextMenu: { 
    show: false, 
    position: { x: 0, y: 0 }, 
    options: [], 
  }, 
  setShowUi: (newShowUI) =>
    set((state) => ({
      ...state,
      ui: { ...state.ui, show: newShowUI },
    })),
  setUser: (newUser) => set((state) => ({ ...state, user: newUser })),
  setRoom: (newRoom) => set((state) => ({ ...state, room: newRoom })),
  // prettier-ignore
  setContextMenuShow: ( 
    newContextMenuShow 
  ) =>
    set((state) => ({ 
      ...state, 
      contextMenu: { ...state.contextMenu, show: newContextMenuShow }, 
    })), 
  // prettier-ignore
  setContextMenuPosition: ( 
    newContextMenuPosition 
  ) =>
    set((state) => ({ 
      ...state, 
      contextMenu: { ...state.contextMenu, position: newContextMenuPosition }, 
    })), 
  // prettier-ignore
  setContextMenuOptions: ( 
    newContextMenuOptions 
  ) =>
    set((state) => ({ 
      ...state, 
      contextMenu: { ...state.contextMenu, options: newContextMenuOptions }, 
    })), 
}));

Next steps

Let's now run the project.