Room page
Learn how to set up the Room page on Next.js
In this guide, we will build the 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 namedroom
. -
On the
app/room
folder, create a folder named[roomId]
. -
On the
app/room/[roomId]
folder, create a file namedpage.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 namedutils
. -
On the
components/utils
folder, create a file namedconstants.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:
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:
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:
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 aroom
folder. - On the
components
folder, create anoverlays
folder. - On the
components
folder, create acontext-menu
folder.
Room
On the components/room
folder, create a file named room.tsx
with the following content:
"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:
"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:
"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:
"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:
"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:
"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:
"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:
"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:
"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:
"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:
"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:
"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.
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.