├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── README.md
├── app
├── dashboard
│ └── page.tsx
├── default.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
├── normalize.css
├── page.module.css
├── page.tsx
└── whiteboard
│ ├── [id]
│ ├── @dashPreview
│ │ ├── (...)dashboard
│ │ │ └── page.tsx
│ │ └── default.tsx
│ ├── WhiteboardPage.module.css
│ ├── layout.tsx
│ └── page.tsx
│ └── page.tsx
├── components
├── Cursors
│ ├── Cursor.module.css
│ ├── Cursor.tsx
│ ├── Cursors.tsx
│ └── index.ts
├── Dashboard
│ ├── Dashboard.module.css
│ ├── Dashboard.tsx
│ ├── DocumentIcon.tsx
│ ├── DocumentRow.module.css
│ └── DocumentRow.tsx
├── Header
│ ├── Header.module.css
│ └── Header.tsx
├── Logo
│ ├── Logo.module.css
│ ├── Logo.tsx
│ └── index.ts
└── Whiteboard
│ ├── Whiteboard.module.css
│ ├── Whiteboard.tsx
│ ├── WhiteboardNote.module.css
│ ├── WhiteboardNote.tsx
│ └── index.ts
├── icons
├── Cross.tsx
├── Plus.tsx
├── Redo.tsx
├── Undo.tsx
└── index.ts
├── liveblocks.config.ts
├── next.config.js
├── package-lock.json
├── package.json
├── primitives
├── Avatar
│ ├── Avatar.module.css
│ ├── Avatar.tsx
│ └── index.ts
├── Button
│ ├── Button.module.css
│ ├── Button.tsx
│ └── index.ts
├── Container
│ ├── Container.module.css
│ ├── Container.tsx
│ └── index.ts
├── Input
│ ├── Input.module.css
│ ├── Input.tsx
│ └── index.ts
├── Skeleton
│ ├── Skeleton.module.css
│ ├── Skeleton.tsx
│ └── index.ts
├── Spinner
│ ├── Spinner.module.css
│ ├── Spinner.tsx
│ └── index.ts
└── Tooltip
│ ├── Tooltip.module.css
│ ├── Tooltip.tsx
│ └── index.ts
├── public
├── next.svg
└── vercel.svg
├── tsconfig.json
└── utils
├── capitalize.ts
├── getContrastingColor.ts
├── getInitials.ts
├── index.ts
├── normalizeTrailingSlash.ts
├── randomUser.ts
└── useBoundingClientRectRef.ts
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | node: true,
5 | },
6 | extends: [
7 | "eslint:recommended",
8 | "plugin:@typescript-eslint/recommended",
9 | "plugin:prettier/recommended",
10 | "next",
11 | ],
12 | parser: "@typescript-eslint/parser",
13 | plugins: ["@typescript-eslint", "react", "react-hooks", "prettier"],
14 | rules: {
15 | "@typescript-eslint/ban-ts-comment": "off",
16 | "@typescript-eslint/ban-types": "off",
17 | "@typescript-eslint/no-empty-function": "off",
18 | "@typescript-eslint/no-explicit-any": "off",
19 | "@typescript-eslint/no-non-null-assertion": "off",
20 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
21 | "@typescript-eslint/no-use-before-define": "off",
22 | "@typescript-eslint/no-var-requires": "off",
23 | "react/display-name": "off",
24 | "react/react-in-jsx-scope": "off",
25 | "react/prop-types": "off",
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | .idea
38 | .vscode
39 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "singleQuote": false,
6 | "jsxSingleQuote": false,
7 | "arrowParens": "always",
8 | "bracketSpacing": true,
9 | "bracketSameLine": false,
10 | "trailingComma": "es5",
11 | "proseWrap": "always"
12 | }
13 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | https://user-images.githubusercontent.com/33033422/231229414-c1c627c6-babd-4b99-9393-ecb20ec06afe.mp4
2 |
3 | ## Next.js 13.3 parallel & intercepted routes + Liveblocks demo
4 |
5 | This demo shows you how to use Next.js 13.3 parallel & intercepted routes with a Liveblocks real-time collaborative app. Stay connected to a Liveblocks room whilst viewing the dashboard.
6 |
7 | ### Set up Liveblocks
8 |
9 | - Install all dependencies with `npm install`
10 | - Create an account on [liveblocks.io](https://liveblocks.io/dashboard)
11 | - Copy your **public** key from the [dashboard](https://liveblocks.io/dashboard/apikeys)
12 | - Create an `.env.local` file and add your **public** key as the `NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY` environment variable
13 | - Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000)
14 |
--------------------------------------------------------------------------------
/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { Dashboard } from "@/components/Dashboard/Dashboard";
2 | import { Header } from "@/components/Header/Header";
3 |
4 | export const metadata = {
5 | title: "Dashboard",
6 | };
7 |
8 | export default function Home() {
9 | return (
10 | <>
11 |
12 |
13 | >
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/app/default.tsx:
--------------------------------------------------------------------------------
1 | export default function Default() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CTNicholas/liveblocks-parallel-routes/3469a1a2fdf914a707d2ce81918dca86d65513f5/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "./normalize.css";
2 |
3 | :root {
4 | --color-black: 0 0 0;
5 | --color-white: 255 255 255;
6 | --color-gray-50: 250 250 250;
7 | --color-gray-100: 244 244 245;
8 | --color-gray-150: 236 236 238;
9 | --color-gray-200: 228 228 231;
10 | --color-gray-250: 220 220 224;
11 | --color-gray-300: 212 212 216;
12 | --color-gray-350: 187 187 193;
13 | --color-gray-400: 161 161 170;
14 | --color-gray-450: 137 137 146;
15 | --color-gray-500: 113 113 122;
16 | --color-gray-550: 98 98 107;
17 | --color-gray-600: 82 82 91;
18 | --color-gray-650: 73 73 81;
19 | --color-gray-700: 63 63 70;
20 | --color-gray-750: 51 51 56;
21 | --color-gray-800: 39 39 42;
22 | --color-gray-850: 32 32 35;
23 | --color-gray-900: 24 24 27;
24 | --color-gray-950: 18 18 21;
25 | --color-overlay: rgb(var(--color-black) / 60%);
26 | --space-0: 0;
27 | --space-1: 0.125rem;
28 | --space-2: 0.25rem;
29 | --space-3: 0.375rem;
30 | --space-4: 0.5rem;
31 | --space-5: 0.625rem;
32 | --space-6: 0.75rem;
33 | --space-7: 0.875rem;
34 | --space-8: 1rem;
35 | --space-9: 1.25rem;
36 | --space-10: 1.5rem;
37 | --space-11: 1.75rem;
38 | --space-12: 2rem;
39 | --space-13: 2.25rem;
40 | --space-14: 2.5rem;
41 | --space-15: 3rem;
42 | --space-16: 3.5rem;
43 | --space-17: 4rem;
44 | --space-18: 4.5rem;
45 | --space-19: 5rem;
46 | --space-20: 6rem;
47 | --space-21: 7rem;
48 | --space-22: 8rem;
49 | --space-23: 9rem;
50 | --space-24: 10rem;
51 | --size-2xs: 0.625rem;
52 | --size-xs: 0.75rem;
53 | --size-sm: 0.875rem;
54 | --size: 1rem;
55 | --size-lg: 1.125rem;
56 | --size-xl: 1.25rem;
57 | --size-2xl: 1.5rem;
58 | --size-3xl: 2rem;
59 | --size-4xl: 3rem;
60 | --radius-xs: 0.3rem;
61 | --radius-sm: 0.4rem;
62 | --radius: 0.7rem;
63 | --radius-lg: 0.8rem;
64 | --shadow-xs: 0 1px 4px rgb(var(--color-black) / 5%);
65 | --shadow-sm: 0 2px 8px rgb(var(--color-black) / 5%);
66 | --shadow: 0 3px 10px rgb(var(--color-black) / 5%);
67 | --shadow-lg: 0 4px 20px rgb(var(--color-black) / 5%);
68 | --shadow-xl: 0 5px 30px rgb(var(--color-black) / 5%);
69 | --backdrop-surface: saturate(2) blur(16px);
70 | --transition: 0.15s ease-in-out;
71 | --transition-linear: 0.15s linear;
72 | --header-height: 60px;
73 | --z-above: 100;
74 | --z-overlay: 200;
75 | --z-badge: 1000;
76 | --opacity-hover: 0.8;
77 | --opacity-disabled: 0.5;
78 |
79 | accent-color: var(--color-accent);
80 | }
81 |
82 | /* Light theme */
83 | :root {
84 | --color-red: 239 67 67;
85 | --color-green: 132 204 22;
86 | --color-accent: rgb(var(--color-gray-900));
87 | --color-surface: rgb(var(--color-gray-100));
88 | --color-surface-hover: rgb(var(--color-gray-150));
89 | --color-surface-elevated: rgb(var(--color-white));
90 | --color-border: rgb(var(--color-gray-150));
91 | --color-border-contrasted: rgb(var(--color-gray-250));
92 | --color-border-transparent: rgb(var(--color-gray-900) / 10%);
93 | --color-skeleton: rgb(var(--color-gray-150));
94 | --color-skeleton-shine: rgb(var(--color-gray-50));
95 | --color-tooltip: rgb(var(--color-gray-950));
96 | --color-tooltip-text: rgb(var(--color-white));
97 | --color-tooltip-border: rgb(var(--color-gray-750));
98 | --color-text: rgb(var(--color-gray-900));
99 | --color-text-light: rgb(var(--color-gray-600));
100 | --color-text-lighter: rgb(var(--color-gray-500));
101 | --color-text-lightest: rgb(var(--color-gray-400));
102 |
103 | color-scheme: light;
104 | }
105 |
106 | /* Dark theme */
107 | @media (prefers-color-scheme: dark) {
108 | :root {
109 | --color-red: 248 113 113;
110 | --color-green: 162 230 53;
111 | --color-accent: rgb(var(--color-white));
112 | --color-surface: rgb(var(--color-gray-850));
113 | --color-surface-hover: rgb(var(--color-gray-800));
114 | --color-surface-elevated: rgb(var(--color-gray-950));
115 | --color-border: rgb(var(--color-gray-750));
116 | --color-border-contrasted: rgb(var(--color-gray-650));
117 | --color-border-transparent: rgb(var(--color-white) / 10%);
118 | --color-skeleton: rgb(var(--color-gray-750));
119 | --color-skeleton-shine: rgb(var(--color-gray-850));
120 | --color-text: rgb(var(--color-white));
121 | --color-text-light: rgb(var(--color-gray-300));
122 | --color-text-lighter: rgb(var(--color-gray-400));
123 | --color-text-lightest: rgb(var(--color-gray-500));
124 |
125 | color-scheme: dark;
126 | }
127 | }
128 |
129 | html,
130 | body {
131 | background: var(--color-surface-elevated);
132 | color: var(--color-text);
133 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
134 | Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
135 | }
136 |
137 | html,
138 | body,
139 | #__next {
140 | max-width: 100%;
141 | min-height: 100vh;
142 | }
143 |
144 | html.grabbing {
145 | cursor: grabbing;
146 | }
147 |
148 | html.grabbing * {
149 | user-select: none;
150 | }
151 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({ subsets: ["latin"] });
6 |
7 | export default function Layout({ children }: { children: ReactNode }) {
8 | return (
9 |
10 |
{children}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/app/normalize.css:
--------------------------------------------------------------------------------
1 | *,
2 | ::before,
3 | ::after {
4 | box-sizing: border-box;
5 | }
6 |
7 | html {
8 | line-height: 1.5;
9 | tab-size: 4;
10 | -webkit-text-size-adjust: 100%;
11 | }
12 |
13 | body {
14 | margin: 0;
15 | line-height: inherit;
16 | }
17 |
18 | h1,
19 | h2,
20 | h3,
21 | h4,
22 | h5,
23 | h6 {
24 | font-size: inherit;
25 | font-weight: inherit;
26 | }
27 |
28 | a {
29 | color: inherit;
30 | text-decoration: inherit;
31 | }
32 |
33 | b,
34 | strong {
35 | font-weight: bolder;
36 | }
37 |
38 | code,
39 | kbd,
40 | samp,
41 | pre {
42 | font-size: 1em;
43 | }
44 |
45 | small {
46 | font-size: 80%;
47 | }
48 |
49 | button,
50 | input,
51 | optgroup,
52 | select,
53 | textarea {
54 | padding: 0;
55 | border: 0;
56 | margin: 0;
57 | color: inherit;
58 | font-family: inherit;
59 | font-size: 100%;
60 | font-weight: inherit;
61 | line-height: inherit;
62 | }
63 |
64 | button,
65 | select {
66 | border: none;
67 | text-transform: none;
68 | }
69 |
70 | button,
71 | [type="button"],
72 | [type="reset"],
73 | [type="submit"] {
74 | appearance: button;
75 | background-color: transparent;
76 | background-image: none;
77 | }
78 |
79 | :-moz-focusring {
80 | outline: auto;
81 | }
82 |
83 | :-moz-ui-invalid {
84 | box-shadow: none;
85 | }
86 |
87 | progress {
88 | vertical-align: baseline;
89 | }
90 |
91 | ::-webkit-inner-spin-button,
92 | ::-webkit-outer-spin-button {
93 | height: auto;
94 | }
95 |
96 | [type="search"] {
97 | appearance: textfield;
98 | }
99 |
100 | ::-webkit-search-decoration {
101 | appearance: none;
102 | }
103 |
104 | ::-webkit-file-upload-button {
105 | appearance: button;
106 | font: inherit;
107 | }
108 |
109 | summary {
110 | display: list-item;
111 | }
112 |
113 | blockquote,
114 | dl,
115 | dd,
116 | h1,
117 | h2,
118 | h3,
119 | h4,
120 | h5,
121 | h6,
122 | hr,
123 | figure,
124 | p,
125 | pre {
126 | margin: 0;
127 | }
128 |
129 | fieldset {
130 | padding: 0;
131 | margin: 0;
132 | }
133 |
134 | legend {
135 | padding: 0;
136 | }
137 |
138 | ol,
139 | ul,
140 | menu {
141 | padding: 0;
142 | margin: 0;
143 | list-style: none;
144 | }
145 |
146 | textarea {
147 | resize: vertical;
148 | }
149 |
150 | button,
151 | [role="button"] {
152 | cursor: pointer;
153 | }
154 |
155 | :disabled {
156 | cursor: default;
157 | }
158 |
159 | img,
160 | svg,
161 | video,
162 | canvas,
163 | audio,
164 | iframe,
165 | embed,
166 | object {
167 | display: block;
168 | vertical-align: middle;
169 | }
170 |
171 | img,
172 | video {
173 | max-width: 100%;
174 | height: auto;
175 | }
176 |
--------------------------------------------------------------------------------
/app/page.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | display: flex;
3 | flex-direction: column;
4 | justify-content: space-between;
5 | align-items: center;
6 | padding: 6rem;
7 | min-height: 100vh;
8 | }
9 |
10 | .description {
11 | display: inherit;
12 | justify-content: inherit;
13 | align-items: inherit;
14 | font-size: 0.85rem;
15 | max-width: var(--max-width);
16 | width: 100%;
17 | z-index: 2;
18 | font-family: var(--font-mono);
19 | }
20 |
21 | .description a {
22 | display: flex;
23 | justify-content: center;
24 | align-items: center;
25 | gap: 0.5rem;
26 | }
27 |
28 | .description p {
29 | position: relative;
30 | margin: 0;
31 | padding: 1rem;
32 | background-color: rgba(var(--callout-rgb), 0.5);
33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3);
34 | border-radius: var(--border-radius);
35 | }
36 |
37 | .code {
38 | font-weight: 700;
39 | font-family: var(--font-mono);
40 | }
41 |
42 | .grid {
43 | display: grid;
44 | grid-template-columns: repeat(4, minmax(25%, auto));
45 | width: var(--max-width);
46 | max-width: 100%;
47 | }
48 |
49 | .card {
50 | padding: 1rem 1.2rem;
51 | border-radius: var(--border-radius);
52 | background: rgba(var(--card-rgb), 0);
53 | border: 1px solid rgba(var(--card-border-rgb), 0);
54 | transition: background 200ms, border 200ms;
55 | }
56 |
57 | .card span {
58 | display: inline-block;
59 | transition: transform 200ms;
60 | }
61 |
62 | .card h2 {
63 | font-weight: 600;
64 | margin-bottom: 0.7rem;
65 | }
66 |
67 | .card p {
68 | margin: 0;
69 | opacity: 0.6;
70 | font-size: 0.9rem;
71 | line-height: 1.5;
72 | max-width: 30ch;
73 | }
74 |
75 | .center {
76 | display: flex;
77 | justify-content: center;
78 | align-items: center;
79 | position: relative;
80 | padding: 4rem 0;
81 | }
82 |
83 | .center::before {
84 | background: var(--secondary-glow);
85 | border-radius: 50%;
86 | width: 480px;
87 | height: 360px;
88 | margin-left: -400px;
89 | }
90 |
91 | .center::after {
92 | background: var(--primary-glow);
93 | width: 240px;
94 | height: 180px;
95 | z-index: -1;
96 | }
97 |
98 | .center::before,
99 | .center::after {
100 | content: '';
101 | left: 50%;
102 | position: absolute;
103 | filter: blur(45px);
104 | transform: translateZ(0);
105 | }
106 |
107 | .logo {
108 | position: relative;
109 | }
110 |
111 | /* Enable hover only on non-touch devices */
112 | @media (hover: hover) and (pointer: fine) {
113 | .card:hover {
114 | background: rgba(var(--card-rgb), 0.1);
115 | border: 1px solid rgba(var(--card-border-rgb), 0.15);
116 | }
117 |
118 | .card:hover span {
119 | transform: translateX(4px);
120 | }
121 | }
122 |
123 | @media (prefers-reduced-motion) {
124 | .card:hover span {
125 | transform: none;
126 | }
127 | }
128 |
129 | /* Mobile */
130 | @media (max-width: 700px) {
131 | .content {
132 | padding: 4rem;
133 | }
134 |
135 | .grid {
136 | grid-template-columns: 1fr;
137 | margin-bottom: 120px;
138 | max-width: 320px;
139 | text-align: center;
140 | }
141 |
142 | .card {
143 | padding: 1rem 2.5rem;
144 | }
145 |
146 | .card h2 {
147 | margin-bottom: 0.5rem;
148 | }
149 |
150 | .center {
151 | padding: 8rem 0 6rem;
152 | }
153 |
154 | .center::before {
155 | transform: none;
156 | height: 300px;
157 | }
158 |
159 | .description {
160 | font-size: 0.8rem;
161 | }
162 |
163 | .description a {
164 | padding: 1rem;
165 | }
166 |
167 | .description p,
168 | .description div {
169 | display: flex;
170 | justify-content: center;
171 | position: fixed;
172 | width: 100%;
173 | }
174 |
175 | .description p {
176 | align-items: center;
177 | inset: 0 0 auto;
178 | padding: 2rem 1rem 1.4rem;
179 | border-radius: 0;
180 | border: none;
181 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25);
182 | background: linear-gradient(
183 | to bottom,
184 | rgba(var(--background-start-rgb), 1),
185 | rgba(var(--callout-rgb), 0.5)
186 | );
187 | background-clip: padding-box;
188 | backdrop-filter: blur(24px);
189 | }
190 |
191 | .description div {
192 | align-items: flex-end;
193 | pointer-events: none;
194 | inset: auto 0 0;
195 | padding: 2rem;
196 | height: 200px;
197 | background: linear-gradient(
198 | to bottom,
199 | transparent 0%,
200 | rgb(var(--background-end-rgb)) 40%
201 | );
202 | z-index: 1;
203 | }
204 | }
205 |
206 | /* Tablet and Smaller Desktop */
207 | @media (min-width: 701px) and (max-width: 1120px) {
208 | .grid {
209 | grid-template-columns: repeat(2, 50%);
210 | }
211 | }
212 |
213 | @media (prefers-color-scheme: dark) {
214 | .vercelLogo {
215 | filter: invert(1);
216 | }
217 |
218 | .logo {
219 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70);
220 | }
221 | }
222 |
223 | @keyframes rotate {
224 | from {
225 | transform: rotate(360deg);
226 | }
227 | to {
228 | transform: rotate(0deg);
229 | }
230 | }
231 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | export default function Home() {
4 | redirect("/dashboard");
5 | }
6 |
--------------------------------------------------------------------------------
/app/whiteboard/[id]/@dashPreview/(...)dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { Dashboard } from "@/components/Dashboard/Dashboard";
2 |
3 | export const metadata = {
4 | title: "Dashboard",
5 | };
6 |
7 | export default function WhiteboardDashboard() {
8 | return ;
9 | }
10 |
--------------------------------------------------------------------------------
/app/whiteboard/[id]/@dashPreview/default.tsx:
--------------------------------------------------------------------------------
1 | export default function Default() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/app/whiteboard/[id]/WhiteboardPage.module.css:
--------------------------------------------------------------------------------
1 | .whiteboardPage {
2 | position: absolute;
3 | top: 0;
4 | right: 0;
5 | bottom: 0;
6 | left: 0;
7 | transform-origin: bottom right;
8 | transition: transform ease-out 0.15s;
9 | background: var(--color-surface);
10 | }
11 |
12 | .whiteboardPageShrunk {
13 | position: fixed;
14 | border-radius: var(--radius-lg);
15 | box-shadow: var(--shadow-lg);
16 | transform: scale(0.4);
17 | right: var(--space-8);
18 | bottom: var(--space-8);
19 | border: 2px solid rgb(var(--color-gray-200));
20 | }
21 |
22 | .whiteboardPageBackButton {
23 | position: absolute;
24 | top: 0;
25 | right: 0;
26 | bottom: 0;
27 | left: 0;
28 | width: 100%;
29 | height: 100%;
30 | }
31 |
--------------------------------------------------------------------------------
/app/whiteboard/[id]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 |
3 | export const metadata = {
4 | title: "Whiteboard",
5 | };
6 |
7 | export default function Layout({
8 | children,
9 | dashPreview,
10 | }: {
11 | children: ReactNode;
12 | dashPreview: ReactNode;
13 | }) {
14 | return (
15 | <>
16 | {dashPreview}
17 | {children}
18 | >
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/app/whiteboard/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { usePathname, useRouter } from "next/navigation";
4 | import { Whiteboard } from "@/components/Whiteboard";
5 | import clsx from "clsx";
6 | import styles from "./WhiteboardPage.module.css";
7 | import { Header } from "@/components/Header/Header";
8 |
9 | export default function WhiteboardPage({
10 | params: { id },
11 | }: {
12 | params: { id: string };
13 | }) {
14 | const pathname = usePathname();
15 | const onDashboard = pathname.endsWith("dashboard");
16 | const router = useRouter();
17 | return (
18 | <>
19 |
25 |
26 | {onDashboard ? (
27 |
31 | ) : null}
32 |
33 |
34 | >
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/app/whiteboard/page.tsx:
--------------------------------------------------------------------------------
1 | export default function Default() {
2 | return null;
3 | }
4 |
--------------------------------------------------------------------------------
/components/Cursors/Cursor.module.css:
--------------------------------------------------------------------------------
1 | .cursor {
2 | position: absolute;
3 | z-index: var(--z-above);
4 | top: 0;
5 | left: 0;
6 | pointer-events: none;
7 | transition: transform var(--transition-linear);
8 | user-select: none;
9 | }
10 |
11 | .pointer {
12 | position: absolute;
13 | top: 0;
14 | left: 0;
15 | }
16 |
17 | .name {
18 | position: absolute;
19 | top: var(--space-8);
20 | left: var(--space-8);
21 | overflow: hidden;
22 | padding: 0.375rem 0.75rem;
23 | border-radius: var(--radius-sm);
24 | font-size: var(--size-sm);
25 | font-weight: 500;
26 | white-space: nowrap;
27 | }
28 |
--------------------------------------------------------------------------------
/components/Cursors/Cursor.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { ComponentProps, useMemo } from "react";
3 | import { getContrastingColor } from "@/utils";
4 | import styles from "./Cursor.module.css";
5 |
6 | interface Props extends Omit, "color"> {
7 | color: string;
8 | name: string;
9 | x: number;
10 | y: number;
11 | }
12 |
13 | export function Cursor({
14 | x,
15 | y,
16 | color,
17 | name,
18 | className,
19 | style,
20 | ...props
21 | }: Props) {
22 | const textColor = useMemo(
23 | () => (color ? getContrastingColor(color) : undefined),
24 | [color]
25 | );
26 |
27 | return (
28 |
33 |
45 |
52 | {name}
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/components/Cursors/Cursors.tsx:
--------------------------------------------------------------------------------
1 | import { MutableRefObject, useEffect } from "react";
2 | import { useOthers, useUpdateMyPresence } from "@/liveblocks.config";
3 | import { Cursor } from "./Cursor";
4 |
5 | interface Props {
6 | // The element that's used for pointer events and scroll position
7 | element: MutableRefObject;
8 | }
9 |
10 | /**
11 | * This file shows you how to create a reusable live cursors component for your product.
12 | * The component takes a reference to another element ref `element` and renders
13 | * cursors according to the location and scroll position of this panel.
14 | */
15 | export function Cursors({ element }: Props) {
16 | /**
17 | * useMyPresence returns a function to update the current user's presence.
18 | * updateMyPresence is different to the setState function returned by the useState hook from React.
19 | * You don't need to pass the full presence object to update it.
20 | * See https://liveblocks.io/docs/api-reference/liveblocks-react#useUpdateMyPresence for more information
21 | */
22 | const updateMyPresence = useUpdateMyPresence();
23 |
24 | /**
25 | * Return all the other users in the room and their presence (a cursor position in this case)
26 | */
27 | const others = useOthers();
28 |
29 | useEffect(() => {
30 | if (!element.current) {
31 | return;
32 | }
33 |
34 | // If element, add live cursor listeners
35 | const updateCursor = (event: PointerEvent) => {
36 | if (!element?.current) {
37 | return;
38 | }
39 |
40 | const { top, left } = element.current!.getBoundingClientRect();
41 |
42 | const x = event.clientX - left + element.current!.scrollLeft;
43 | const y = event.clientY - top + element.current!.scrollTop;
44 |
45 | updateMyPresence({
46 | cursor: {
47 | x: Math.round(x),
48 | y: Math.round(y),
49 | },
50 | });
51 | };
52 |
53 | const removeCursor = () => {
54 | updateMyPresence({
55 | cursor: null,
56 | });
57 | };
58 |
59 | element.current!.addEventListener("pointermove", updateCursor);
60 | element.current!.addEventListener("pointerleave", removeCursor);
61 |
62 | // Clean up event listeners
63 | const oldRef = element.current;
64 | return () => {
65 | if (!oldRef) {
66 | return;
67 | }
68 | oldRef.removeEventListener("pointermove", updateCursor);
69 | oldRef.removeEventListener("pointerleave", removeCursor);
70 | };
71 | }, [updateMyPresence, element]);
72 |
73 | return (
74 | <>
75 | {
76 | /**
77 | * Iterate over other users and display a cursor based on their presence
78 | */
79 | others.map(({ connectionId, presence }) => {
80 | if (presence == null || presence.cursor == null) {
81 | return null;
82 | }
83 |
84 | return (
85 |
94 | );
95 | })
96 | }
97 | >
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/components/Cursors/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Cursors";
2 |
--------------------------------------------------------------------------------
/components/Dashboard/Dashboard.module.css:
--------------------------------------------------------------------------------
1 | .dashboard {
2 | background: rgb(var(--color-gray-50));
3 | }
4 |
5 | .dashboardHeader {
6 | font-size: var(--size-xl);
7 | font-weight: 600;
8 | margin: var(--space-10) 0;
9 | }
10 |
11 | .dashboardList {
12 | max-width: 800px;
13 | margin: 50px auto 0;
14 | display: flex;
15 | flex-direction: column;
16 | padding: var(--space-12);
17 | }
18 |
--------------------------------------------------------------------------------
/components/Dashboard/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | import { DocumentRow } from "@/components/Dashboard/DocumentRow";
2 | import styles from "./Dashboard.module.css";
3 |
4 | const ids = [...Array(30).keys()];
5 |
6 | export function Dashboard() {
7 | return (
8 |
9 |
10 |
11 | {ids.map((id) => (
12 |
13 | ))}
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/components/Dashboard/DocumentIcon.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export function DocumentIcon(props: ComponentProps<"svg">) {
4 | return (
5 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/components/Dashboard/DocumentRow.module.css:
--------------------------------------------------------------------------------
1 | .row {
2 | display: flex;
3 | place-items: center;
4 | position: relative;
5 | border: 1px solid var(--color-border);
6 | border-bottom: 0;
7 | background: var(--color-surface-elevated);
8 | cursor: pointer;
9 | }
10 |
11 | .row:first-of-type {
12 | border-top-left-radius: var(--radius);
13 | border-top-right-radius: var(--radius);
14 | }
15 |
16 | .row:last-of-type {
17 | border-bottom: 1px solid var(--color-border);
18 | border-bottom-left-radius: var(--radius);
19 | border-bottom-right-radius: var(--radius);
20 | }
21 |
22 | .container {
23 | display: grid;
24 | flex: 1 0 auto;
25 | width: 100%;
26 | padding: var(--space-8) var(--space-19) var(--space-8) var(--space-8);
27 | gap: var(--space-8);
28 | grid-template-columns: auto 1fr auto;
29 | outline: none;
30 | }
31 |
32 | .icon,
33 | .info {
34 | transition: opacity var(--transition);
35 | }
36 |
37 | .link:hover .icon,
38 | .link:hover .info,
39 | .link:focus-visible .icon,
40 | .link:focus-visible .info {
41 | opacity: var(--opacity-hover);
42 | }
43 |
44 | .icon {
45 | display: flex;
46 | width: 40px;
47 | height: 52px;
48 | border: 1px solid var(--color-border);
49 | background: var(--color-surface-elevated);
50 | border-radius: var(--radius-xs);
51 | box-shadow: var(--shadow-xs);
52 | place-content: center;
53 | place-items: center;
54 | }
55 |
56 | .info {
57 | display: flex;
58 | flex-direction: column;
59 | place-content: center;
60 | }
61 |
62 | .infoSkeleton {
63 | gap: var(--space-4);
64 | }
65 |
66 | .documentName {
67 | font-size: var(--size-sm);
68 | font-weight: 500;
69 | }
70 |
71 | .documentDate {
72 | color: var(--color-text-lighter);
73 | font-size: var(--size-xs);
74 | }
75 |
76 | .groups {
77 | display: inline-flex;
78 | margin-left: var(--space-3);
79 | gap: var(--space-3);
80 | }
81 |
82 | .group {
83 | position: relative;
84 | display: inline-block;
85 | padding: 0 var(--space-3);
86 | border-radius: 1em;
87 | color: var(--color-text-lighter);
88 | background: var(--color-surface);
89 | font-size: 0.8em;
90 | }
91 |
92 | .presence {
93 | display: flex;
94 | place-items: center;
95 | }
96 |
97 | .more {
98 | position: absolute;
99 | right: var(--space-10);
100 | }
101 |
102 | .morePopover {
103 | display: flex;
104 | min-width: 160px;
105 | max-width: 320px;
106 | flex-direction: column;
107 | padding: var(--space-3);
108 | }
109 |
--------------------------------------------------------------------------------
/components/Dashboard/DocumentRow.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./DocumentRow.module.css";
2 | import clsx from "clsx";
3 | import { DocumentIcon } from "@/components/Dashboard/DocumentIcon";
4 | import Link from "next/link";
5 |
6 | export function DocumentRow({ id }: { id: number | string }) {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 | Untitled
15 | Edited just now
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/components/Header/Header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | position: fixed;
3 | top: 0;
4 | width: 100%;
5 | padding: var(--space-8);
6 | background: var(--color-surface-elevated);
7 | border-bottom: 1px solid var(--color-border);
8 | }
9 |
--------------------------------------------------------------------------------
/components/Header/Header.tsx:
--------------------------------------------------------------------------------
1 | import { Logo } from "@/components/Logo";
2 | import Link from "next/link";
3 | import styles from "./Header.module.css";
4 |
5 | export function Header() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/components/Logo/Logo.module.css:
--------------------------------------------------------------------------------
1 | .logo {
2 | display: flex;
3 | font-size: var(--size-lg);
4 | font-weight: 700;
5 | place-items: center;
6 | }
7 |
8 | .mark {
9 | width: 1.35em;
10 | height: 1.35em;
11 | margin-right: 0.35em;
12 | }
13 |
14 | .wordmark {
15 | white-space: nowrap;
16 | }
17 |
--------------------------------------------------------------------------------
/components/Logo/Logo.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { ComponentProps } from "react";
3 | import styles from "./Logo.module.css";
4 |
5 | export function Logo({ className, ...props }: ComponentProps<"div">) {
6 | return (
7 |
8 |
21 |
Dashboard
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/components/Logo/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Logo";
2 |
--------------------------------------------------------------------------------
/components/Whiteboard/Whiteboard.module.css:
--------------------------------------------------------------------------------
1 | .canvas {
2 | position: relative;
3 | overflow: hidden;
4 | width: 100%;
5 | height: 100%;
6 | }
7 |
8 | .toolbar {
9 | position: absolute;
10 | z-index: var(--z-above);
11 | bottom: var(--space-10);
12 | left: 50%;
13 | display: flex;
14 | padding: var(--space-4);
15 | border: 1px solid var(--color-border);
16 | background: var(--color-surface-elevated);
17 | border-radius: var(--radius);
18 | box-shadow: var(--shadow-lg);
19 | transform: translateX(-50%);
20 | }
21 |
22 | .loading {
23 | position: absolute;
24 | display: flex;
25 | color: var(--color-text-lighter);
26 | inset: 0;
27 | place-content: center;
28 | place-items: center;
29 | }
30 |
--------------------------------------------------------------------------------
/components/Whiteboard/Whiteboard.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { LiveMap, LiveObject, shallow } from "@liveblocks/client";
3 | import { ClientSideSuspense } from "@liveblocks/react";
4 | import { nanoid } from "nanoid";
5 | import {
6 | ChangeEvent,
7 | ComponentProps,
8 | FocusEvent,
9 | PointerEvent,
10 | useRef,
11 | useState,
12 | } from "react";
13 | import { PlusIcon, RedoIcon, UndoIcon } from "@/icons";
14 | import {
15 | RoomProvider,
16 | useCanRedo,
17 | useCanUndo,
18 | useHistory,
19 | useMutation,
20 | useSelf,
21 | useStorage,
22 | } from "@/liveblocks.config";
23 | import { Button } from "@/primitives/Button";
24 | import { Spinner } from "@/primitives/Spinner";
25 | import { Tooltip } from "@/primitives/Tooltip";
26 | import { useBoundingClientRectRef } from "@/utils";
27 | import { Cursors } from "../Cursors";
28 | import { WhiteboardNote } from "./WhiteboardNote";
29 | import styles from "./Whiteboard.module.css";
30 | import { randomUser } from "@/utils/randomUser";
31 | import { TooltipProvider } from "@radix-ui/react-tooltip";
32 |
33 | /**
34 | * This file shows how to create a multiplayer canvas with draggable notes.
35 | * The notes allow you to add text, display who's currently editing them, and can be removed.
36 | * There's also a toolbar allowing you to undo/redo your actions and add more notes.
37 | */
38 |
39 | export function Whiteboard({ roomId }: { roomId: string }) {
40 | const loading = (
41 |
42 |
43 |
44 | );
45 |
46 | return (
47 |
48 |
53 |
54 | {() => }
55 |
56 |
57 |
58 | );
59 | }
60 |
61 | // The main Liveblocks code, handling all events and note modifications
62 | function Canvas({ className, style, ...props }: ComponentProps<"div">) {
63 | // An array of every note id
64 | const noteIds: string[] = useStorage(
65 | (root) => Array.from(root.notes.keys()),
66 | shallow
67 | );
68 |
69 | const currentUser = useSelf((me) => me.presence.info);
70 |
71 | const history = useHistory();
72 | const canUndo = useCanUndo();
73 | const canRedo = useCanRedo();
74 |
75 | const canvasRef = useRef(null);
76 | const rectRef = useBoundingClientRectRef(canvasRef);
77 |
78 | const isReadOnly = useSelf((me) => me.isReadOnly);
79 |
80 | // Info about element being dragged
81 | const [isDragging, setIsDragging] = useState(false);
82 | const dragInfo = useRef<{
83 | element: Element;
84 | noteId: string;
85 | offset: { x: number; y: number };
86 | } | null>();
87 |
88 | // Insert a new note onto the canvas
89 | const insertNote = useMutation(({ storage, self }) => {
90 | if (self.isReadOnly) {
91 | return;
92 | }
93 |
94 | const noteId = nanoid();
95 | const note = new LiveObject({
96 | x: getRandomInt(300),
97 | y: getRandomInt(300),
98 | text: "",
99 | selectedBy: null,
100 | id: noteId,
101 | });
102 | storage.get("notes").set(noteId, note);
103 | }, []);
104 |
105 | // Delete a note
106 | const handleNoteDelete = useMutation(({ storage, self }, noteId) => {
107 | if (self.isReadOnly) {
108 | return;
109 | }
110 |
111 | storage.get("notes").delete(noteId);
112 | }, []);
113 |
114 | // Update a note, if it exists
115 | const handleNoteUpdate = useMutation(({ storage, self }, noteId, updates) => {
116 | if (self.isReadOnly) {
117 | return;
118 | }
119 |
120 | const note = storage.get("notes").get(noteId);
121 | if (note) {
122 | note.update(updates);
123 | }
124 | }, []);
125 |
126 | // On note pointer down, pause history, set dragged note
127 | function handleNotePointerDown(
128 | e: PointerEvent,
129 | noteId: string
130 | ) {
131 | history.pause();
132 | e.stopPropagation();
133 | const element = document.querySelector(`[data-note="${noteId}"]`);
134 | if (!element) {
135 | return;
136 | }
137 |
138 | // Get position of cursor on note, to use as an offset when moving notes
139 | const rect = element.getBoundingClientRect();
140 | const offset = {
141 | x: e.clientX - rect.left,
142 | y: e.clientY - rect.top,
143 | };
144 |
145 | dragInfo.current = { noteId, element, offset };
146 | setIsDragging(true);
147 | document.documentElement.classList.add("grabbing");
148 | }
149 |
150 | // On canvas pointer up, remove dragged element, resume history
151 | function handleCanvasPointerUp() {
152 | setIsDragging(false);
153 | dragInfo.current = null;
154 | document.documentElement.classList.remove("grabbing");
155 | history.resume();
156 | }
157 |
158 | // If dragging on canvas pointer move, move element and adjust for offset
159 | function handleCanvasPointerMove(e: PointerEvent) {
160 | e.preventDefault();
161 |
162 | if (isDragging && dragInfo.current) {
163 | const { x, y } = dragInfo.current!.offset;
164 | const coords = {
165 | x: e.clientX - rectRef.current.x - x,
166 | y: e.clientY - rectRef.current.y - y,
167 | };
168 | handleNoteUpdate(dragInfo.current!.noteId, coords);
169 | }
170 | }
171 |
172 | // When note text is changed, update the text and selected user on the LiveObject
173 | function handleNoteChange(
174 | e: ChangeEvent,
175 | noteId: string
176 | ) {
177 | handleNoteUpdate(noteId, { text: e.target.value, selectedBy: currentUser });
178 | }
179 |
180 | // When note is focused, update the selected user LiveObject
181 | function handleNoteFocus(e: FocusEvent, noteId: string) {
182 | history.pause();
183 | handleNoteUpdate(noteId, { selectedBy: currentUser });
184 | }
185 |
186 | // When note is unfocused, remove the selected user on the LiveObject
187 | function handleNoteBlur(e: FocusEvent, noteId: string) {
188 | handleNoteUpdate(noteId, { selectedBy: null });
189 | history.resume();
190 | }
191 |
192 | return (
193 |
201 |
202 | {
203 | /*
204 | * Iterate through each note in the LiveMap and render it as a note
205 | */
206 | noteIds.map((id) => (
207 |
handleNoteBlur(e, id)}
212 | onChange={(e) => handleNoteChange(e, id)}
213 | onDelete={() => handleNoteDelete(id)}
214 | onFocus={(e) => handleNoteFocus(e, id)}
215 | onPointerDown={(e) => handleNotePointerDown(e, id)}
216 | />
217 | ))
218 | }
219 |
220 | {!isReadOnly && (
221 |
222 |
223 | } onClick={insertNote} variant="subtle" />
224 |
225 |
226 | }
229 | onClick={history.undo}
230 | variant="subtle"
231 | />
232 |
233 |
234 | }
237 | onClick={history.redo}
238 | variant="subtle"
239 | />
240 |
241 |
242 | )}
243 |
244 | );
245 | }
246 |
247 | function getRandomInt(max: number) {
248 | return Math.floor(Math.random() * max);
249 | }
250 |
--------------------------------------------------------------------------------
/components/Whiteboard/WhiteboardNote.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | position: absolute;
3 | width: auto;
4 | min-width: 250px;
5 | max-width: 400px;
6 | min-height: 100px;
7 | transition: transform var(--transition-linear);
8 | }
9 |
10 | .note {
11 | display: flex;
12 | overflow: hidden;
13 | flex-direction: column;
14 | padding: var(--space-5) var(--space-8) var(--space-7);
15 | border: 1px solid var(--color-border);
16 | background: var(--color-surface-elevated);
17 | border-radius: var(--radius);
18 | box-shadow: var(--shadow);
19 | gap: var(--space-5);
20 | }
21 |
22 | .header {
23 | display: flex;
24 | height: var(--space-13);
25 | flex: none;
26 | place-content: space-between;
27 | place-items: center;
28 | }
29 |
30 | .deleteButton {
31 | margin-left: calc(-1 * var(--space-3));
32 | }
33 |
34 | .content {
35 | position: relative;
36 | flex: 1 0 auto;
37 | }
38 |
39 | .textAreaSize {
40 | overflow: hidden;
41 | width: 100%;
42 | max-width: 100%;
43 | visibility: hidden;
44 | white-space: pre-wrap;
45 | word-wrap: break-word;
46 | }
47 |
48 | .textArea {
49 | position: absolute;
50 | top: 0;
51 | left: 0;
52 | display: block;
53 | overflow: hidden;
54 | width: 100%;
55 | height: 100%;
56 | background: transparent;
57 | outline: none;
58 | resize: none;
59 | word-wrap: break-word;
60 | }
61 |
62 | .textArea::placeholder {
63 | color: var(--color-text-lightest);
64 | }
65 |
--------------------------------------------------------------------------------
/components/Whiteboard/WhiteboardNote.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import {
3 | ChangeEventHandler,
4 | ComponentProps,
5 | FocusEventHandler,
6 | KeyboardEvent,
7 | memo,
8 | PointerEventHandler,
9 | useCallback,
10 | useRef,
11 | } from "react";
12 | import { CrossIcon } from "../../icons";
13 | import { useStorage } from "../../liveblocks.config";
14 | import { Avatar } from "../../primitives/Avatar";
15 | import { Button } from "../../primitives/Button";
16 | import styles from "./WhiteboardNote.module.css";
17 |
18 | interface Props
19 | extends Omit<
20 | ComponentProps<"div">,
21 | "id" | "onBlur" | "onChange" | "onFocus"
22 | > {
23 | dragged: boolean;
24 | id: string;
25 | onBlur: FocusEventHandler;
26 | onChange: ChangeEventHandler;
27 | onDelete: () => void;
28 | onFocus: FocusEventHandler;
29 | onPointerDown: PointerEventHandler;
30 | }
31 |
32 | export const WhiteboardNote = memo(
33 | ({
34 | id,
35 | dragged,
36 | onPointerDown,
37 | onDelete,
38 | onChange,
39 | onFocus,
40 | onBlur,
41 | style,
42 | className,
43 | ...props
44 | }: Props) => {
45 | const textAreaRef = useRef(null);
46 | const note = useStorage((root) => root.notes.get(id));
47 |
48 | const handleDoubleClick = useCallback(() => {
49 | textAreaRef.current?.focus();
50 | }, []);
51 |
52 | const handleKeyDown = useCallback(
53 | (event: KeyboardEvent) => {
54 | if (event.key === "Escape") {
55 | textAreaRef.current?.blur();
56 | }
57 | },
58 | []
59 | );
60 |
61 | if (!note) {
62 | return null;
63 | }
64 |
65 | const { x, y, text, selectedBy } = note;
66 |
67 | return (
68 |
82 |
83 |
84 |
}
87 | onClick={onDelete}
88 | variant="subtle"
89 | />
90 |
91 | {selectedBy ? (
92 |
98 | ) : null}
99 |
100 |
101 |
115 |
116 |
117 | );
118 | }
119 | );
120 |
--------------------------------------------------------------------------------
/components/Whiteboard/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Whiteboard";
2 | export * from "./WhiteboardNote";
3 |
--------------------------------------------------------------------------------
/icons/Cross.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export function CrossIcon(props: ComponentProps<"svg">) {
4 | return (
5 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/icons/Plus.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export function PlusIcon(props: ComponentProps<"svg">) {
4 | return (
5 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/icons/Redo.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export function RedoIcon(props: ComponentProps<"svg">) {
4 | return (
5 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/icons/Undo.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from "react";
2 |
3 | export function UndoIcon(props: ComponentProps<"svg">) {
4 | return (
5 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/icons/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Plus";
2 | export * from "./Cross";
3 | export * from "./Undo";
4 | export * from "./Redo";
5 |
--------------------------------------------------------------------------------
/liveblocks.config.ts:
--------------------------------------------------------------------------------
1 | import { createClient, LiveMap, LiveObject } from "@liveblocks/client";
2 | import { createRoomContext } from "@liveblocks/react";
3 |
4 | const client = createClient({
5 | publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY,
6 | throttle: 16,
7 | });
8 |
9 | // Presence represents the properties that will exist on every User in the Room
10 | // and that will automatically be kept in sync. Accessible through the
11 | // `user.presence` property. Must be JSON-serializable.
12 | export type Presence = {
13 | cursor: { x: number; y: number } | null;
14 | info: {
15 | name: string;
16 | color: string;
17 | };
18 | };
19 |
20 | export type Note = LiveObject<{
21 | x: number;
22 | y: number;
23 | text: string;
24 | selectedBy: UserMeta["info"] | null;
25 | id: string;
26 | }>;
27 |
28 | export type Notes = LiveMap;
29 |
30 | // Optionally, Storage represents the shared document that persists in the
31 | // Room, even after all Users leave. Fields under Storage typically are
32 | // LiveList, LiveMap, LiveObject instances, for which updates are
33 | // automatically persisted and synced to all connected clients.
34 | type Storage = {
35 | notes: Notes;
36 | };
37 |
38 | // Optionally, UserMeta represents static/readonly metadata on each User, as
39 | // provided by your own custom auth backend (if used). Useful for data that
40 | // will not change during a session, like a User's name or avatar.
41 | export type UserMeta = {};
42 |
43 | // Optionally, the type of custom events broadcast and listened to in this
44 | // room. Must be JSON-serializable.
45 | type RoomEvent = {};
46 |
47 | export const {
48 | suspense: {
49 | RoomProvider,
50 | useHistory,
51 | useCanUndo,
52 | useCanRedo,
53 | useMutation,
54 | useOthers,
55 | useSelf,
56 | useStorage,
57 | useUpdateMyPresence,
58 | },
59 | /* ...all the other hooks you’re using... */
60 | } = createRoomContext(client);
61 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | experimental: {
5 | appDir: true,
6 | },
7 | };
8 |
9 | module.exports = nextConfig;
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "liveblocks-nextjs-13.3-routes",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@liveblocks/client": "^1.0.2",
13 | "@liveblocks/node": "^1.0.2",
14 | "@liveblocks/react": "^1.0.2",
15 | "@radix-ui/react-tooltip": "^1.0.5",
16 | "@types/node": "18.15.11",
17 | "@types/react": "18.0.33",
18 | "@types/react-dom": "18.0.11",
19 | "clsx": "^1.2.1",
20 | "next": "13.3.0",
21 | "react": "18.2.0",
22 | "react-dom": "18.2.0",
23 | "typescript": "5.0.4"
24 | },
25 | "devDependencies": {
26 | "@typescript-eslint/eslint-plugin": "^5.57.1",
27 | "@typescript-eslint/parser": "^5.57.1",
28 | "eslint": "^8.38.0",
29 | "eslint-config-next": "^13.3.0",
30 | "eslint-config-prettier": "^8.8.0",
31 | "eslint-plugin-prettier": "^4.2.1"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/primitives/Avatar/Avatar.module.css:
--------------------------------------------------------------------------------
1 | .avatar {
2 | position: relative;
3 | display: flex;
4 | background: var(--color-surface);
5 | border-radius: 100%;
6 | color: var(--color-text-lighter);
7 | place-content: center;
8 | place-items: center;
9 | }
10 |
11 | .avatar::before,
12 | .avatar::after {
13 | position: absolute;
14 | border-radius: inherit;
15 | content: "";
16 | inset: 0;
17 | pointer-events: none;
18 | }
19 |
20 | .avatarOutline::before {
21 | box-shadow: 0 0 0 2px var(--color-surface-elevated), 0 0 0 4px currentColor;
22 | }
23 |
24 | .avatar::after {
25 | box-shadow: inset 0 0 0 1px var(--color-border-transparent);
26 | }
27 |
28 | .avatar img {
29 | position: absolute;
30 | border-radius: inherit;
31 | inset: 0;
32 | }
33 |
34 | .label {
35 | color: var(--color-text-lighter);
36 | font-weight: 500;
37 | }
38 |
--------------------------------------------------------------------------------
/primitives/Avatar/Avatar.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import Image from "next/image";
3 | import { ComponentProps } from "react";
4 | import { getInitials } from "../../utils";
5 | import { Tooltip } from "../Tooltip";
6 | import styles from "./Avatar.module.css";
7 |
8 | const DEFAULT_SIZE = 24;
9 | const FONT_SIZE_FACTOR = 0.36;
10 |
11 | export interface Props extends Omit, "color"> {
12 | src?: string;
13 | name: string;
14 | size?: number;
15 | color?: string;
16 | outline?: boolean;
17 | tooltip?: boolean;
18 | tooltipProps?: Omit, "children" | "content">;
19 | }
20 |
21 | interface EllipsisProps extends ComponentProps<"div"> {
22 | ellipsis: number;
23 | size?: number;
24 | outline?: boolean;
25 | tooltip?: boolean;
26 | tooltipProps?: Omit, "children" | "content">;
27 | }
28 |
29 | export function Avatar({
30 | src,
31 | size = DEFAULT_SIZE,
32 | outline = false,
33 | name,
34 | color,
35 | tooltip = false,
36 | tooltipProps,
37 | className,
38 | style,
39 | ...props
40 | }: Props) {
41 | const content = (
42 |
52 | {src && (
53 |
54 | )}
55 |
60 | {getInitials(name)}
61 |
62 |
63 | );
64 |
65 | return tooltip ? (
66 |
67 | {content}
68 |
69 | ) : (
70 | content
71 | );
72 | }
73 |
74 | export function AvatarEllipsis({
75 | ellipsis,
76 | size = DEFAULT_SIZE,
77 | outline = false,
78 | tooltip = false,
79 | tooltipProps,
80 | className,
81 | style,
82 | ...props
83 | }: EllipsisProps) {
84 | const content = (
85 |
94 |
98 | +{ellipsis}
99 |
100 |
101 | );
102 |
103 | return tooltip ? (
104 | 1 ? "s" : ""}`}
106 | {...tooltipProps}
107 | >
108 | {content}
109 |
110 | ) : (
111 | content
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/primitives/Avatar/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Avatar";
2 |
--------------------------------------------------------------------------------
/primitives/Button/Button.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | display: flex;
3 | height: var(--space-13);
4 | padding: 0 var(--space-7);
5 | border-radius: var(--radius-sm);
6 | place-items: center;
7 | transition: var(--transition);
8 | transition-property: background, color, opacity;
9 | outline: none;
10 | }
11 |
12 | .button:disabled {
13 | cursor: default;
14 | opacity: var(--opacity-disabled);
15 | }
16 |
17 | .iconButton {
18 | width: var(--space-13);
19 | padding: 0;
20 | place-content: center;
21 | }
22 |
23 | .buttonPrimary {
24 | background: var(--color-accent);
25 | color: var(--color-surface-elevated);
26 | }
27 |
28 | .buttonDestructive {
29 | background: rgb(var(--color-red));
30 | color: var(--color-surface-elevated);
31 | }
32 |
33 | .buttonPrimary:hover,
34 | .buttonPrimary:focus-visible,
35 | .buttonDestructive:hover,
36 | .buttonDestructive:focus-visible {
37 | opacity: var(--opacity-hover);
38 | }
39 |
40 | .buttonSecondary {
41 | background: var(--color-surface);
42 | color: var(--color-text-light);
43 | }
44 |
45 | .buttonSecondary:hover,
46 | .buttonSecondary:focus-visible {
47 | background: var(--color-surface-hover);
48 | color: var(--color-text);
49 | }
50 |
51 | .buttonSubtle {
52 | color: var(--color-text-lighter);
53 | }
54 |
55 | .buttonSubtle:not(:disabled):hover,
56 | .buttonSubtle:not(:disabled):focus-visible,
57 | .buttonSubtle[data-active] {
58 | background: var(--color-surface);
59 | color: var(--color-text-light);
60 | }
61 |
62 | .button:not(.iconButton) .icon {
63 | margin-right: var(--space-4);
64 | margin-left: calc(-1 * var(--space-1));
65 | }
66 |
67 | .label {
68 | font-size: var(--size-sm);
69 | font-weight: 500;
70 | }
71 |
--------------------------------------------------------------------------------
/primitives/Button/Button.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { ComponentProps, forwardRef, ReactNode } from "react";
3 | import styles from "./Button.module.css";
4 |
5 | interface Props {
6 | variant?: "primary" | "secondary" | "subtle" | "destructive";
7 | icon?: ReactNode;
8 | }
9 |
10 | export const Button = forwardRef<
11 | HTMLButtonElement,
12 | ComponentProps<"button"> & Props
13 | >(({ variant = "primary", icon, children, className, ...props }, ref) => (
14 |
32 | ));
33 |
--------------------------------------------------------------------------------
/primitives/Button/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Button";
2 |
--------------------------------------------------------------------------------
/primitives/Container/Container.module.css:
--------------------------------------------------------------------------------
1 | .container {
2 | padding-right: var(--space-8);
3 | padding-left: var(--space-8);
4 | margin-right: auto;
5 | margin-left: auto;
6 | }
7 |
8 | .containerSmall {
9 | max-width: 760px;
10 | }
11 |
12 | .containerMedium {
13 | max-width: 960px;
14 | }
15 |
16 | .containerLarge {
17 | max-width: 1160px;
18 | }
19 |
--------------------------------------------------------------------------------
/primitives/Container/Container.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { ComponentProps } from "react";
3 | import styles from "./Container.module.css";
4 |
5 | interface Props extends ComponentProps<"div"> {
6 | size?: "small" | "medium" | "large";
7 | }
8 |
9 | export function Container({
10 | size = "medium",
11 | className,
12 | children,
13 | ...props
14 | }: Props) {
15 | return (
16 |
24 | {children}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/primitives/Container/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Container";
2 |
--------------------------------------------------------------------------------
/primitives/Input/Input.module.css:
--------------------------------------------------------------------------------
1 | .input {
2 | height: var(--space-13);
3 | padding: 0 var(--space-5);
4 | background: var(--color-surface);
5 | border-radius: var(--radius-sm);
6 | font-size: var(--size-sm);
7 | color: var(--color-text-light);
8 | transition: var(--transition);
9 | transition-property: background, color, opacity;
10 | outline: none;
11 | }
12 |
13 | .input:hover,
14 | .input:focus-visible {
15 | background: var(--color-surface-hover);
16 | color: var(--color-text);
17 | }
18 |
19 | .input:disabled {
20 | cursor: default;
21 | opacity: var(--opacity-disabled);
22 | }
23 |
24 | .input::placeholder {
25 | color: var(--color-text-lightest);
26 | }
27 |
--------------------------------------------------------------------------------
/primitives/Input/Input.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { ComponentProps, forwardRef } from "react";
3 | import styles from "./Input.module.css";
4 |
5 | export const Input = forwardRef>(
6 | ({ className, ...props }, ref) => {
7 | return (
8 |
9 | );
10 | }
11 | );
12 |
--------------------------------------------------------------------------------
/primitives/Input/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Input";
2 |
--------------------------------------------------------------------------------
/primitives/Skeleton/Skeleton.module.css:
--------------------------------------------------------------------------------
1 | @keyframes skeleton {
2 | 0% {
3 | background-position: 200% 0;
4 | }
5 |
6 | 100% {
7 | background-position: -200% 0;
8 | }
9 | }
10 |
11 | .skeleton {
12 | display: block;
13 | height: 1em;
14 | animation: skeleton 6s linear infinite;
15 | background: linear-gradient(
16 | 270deg,
17 | var(--color-skeleton),
18 | var(--color-skeleton-shine),
19 | var(--color-skeleton-shine),
20 | var(--color-skeleton)
21 | );
22 | background-size: 400% 100%;
23 | border-radius: var(--radius-xs);
24 | }
25 |
--------------------------------------------------------------------------------
/primitives/Skeleton/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { ComponentProps } from "react";
3 | import styles from "./Skeleton.module.css";
4 |
5 | export function Skeleton({ className, ...props }: ComponentProps<"span">) {
6 | return ;
7 | }
8 |
--------------------------------------------------------------------------------
/primitives/Skeleton/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Skeleton";
2 |
--------------------------------------------------------------------------------
/primitives/Spinner/Spinner.module.css:
--------------------------------------------------------------------------------
1 | @keyframes spin {
2 | 0% {
3 | transform: rotate(0deg);
4 | }
5 |
6 | 100% {
7 | transform: rotate(360deg);
8 | }
9 | }
10 |
11 | .spinner {
12 | animation: spin 1s linear infinite;
13 | }
14 |
--------------------------------------------------------------------------------
/primitives/Spinner/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { ComponentProps } from "react";
3 | import styles from "./Spinner.module.css";
4 |
5 | export interface Props extends ComponentProps<"svg"> {
6 | size?: number;
7 | }
8 |
9 | export function Spinner({ size = 16, className, ...props }: Props) {
10 | return (
11 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/primitives/Spinner/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Spinner";
2 |
--------------------------------------------------------------------------------
/primitives/Tooltip/Tooltip.module.css:
--------------------------------------------------------------------------------
1 | @keyframes up {
2 | 0% {
3 | opacity: 0;
4 | transform: translateY(var(--space-3));
5 | }
6 |
7 | 100% {
8 | opacity: 1;
9 | transform: translateY(0);
10 | }
11 | }
12 |
13 | @keyframes down {
14 | 0% {
15 | opacity: 0;
16 | transform: translateY(calc(-1 * var(--space-3)));
17 | }
18 |
19 | 100% {
20 | opacity: 1;
21 | transform: translateY(0);
22 | }
23 | }
24 |
25 | .tooltip {
26 | z-index: var(--z-above);
27 | padding: var(--space-2) var(--space-5);
28 | border: 1px solid var(--color-tooltip-border);
29 | background: var(--color-tooltip);
30 | border-radius: var(--radius-sm);
31 | box-shadow: var(--shadow-lg);
32 | color: var(--color-tooltip-text);
33 | font-size: var(--size-sm);
34 | font-weight: 500;
35 | animation: 0.15s ease-in-out;
36 | }
37 |
38 | .tooltip[data-side="top"] {
39 | animation-name: up;
40 | }
41 | .tooltip[data-side="bottom"] {
42 | animation-name: down;
43 | }
44 |
--------------------------------------------------------------------------------
/primitives/Tooltip/Tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as RadixTooltip from "@radix-ui/react-tooltip";
2 | import clsx from "clsx";
3 | import { ReactNode, forwardRef } from "react";
4 | import styles from "./Tooltip.module.css";
5 |
6 | export interface Props
7 | extends RadixTooltip.TooltipProps,
8 | RadixTooltip.TooltipContentProps {
9 | content: ReactNode;
10 | }
11 |
12 | export const Tooltip = forwardRef(
13 | (
14 | {
15 | children,
16 | content,
17 | open,
18 | defaultOpen,
19 | onOpenChange,
20 | delayDuration,
21 | disableHoverableContent = true,
22 | collisionPadding = 10,
23 | sideOffset = 10,
24 | className,
25 | ...props
26 | },
27 | ref
28 | ) => {
29 | return (
30 |
37 | {children}
38 |
39 |
46 | {content}
47 |
48 |
49 |
50 | );
51 | }
52 | );
53 |
--------------------------------------------------------------------------------
/primitives/Tooltip/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Tooltip";
2 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/utils/capitalize.ts:
--------------------------------------------------------------------------------
1 | export function capitalize(string: string) {
2 | return string.charAt(0).toUpperCase() + string.slice(1);
3 | }
4 |
--------------------------------------------------------------------------------
/utils/getContrastingColor.ts:
--------------------------------------------------------------------------------
1 | export function getContrastingColor(col: string) {
2 | if (typeof window === "undefined") {
3 | return;
4 | }
5 | const useBlack = getColor(hexToRgb(standardizeColor(col)));
6 | return useBlack ? "#000000" : "#ffffff";
7 | }
8 |
9 | type RGB = {
10 | r: number;
11 | g: number;
12 | b: number;
13 | } | null;
14 |
15 | function getColor(rgb: RGB) {
16 | if (!rgb) {
17 | return;
18 | }
19 |
20 | const { r, g, b } = rgb;
21 | if (r && g && b) {
22 | const isLight = 1 - (0.299 * r + 0.587 * g + 0.114 * b) / 255;
23 | return isLight < 0.5;
24 | }
25 | return false;
26 | }
27 |
28 | function standardizeColor(str: string): string {
29 | const ctx = document.createElement("canvas").getContext("2d");
30 | if (!ctx) {
31 | return "";
32 | }
33 |
34 | ctx.fillStyle = str;
35 | return ctx.fillStyle;
36 | }
37 |
38 | function hexToRgb(hex: string): RGB {
39 | // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
40 | const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
41 | hex = hex.replace(shorthandRegex, function (m, r, g, b) {
42 | return r + r + g + g + b + b;
43 | });
44 |
45 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
46 | return result
47 | ? {
48 | r: parseInt(result[1], 16),
49 | g: parseInt(result[2], 16),
50 | b: parseInt(result[3], 16),
51 | }
52 | : null;
53 | }
54 |
--------------------------------------------------------------------------------
/utils/getInitials.ts:
--------------------------------------------------------------------------------
1 | export function getInitials(name: string) {
2 | const initials = name.replace(/[^a-zA-Z- ]/g, "").match(/\b\w/g);
3 |
4 | return initials
5 | ? initials.map((initial) => initial.toUpperCase()).join("")
6 | : "";
7 | }
8 |
--------------------------------------------------------------------------------
/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./capitalize";
2 | export * from "./getContrastingColor";
3 | export * from "./getInitials";
4 | export * from "./useBoundingClientRectRef";
5 | export * from "./normalizeTrailingSlash";
6 |
--------------------------------------------------------------------------------
/utils/normalizeTrailingSlash.ts:
--------------------------------------------------------------------------------
1 | export function normalizeTrailingSlash(string: string) {
2 | return string.replace(/\/$/, "");
3 | }
4 |
--------------------------------------------------------------------------------
/utils/randomUser.ts:
--------------------------------------------------------------------------------
1 | const NAMES = [
2 | "Charlie Layne",
3 | "Mislav Abha",
4 | "Tatum Paolo",
5 | "Anjali Wanda",
6 | "Jody Hekla",
7 | "Emil Joyce",
8 | "Jory Quispe",
9 | "Quinn Elton",
10 | ];
11 |
12 | const COLORS = [
13 | "#E57373",
14 | "#9575CD",
15 | "#4FC3F7",
16 | "#81C784",
17 | "#FFF176",
18 | "#FF8A65",
19 | "#F06292",
20 | "#7986CB",
21 | ];
22 |
23 | export function randomUser() {
24 | return {
25 | name: NAMES[Math.floor(Math.random() * NAMES.length)],
26 | color: COLORS[Math.floor(Math.random() * COLORS.length)],
27 | };
28 | }
29 |
--------------------------------------------------------------------------------
/utils/useBoundingClientRectRef.ts:
--------------------------------------------------------------------------------
1 | import { MutableRefObject, useEffect, useRef } from "react";
2 |
3 | const initialRect = {
4 | x: 0,
5 | y: 0,
6 | height: 0,
7 | width: 0,
8 | top: 0,
9 | right: 0,
10 | bottom: 0,
11 | left: 0,
12 | toJSON: () => "",
13 | };
14 |
15 | /**
16 | * Returns a ref containing the results of `getBoundingClientRect` for `ref`
17 | * Updates on window changes
18 | */
19 | export function useBoundingClientRectRef(
20 | ref: MutableRefObject
21 | ) {
22 | const rectRef = useRef(initialRect);
23 |
24 | useEffect(() => {
25 | const updateRect = () => {
26 | if (!(ref?.current instanceof Element)) {
27 | return;
28 | }
29 | rectRef.current = ref.current.getBoundingClientRect();
30 | };
31 |
32 | window.addEventListener("resize", updateRect);
33 | window.addEventListener("orientationchange", updateRect);
34 | updateRect();
35 |
36 | return () => {
37 | window.removeEventListener("resize", updateRect);
38 | window.removeEventListener("orientationchange", updateRect);
39 | };
40 | }, [ref]);
41 |
42 | return rectRef;
43 | }
44 |
--------------------------------------------------------------------------------