├── .codesandbox └── tasks.json ├── .firebaserc ├── .gitignore ├── .pnp.cjs ├── .pnp.loader.mjs ├── .yarn ├── install-state.gz └── releases │ └── yarn-4.0.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── __mocks__ ├── @excalidraw │ └── excalidraw.js ├── points-on-curve.js └── roughjs │ └── bin │ ├── math.js │ └── rough.js ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── index.html ├── package.json ├── postcss.config.js ├── public └── index.html ├── screenshot.png ├── src ├── environment-interface │ ├── authentication.ts │ ├── copyImageToClipboard.ts │ ├── index.tsx │ ├── loom.ts │ └── storage.ts ├── environments │ ├── authentication │ │ ├── browser.ts │ │ └── test.ts │ ├── browser.ts │ ├── copyImageToClipboard │ │ ├── browser.ts │ │ └── test.ts │ ├── loom │ │ ├── browser.ts │ │ └── test.ts │ ├── storage │ │ ├── browser.ts │ │ └── test.ts │ └── test.ts ├── favicon.svg ├── firebase.config.json ├── index.css ├── logo.svg ├── main.tsx ├── pages │ ├── dashboard │ │ ├── Dashboard.tsx │ │ ├── ExcalidrawPreview.tsx │ │ ├── Navigation.tsx │ │ ├── index.tsx │ │ ├── useDashboard.test.tsx │ │ ├── useDashboard.tsx │ │ ├── useNavigation.test.tsx │ │ ├── useNavigation.tsx │ │ └── useUserDashboard.tsx │ ├── excalidraw │ │ ├── Excalidraw.tsx │ │ ├── ExcalidrawCanvas.tsx │ │ ├── index.tsx │ │ ├── useExcalidraw │ │ │ ├── index.test.tsx │ │ │ ├── index.tsx │ │ │ ├── reducer.ts │ │ │ └── types.ts │ │ └── useRecording.tsx │ ├── index.tsx │ ├── useAuth.test.tsx │ └── useAuth.tsx └── utils.ts ├── tailwind.config.js ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.codesandbox/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // These tasks will run in order when initializing your CodeSandbox project. 3 | "setupTasks": [ 4 | { 5 | "name": "Install Dependencies", 6 | "command": "yarn install" 7 | } 8 | ], 9 | 10 | // These tasks can be run from CodeSandbox. Running one will open a log in the app. 11 | "tasks": { 12 | "auth": { 13 | "name": "auth", 14 | "command": "npx firebase login --no-localhost", 15 | "preview": { 16 | "port": 9005 17 | } 18 | }, 19 | "dev": { 20 | "name": "dev", 21 | "command": "yarn dev", 22 | "runAtStart": true, 23 | "preview": { 24 | "port": 3000 25 | } 26 | }, 27 | "build": { 28 | "name": "build", 29 | "command": "yarn build", 30 | "runAtStart": false 31 | }, 32 | "deploy": { 33 | "name": "deploy", 34 | "command": "yarn deploy", 35 | "runAtStart": false 36 | }, 37 | "serve": { 38 | "name": "serve", 39 | "command": "yarn serve", 40 | "runAtStart": false 41 | }, 42 | "test": { 43 | "name": "test", 44 | "command": "yarn test", 45 | "runAtStart": false 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "excalidraw-8b385" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .DS_Store 9 | .firebase/** 10 | ssl.crt 11 | ssl.key 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # TypeScript v1 declaration files 49 | typings/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variables file 76 | .env 77 | .env.test 78 | 79 | # parcel-bundler cache (https://parceljs.org/) 80 | .cache 81 | 82 | # Next.js build output 83 | .next 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | -------------------------------------------------------------------------------- /.pnp.loader.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { URL as URL$1, fileURLToPath, pathToFileURL } from 'url'; 3 | import path from 'path'; 4 | import { createHash } from 'crypto'; 5 | import { EOL } from 'os'; 6 | import moduleExports, { isBuiltin } from 'module'; 7 | import assert from 'assert'; 8 | 9 | const SAFE_TIME = 456789e3; 10 | 11 | const PortablePath = { 12 | root: `/`, 13 | dot: `.`, 14 | parent: `..` 15 | }; 16 | const npath = Object.create(path); 17 | const ppath = Object.create(path.posix); 18 | npath.cwd = () => process.cwd(); 19 | ppath.cwd = process.platform === `win32` ? () => toPortablePath(process.cwd()) : process.cwd; 20 | if (process.platform === `win32`) { 21 | ppath.resolve = (...segments) => { 22 | if (segments.length > 0 && ppath.isAbsolute(segments[0])) { 23 | return path.posix.resolve(...segments); 24 | } else { 25 | return path.posix.resolve(ppath.cwd(), ...segments); 26 | } 27 | }; 28 | } 29 | const contains = function(pathUtils, from, to) { 30 | from = pathUtils.normalize(from); 31 | to = pathUtils.normalize(to); 32 | if (from === to) 33 | return `.`; 34 | if (!from.endsWith(pathUtils.sep)) 35 | from = from + pathUtils.sep; 36 | if (to.startsWith(from)) { 37 | return to.slice(from.length); 38 | } else { 39 | return null; 40 | } 41 | }; 42 | npath.contains = (from, to) => contains(npath, from, to); 43 | ppath.contains = (from, to) => contains(ppath, from, to); 44 | const WINDOWS_PATH_REGEXP = /^([a-zA-Z]:.*)$/; 45 | const UNC_WINDOWS_PATH_REGEXP = /^\/\/(\.\/)?(.*)$/; 46 | const PORTABLE_PATH_REGEXP = /^\/([a-zA-Z]:.*)$/; 47 | const UNC_PORTABLE_PATH_REGEXP = /^\/unc\/(\.dot\/)?(.*)$/; 48 | function fromPortablePathWin32(p) { 49 | let portablePathMatch, uncPortablePathMatch; 50 | if (portablePathMatch = p.match(PORTABLE_PATH_REGEXP)) 51 | p = portablePathMatch[1]; 52 | else if (uncPortablePathMatch = p.match(UNC_PORTABLE_PATH_REGEXP)) 53 | p = `\\\\${uncPortablePathMatch[1] ? `.\\` : ``}${uncPortablePathMatch[2]}`; 54 | else 55 | return p; 56 | return p.replace(/\//g, `\\`); 57 | } 58 | function toPortablePathWin32(p) { 59 | p = p.replace(/\\/g, `/`); 60 | let windowsPathMatch, uncWindowsPathMatch; 61 | if (windowsPathMatch = p.match(WINDOWS_PATH_REGEXP)) 62 | p = `/${windowsPathMatch[1]}`; 63 | else if (uncWindowsPathMatch = p.match(UNC_WINDOWS_PATH_REGEXP)) 64 | p = `/unc/${uncWindowsPathMatch[1] ? `.dot/` : ``}${uncWindowsPathMatch[2]}`; 65 | return p; 66 | } 67 | const toPortablePath = process.platform === `win32` ? toPortablePathWin32 : (p) => p; 68 | const fromPortablePath = process.platform === `win32` ? fromPortablePathWin32 : (p) => p; 69 | npath.fromPortablePath = fromPortablePath; 70 | npath.toPortablePath = toPortablePath; 71 | function convertPath(targetPathUtils, sourcePath) { 72 | return targetPathUtils === npath ? fromPortablePath(sourcePath) : toPortablePath(sourcePath); 73 | } 74 | 75 | const defaultTime = new Date(SAFE_TIME * 1e3); 76 | const defaultTimeMs = defaultTime.getTime(); 77 | async function copyPromise(destinationFs, destination, sourceFs, source, opts) { 78 | const normalizedDestination = destinationFs.pathUtils.normalize(destination); 79 | const normalizedSource = sourceFs.pathUtils.normalize(source); 80 | const prelayout = []; 81 | const postlayout = []; 82 | const { atime, mtime } = opts.stableTime ? { atime: defaultTime, mtime: defaultTime } : await sourceFs.lstatPromise(normalizedSource); 83 | await destinationFs.mkdirpPromise(destinationFs.pathUtils.dirname(destination), { utimes: [atime, mtime] }); 84 | await copyImpl(prelayout, postlayout, destinationFs, normalizedDestination, sourceFs, normalizedSource, { ...opts, didParentExist: true }); 85 | for (const operation of prelayout) 86 | await operation(); 87 | await Promise.all(postlayout.map((operation) => { 88 | return operation(); 89 | })); 90 | } 91 | async function copyImpl(prelayout, postlayout, destinationFs, destination, sourceFs, source, opts) { 92 | const destinationStat = opts.didParentExist ? await maybeLStat(destinationFs, destination) : null; 93 | const sourceStat = await sourceFs.lstatPromise(source); 94 | const { atime, mtime } = opts.stableTime ? { atime: defaultTime, mtime: defaultTime } : sourceStat; 95 | let updated; 96 | switch (true) { 97 | case sourceStat.isDirectory(): 98 | { 99 | updated = await copyFolder(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); 100 | } 101 | break; 102 | case sourceStat.isFile(): 103 | { 104 | updated = await copyFile(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); 105 | } 106 | break; 107 | case sourceStat.isSymbolicLink(): 108 | { 109 | updated = await copySymlink(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); 110 | } 111 | break; 112 | default: 113 | { 114 | throw new Error(`Unsupported file type (${sourceStat.mode})`); 115 | } 116 | } 117 | if (opts.linkStrategy?.type !== `HardlinkFromIndex` || !sourceStat.isFile()) { 118 | if (updated || destinationStat?.mtime?.getTime() !== mtime.getTime() || destinationStat?.atime?.getTime() !== atime.getTime()) { 119 | postlayout.push(() => destinationFs.lutimesPromise(destination, atime, mtime)); 120 | updated = true; 121 | } 122 | if (destinationStat === null || (destinationStat.mode & 511) !== (sourceStat.mode & 511)) { 123 | postlayout.push(() => destinationFs.chmodPromise(destination, sourceStat.mode & 511)); 124 | updated = true; 125 | } 126 | } 127 | return updated; 128 | } 129 | async function maybeLStat(baseFs, p) { 130 | try { 131 | return await baseFs.lstatPromise(p); 132 | } catch (e) { 133 | return null; 134 | } 135 | } 136 | async function copyFolder(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { 137 | if (destinationStat !== null && !destinationStat.isDirectory()) { 138 | if (opts.overwrite) { 139 | prelayout.push(async () => destinationFs.removePromise(destination)); 140 | destinationStat = null; 141 | } else { 142 | return false; 143 | } 144 | } 145 | let updated = false; 146 | if (destinationStat === null) { 147 | prelayout.push(async () => { 148 | try { 149 | await destinationFs.mkdirPromise(destination, { mode: sourceStat.mode }); 150 | } catch (err) { 151 | if (err.code !== `EEXIST`) { 152 | throw err; 153 | } 154 | } 155 | }); 156 | updated = true; 157 | } 158 | const entries = await sourceFs.readdirPromise(source); 159 | const nextOpts = opts.didParentExist && !destinationStat ? { ...opts, didParentExist: false } : opts; 160 | if (opts.stableSort) { 161 | for (const entry of entries.sort()) { 162 | if (await copyImpl(prelayout, postlayout, destinationFs, destinationFs.pathUtils.join(destination, entry), sourceFs, sourceFs.pathUtils.join(source, entry), nextOpts)) { 163 | updated = true; 164 | } 165 | } 166 | } else { 167 | const entriesUpdateStatus = await Promise.all(entries.map(async (entry) => { 168 | await copyImpl(prelayout, postlayout, destinationFs, destinationFs.pathUtils.join(destination, entry), sourceFs, sourceFs.pathUtils.join(source, entry), nextOpts); 169 | })); 170 | if (entriesUpdateStatus.some((status) => status)) { 171 | updated = true; 172 | } 173 | } 174 | return updated; 175 | } 176 | async function copyFileViaIndex(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts, linkStrategy) { 177 | const sourceHash = await sourceFs.checksumFilePromise(source, { algorithm: `sha1` }); 178 | const indexPath = destinationFs.pathUtils.join(linkStrategy.indexPath, sourceHash.slice(0, 2), `${sourceHash}.dat`); 179 | let AtomicBehavior; 180 | ((AtomicBehavior2) => { 181 | AtomicBehavior2[AtomicBehavior2["Lock"] = 0] = "Lock"; 182 | AtomicBehavior2[AtomicBehavior2["Rename"] = 1] = "Rename"; 183 | })(AtomicBehavior || (AtomicBehavior = {})); 184 | let atomicBehavior = 1 /* Rename */; 185 | let indexStat = await maybeLStat(destinationFs, indexPath); 186 | if (destinationStat) { 187 | const isDestinationHardlinkedFromIndex = indexStat && destinationStat.dev === indexStat.dev && destinationStat.ino === indexStat.ino; 188 | const isIndexModified = indexStat?.mtimeMs !== defaultTimeMs; 189 | if (isDestinationHardlinkedFromIndex) { 190 | if (isIndexModified && linkStrategy.autoRepair) { 191 | atomicBehavior = 0 /* Lock */; 192 | indexStat = null; 193 | } 194 | } 195 | if (!isDestinationHardlinkedFromIndex) { 196 | if (opts.overwrite) { 197 | prelayout.push(async () => destinationFs.removePromise(destination)); 198 | destinationStat = null; 199 | } else { 200 | return false; 201 | } 202 | } 203 | } 204 | const tempPath = !indexStat && atomicBehavior === 1 /* Rename */ ? `${indexPath}.${Math.floor(Math.random() * 4294967296).toString(16).padStart(8, `0`)}` : null; 205 | let tempPathCleaned = false; 206 | prelayout.push(async () => { 207 | if (!indexStat) { 208 | if (atomicBehavior === 0 /* Lock */) { 209 | await destinationFs.lockPromise(indexPath, async () => { 210 | const content = await sourceFs.readFilePromise(source); 211 | await destinationFs.writeFilePromise(indexPath, content); 212 | }); 213 | } 214 | if (atomicBehavior === 1 /* Rename */ && tempPath) { 215 | const content = await sourceFs.readFilePromise(source); 216 | await destinationFs.writeFilePromise(tempPath, content); 217 | try { 218 | await destinationFs.linkPromise(tempPath, indexPath); 219 | } catch (err) { 220 | if (err.code === `EEXIST`) { 221 | tempPathCleaned = true; 222 | await destinationFs.unlinkPromise(tempPath); 223 | } else { 224 | throw err; 225 | } 226 | } 227 | } 228 | } 229 | if (!destinationStat) { 230 | await destinationFs.linkPromise(indexPath, destination); 231 | } 232 | }); 233 | postlayout.push(async () => { 234 | if (!indexStat) 235 | await destinationFs.lutimesPromise(indexPath, defaultTime, defaultTime); 236 | if (tempPath && !tempPathCleaned) { 237 | await destinationFs.unlinkPromise(tempPath); 238 | } 239 | }); 240 | return false; 241 | } 242 | async function copyFileDirect(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { 243 | if (destinationStat !== null) { 244 | if (opts.overwrite) { 245 | prelayout.push(async () => destinationFs.removePromise(destination)); 246 | destinationStat = null; 247 | } else { 248 | return false; 249 | } 250 | } 251 | prelayout.push(async () => { 252 | const content = await sourceFs.readFilePromise(source); 253 | await destinationFs.writeFilePromise(destination, content); 254 | }); 255 | return true; 256 | } 257 | async function copyFile(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { 258 | if (opts.linkStrategy?.type === `HardlinkFromIndex`) { 259 | return copyFileViaIndex(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts, opts.linkStrategy); 260 | } else { 261 | return copyFileDirect(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); 262 | } 263 | } 264 | async function copySymlink(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { 265 | if (destinationStat !== null) { 266 | if (opts.overwrite) { 267 | prelayout.push(async () => destinationFs.removePromise(destination)); 268 | destinationStat = null; 269 | } else { 270 | return false; 271 | } 272 | } 273 | prelayout.push(async () => { 274 | await destinationFs.symlinkPromise(convertPath(destinationFs.pathUtils, await sourceFs.readlinkPromise(source)), destination); 275 | }); 276 | return true; 277 | } 278 | 279 | class FakeFS { 280 | constructor(pathUtils) { 281 | this.pathUtils = pathUtils; 282 | } 283 | async *genTraversePromise(init, { stableSort = false } = {}) { 284 | const stack = [init]; 285 | while (stack.length > 0) { 286 | const p = stack.shift(); 287 | const entry = await this.lstatPromise(p); 288 | if (entry.isDirectory()) { 289 | const entries = await this.readdirPromise(p); 290 | if (stableSort) { 291 | for (const entry2 of entries.sort()) { 292 | stack.push(this.pathUtils.join(p, entry2)); 293 | } 294 | } else { 295 | throw new Error(`Not supported`); 296 | } 297 | } else { 298 | yield p; 299 | } 300 | } 301 | } 302 | async checksumFilePromise(path, { algorithm = `sha512` } = {}) { 303 | const fd = await this.openPromise(path, `r`); 304 | try { 305 | const CHUNK_SIZE = 65536; 306 | const chunk = Buffer.allocUnsafeSlow(CHUNK_SIZE); 307 | const hash = createHash(algorithm); 308 | let bytesRead = 0; 309 | while ((bytesRead = await this.readPromise(fd, chunk, 0, CHUNK_SIZE)) !== 0) 310 | hash.update(bytesRead === CHUNK_SIZE ? chunk : chunk.slice(0, bytesRead)); 311 | return hash.digest(`hex`); 312 | } finally { 313 | await this.closePromise(fd); 314 | } 315 | } 316 | async removePromise(p, { recursive = true, maxRetries = 5 } = {}) { 317 | let stat; 318 | try { 319 | stat = await this.lstatPromise(p); 320 | } catch (error) { 321 | if (error.code === `ENOENT`) { 322 | return; 323 | } else { 324 | throw error; 325 | } 326 | } 327 | if (stat.isDirectory()) { 328 | if (recursive) { 329 | const entries = await this.readdirPromise(p); 330 | await Promise.all(entries.map((entry) => { 331 | return this.removePromise(this.pathUtils.resolve(p, entry)); 332 | })); 333 | } 334 | for (let t = 0; t <= maxRetries; t++) { 335 | try { 336 | await this.rmdirPromise(p); 337 | break; 338 | } catch (error) { 339 | if (error.code !== `EBUSY` && error.code !== `ENOTEMPTY`) { 340 | throw error; 341 | } else if (t < maxRetries) { 342 | await new Promise((resolve) => setTimeout(resolve, t * 100)); 343 | } 344 | } 345 | } 346 | } else { 347 | await this.unlinkPromise(p); 348 | } 349 | } 350 | removeSync(p, { recursive = true } = {}) { 351 | let stat; 352 | try { 353 | stat = this.lstatSync(p); 354 | } catch (error) { 355 | if (error.code === `ENOENT`) { 356 | return; 357 | } else { 358 | throw error; 359 | } 360 | } 361 | if (stat.isDirectory()) { 362 | if (recursive) 363 | for (const entry of this.readdirSync(p)) 364 | this.removeSync(this.pathUtils.resolve(p, entry)); 365 | this.rmdirSync(p); 366 | } else { 367 | this.unlinkSync(p); 368 | } 369 | } 370 | async mkdirpPromise(p, { chmod, utimes } = {}) { 371 | p = this.resolve(p); 372 | if (p === this.pathUtils.dirname(p)) 373 | return void 0; 374 | const parts = p.split(this.pathUtils.sep); 375 | let createdDirectory; 376 | for (let u = 2; u <= parts.length; ++u) { 377 | const subPath = parts.slice(0, u).join(this.pathUtils.sep); 378 | if (!this.existsSync(subPath)) { 379 | try { 380 | await this.mkdirPromise(subPath); 381 | } catch (error) { 382 | if (error.code === `EEXIST`) { 383 | continue; 384 | } else { 385 | throw error; 386 | } 387 | } 388 | createdDirectory ??= subPath; 389 | if (chmod != null) 390 | await this.chmodPromise(subPath, chmod); 391 | if (utimes != null) { 392 | await this.utimesPromise(subPath, utimes[0], utimes[1]); 393 | } else { 394 | const parentStat = await this.statPromise(this.pathUtils.dirname(subPath)); 395 | await this.utimesPromise(subPath, parentStat.atime, parentStat.mtime); 396 | } 397 | } 398 | } 399 | return createdDirectory; 400 | } 401 | mkdirpSync(p, { chmod, utimes } = {}) { 402 | p = this.resolve(p); 403 | if (p === this.pathUtils.dirname(p)) 404 | return void 0; 405 | const parts = p.split(this.pathUtils.sep); 406 | let createdDirectory; 407 | for (let u = 2; u <= parts.length; ++u) { 408 | const subPath = parts.slice(0, u).join(this.pathUtils.sep); 409 | if (!this.existsSync(subPath)) { 410 | try { 411 | this.mkdirSync(subPath); 412 | } catch (error) { 413 | if (error.code === `EEXIST`) { 414 | continue; 415 | } else { 416 | throw error; 417 | } 418 | } 419 | createdDirectory ??= subPath; 420 | if (chmod != null) 421 | this.chmodSync(subPath, chmod); 422 | if (utimes != null) { 423 | this.utimesSync(subPath, utimes[0], utimes[1]); 424 | } else { 425 | const parentStat = this.statSync(this.pathUtils.dirname(subPath)); 426 | this.utimesSync(subPath, parentStat.atime, parentStat.mtime); 427 | } 428 | } 429 | } 430 | return createdDirectory; 431 | } 432 | async copyPromise(destination, source, { baseFs = this, overwrite = true, stableSort = false, stableTime = false, linkStrategy = null } = {}) { 433 | return await copyPromise(this, destination, baseFs, source, { overwrite, stableSort, stableTime, linkStrategy }); 434 | } 435 | copySync(destination, source, { baseFs = this, overwrite = true } = {}) { 436 | const stat = baseFs.lstatSync(source); 437 | const exists = this.existsSync(destination); 438 | if (stat.isDirectory()) { 439 | this.mkdirpSync(destination); 440 | const directoryListing = baseFs.readdirSync(source); 441 | for (const entry of directoryListing) { 442 | this.copySync(this.pathUtils.join(destination, entry), baseFs.pathUtils.join(source, entry), { baseFs, overwrite }); 443 | } 444 | } else if (stat.isFile()) { 445 | if (!exists || overwrite) { 446 | if (exists) 447 | this.removeSync(destination); 448 | const content = baseFs.readFileSync(source); 449 | this.writeFileSync(destination, content); 450 | } 451 | } else if (stat.isSymbolicLink()) { 452 | if (!exists || overwrite) { 453 | if (exists) 454 | this.removeSync(destination); 455 | const target = baseFs.readlinkSync(source); 456 | this.symlinkSync(convertPath(this.pathUtils, target), destination); 457 | } 458 | } else { 459 | throw new Error(`Unsupported file type (file: ${source}, mode: 0o${stat.mode.toString(8).padStart(6, `0`)})`); 460 | } 461 | const mode = stat.mode & 511; 462 | this.chmodSync(destination, mode); 463 | } 464 | async changeFilePromise(p, content, opts = {}) { 465 | if (Buffer.isBuffer(content)) { 466 | return this.changeFileBufferPromise(p, content, opts); 467 | } else { 468 | return this.changeFileTextPromise(p, content, opts); 469 | } 470 | } 471 | async changeFileBufferPromise(p, content, { mode } = {}) { 472 | let current = Buffer.alloc(0); 473 | try { 474 | current = await this.readFilePromise(p); 475 | } catch (error) { 476 | } 477 | if (Buffer.compare(current, content) === 0) 478 | return; 479 | await this.writeFilePromise(p, content, { mode }); 480 | } 481 | async changeFileTextPromise(p, content, { automaticNewlines, mode } = {}) { 482 | let current = ``; 483 | try { 484 | current = await this.readFilePromise(p, `utf8`); 485 | } catch (error) { 486 | } 487 | const normalizedContent = automaticNewlines ? normalizeLineEndings(current, content) : content; 488 | if (current === normalizedContent) 489 | return; 490 | await this.writeFilePromise(p, normalizedContent, { mode }); 491 | } 492 | changeFileSync(p, content, opts = {}) { 493 | if (Buffer.isBuffer(content)) { 494 | return this.changeFileBufferSync(p, content, opts); 495 | } else { 496 | return this.changeFileTextSync(p, content, opts); 497 | } 498 | } 499 | changeFileBufferSync(p, content, { mode } = {}) { 500 | let current = Buffer.alloc(0); 501 | try { 502 | current = this.readFileSync(p); 503 | } catch (error) { 504 | } 505 | if (Buffer.compare(current, content) === 0) 506 | return; 507 | this.writeFileSync(p, content, { mode }); 508 | } 509 | changeFileTextSync(p, content, { automaticNewlines = false, mode } = {}) { 510 | let current = ``; 511 | try { 512 | current = this.readFileSync(p, `utf8`); 513 | } catch (error) { 514 | } 515 | const normalizedContent = automaticNewlines ? normalizeLineEndings(current, content) : content; 516 | if (current === normalizedContent) 517 | return; 518 | this.writeFileSync(p, normalizedContent, { mode }); 519 | } 520 | async movePromise(fromP, toP) { 521 | try { 522 | await this.renamePromise(fromP, toP); 523 | } catch (error) { 524 | if (error.code === `EXDEV`) { 525 | await this.copyPromise(toP, fromP); 526 | await this.removePromise(fromP); 527 | } else { 528 | throw error; 529 | } 530 | } 531 | } 532 | moveSync(fromP, toP) { 533 | try { 534 | this.renameSync(fromP, toP); 535 | } catch (error) { 536 | if (error.code === `EXDEV`) { 537 | this.copySync(toP, fromP); 538 | this.removeSync(fromP); 539 | } else { 540 | throw error; 541 | } 542 | } 543 | } 544 | async lockPromise(affectedPath, callback) { 545 | const lockPath = `${affectedPath}.flock`; 546 | const interval = 1e3 / 60; 547 | const startTime = Date.now(); 548 | let fd = null; 549 | const isAlive = async () => { 550 | let pid; 551 | try { 552 | [pid] = await this.readJsonPromise(lockPath); 553 | } catch (error) { 554 | return Date.now() - startTime < 500; 555 | } 556 | try { 557 | process.kill(pid, 0); 558 | return true; 559 | } catch (error) { 560 | return false; 561 | } 562 | }; 563 | while (fd === null) { 564 | try { 565 | fd = await this.openPromise(lockPath, `wx`); 566 | } catch (error) { 567 | if (error.code === `EEXIST`) { 568 | if (!await isAlive()) { 569 | try { 570 | await this.unlinkPromise(lockPath); 571 | continue; 572 | } catch (error2) { 573 | } 574 | } 575 | if (Date.now() - startTime < 60 * 1e3) { 576 | await new Promise((resolve) => setTimeout(resolve, interval)); 577 | } else { 578 | throw new Error(`Couldn't acquire a lock in a reasonable time (via ${lockPath})`); 579 | } 580 | } else { 581 | throw error; 582 | } 583 | } 584 | } 585 | await this.writePromise(fd, JSON.stringify([process.pid])); 586 | try { 587 | return await callback(); 588 | } finally { 589 | try { 590 | await this.closePromise(fd); 591 | await this.unlinkPromise(lockPath); 592 | } catch (error) { 593 | } 594 | } 595 | } 596 | async readJsonPromise(p) { 597 | const content = await this.readFilePromise(p, `utf8`); 598 | try { 599 | return JSON.parse(content); 600 | } catch (error) { 601 | error.message += ` (in ${p})`; 602 | throw error; 603 | } 604 | } 605 | readJsonSync(p) { 606 | const content = this.readFileSync(p, `utf8`); 607 | try { 608 | return JSON.parse(content); 609 | } catch (error) { 610 | error.message += ` (in ${p})`; 611 | throw error; 612 | } 613 | } 614 | async writeJsonPromise(p, data, { compact = false } = {}) { 615 | const space = compact ? 0 : 2; 616 | return await this.writeFilePromise(p, `${JSON.stringify(data, null, space)} 617 | `); 618 | } 619 | writeJsonSync(p, data, { compact = false } = {}) { 620 | const space = compact ? 0 : 2; 621 | return this.writeFileSync(p, `${JSON.stringify(data, null, space)} 622 | `); 623 | } 624 | async preserveTimePromise(p, cb) { 625 | const stat = await this.lstatPromise(p); 626 | const result = await cb(); 627 | if (typeof result !== `undefined`) 628 | p = result; 629 | await this.lutimesPromise(p, stat.atime, stat.mtime); 630 | } 631 | async preserveTimeSync(p, cb) { 632 | const stat = this.lstatSync(p); 633 | const result = cb(); 634 | if (typeof result !== `undefined`) 635 | p = result; 636 | this.lutimesSync(p, stat.atime, stat.mtime); 637 | } 638 | } 639 | class BasePortableFakeFS extends FakeFS { 640 | constructor() { 641 | super(ppath); 642 | } 643 | } 644 | function getEndOfLine(content) { 645 | const matches = content.match(/\r?\n/g); 646 | if (matches === null) 647 | return EOL; 648 | const crlf = matches.filter((nl) => nl === `\r 649 | `).length; 650 | const lf = matches.length - crlf; 651 | return crlf > lf ? `\r 652 | ` : ` 653 | `; 654 | } 655 | function normalizeLineEndings(originalContent, newContent) { 656 | return newContent.replace(/\r?\n/g, getEndOfLine(originalContent)); 657 | } 658 | 659 | class ProxiedFS extends FakeFS { 660 | getExtractHint(hints) { 661 | return this.baseFs.getExtractHint(hints); 662 | } 663 | resolve(path) { 664 | return this.mapFromBase(this.baseFs.resolve(this.mapToBase(path))); 665 | } 666 | getRealPath() { 667 | return this.mapFromBase(this.baseFs.getRealPath()); 668 | } 669 | async openPromise(p, flags, mode) { 670 | return this.baseFs.openPromise(this.mapToBase(p), flags, mode); 671 | } 672 | openSync(p, flags, mode) { 673 | return this.baseFs.openSync(this.mapToBase(p), flags, mode); 674 | } 675 | async opendirPromise(p, opts) { 676 | return Object.assign(await this.baseFs.opendirPromise(this.mapToBase(p), opts), { path: p }); 677 | } 678 | opendirSync(p, opts) { 679 | return Object.assign(this.baseFs.opendirSync(this.mapToBase(p), opts), { path: p }); 680 | } 681 | async readPromise(fd, buffer, offset, length, position) { 682 | return await this.baseFs.readPromise(fd, buffer, offset, length, position); 683 | } 684 | readSync(fd, buffer, offset, length, position) { 685 | return this.baseFs.readSync(fd, buffer, offset, length, position); 686 | } 687 | async writePromise(fd, buffer, offset, length, position) { 688 | if (typeof buffer === `string`) { 689 | return await this.baseFs.writePromise(fd, buffer, offset); 690 | } else { 691 | return await this.baseFs.writePromise(fd, buffer, offset, length, position); 692 | } 693 | } 694 | writeSync(fd, buffer, offset, length, position) { 695 | if (typeof buffer === `string`) { 696 | return this.baseFs.writeSync(fd, buffer, offset); 697 | } else { 698 | return this.baseFs.writeSync(fd, buffer, offset, length, position); 699 | } 700 | } 701 | async closePromise(fd) { 702 | return this.baseFs.closePromise(fd); 703 | } 704 | closeSync(fd) { 705 | this.baseFs.closeSync(fd); 706 | } 707 | createReadStream(p, opts) { 708 | return this.baseFs.createReadStream(p !== null ? this.mapToBase(p) : p, opts); 709 | } 710 | createWriteStream(p, opts) { 711 | return this.baseFs.createWriteStream(p !== null ? this.mapToBase(p) : p, opts); 712 | } 713 | async realpathPromise(p) { 714 | return this.mapFromBase(await this.baseFs.realpathPromise(this.mapToBase(p))); 715 | } 716 | realpathSync(p) { 717 | return this.mapFromBase(this.baseFs.realpathSync(this.mapToBase(p))); 718 | } 719 | async existsPromise(p) { 720 | return this.baseFs.existsPromise(this.mapToBase(p)); 721 | } 722 | existsSync(p) { 723 | return this.baseFs.existsSync(this.mapToBase(p)); 724 | } 725 | accessSync(p, mode) { 726 | return this.baseFs.accessSync(this.mapToBase(p), mode); 727 | } 728 | async accessPromise(p, mode) { 729 | return this.baseFs.accessPromise(this.mapToBase(p), mode); 730 | } 731 | async statPromise(p, opts) { 732 | return this.baseFs.statPromise(this.mapToBase(p), opts); 733 | } 734 | statSync(p, opts) { 735 | return this.baseFs.statSync(this.mapToBase(p), opts); 736 | } 737 | async fstatPromise(fd, opts) { 738 | return this.baseFs.fstatPromise(fd, opts); 739 | } 740 | fstatSync(fd, opts) { 741 | return this.baseFs.fstatSync(fd, opts); 742 | } 743 | lstatPromise(p, opts) { 744 | return this.baseFs.lstatPromise(this.mapToBase(p), opts); 745 | } 746 | lstatSync(p, opts) { 747 | return this.baseFs.lstatSync(this.mapToBase(p), opts); 748 | } 749 | async fchmodPromise(fd, mask) { 750 | return this.baseFs.fchmodPromise(fd, mask); 751 | } 752 | fchmodSync(fd, mask) { 753 | return this.baseFs.fchmodSync(fd, mask); 754 | } 755 | async chmodPromise(p, mask) { 756 | return this.baseFs.chmodPromise(this.mapToBase(p), mask); 757 | } 758 | chmodSync(p, mask) { 759 | return this.baseFs.chmodSync(this.mapToBase(p), mask); 760 | } 761 | async fchownPromise(fd, uid, gid) { 762 | return this.baseFs.fchownPromise(fd, uid, gid); 763 | } 764 | fchownSync(fd, uid, gid) { 765 | return this.baseFs.fchownSync(fd, uid, gid); 766 | } 767 | async chownPromise(p, uid, gid) { 768 | return this.baseFs.chownPromise(this.mapToBase(p), uid, gid); 769 | } 770 | chownSync(p, uid, gid) { 771 | return this.baseFs.chownSync(this.mapToBase(p), uid, gid); 772 | } 773 | async renamePromise(oldP, newP) { 774 | return this.baseFs.renamePromise(this.mapToBase(oldP), this.mapToBase(newP)); 775 | } 776 | renameSync(oldP, newP) { 777 | return this.baseFs.renameSync(this.mapToBase(oldP), this.mapToBase(newP)); 778 | } 779 | async copyFilePromise(sourceP, destP, flags = 0) { 780 | return this.baseFs.copyFilePromise(this.mapToBase(sourceP), this.mapToBase(destP), flags); 781 | } 782 | copyFileSync(sourceP, destP, flags = 0) { 783 | return this.baseFs.copyFileSync(this.mapToBase(sourceP), this.mapToBase(destP), flags); 784 | } 785 | async appendFilePromise(p, content, opts) { 786 | return this.baseFs.appendFilePromise(this.fsMapToBase(p), content, opts); 787 | } 788 | appendFileSync(p, content, opts) { 789 | return this.baseFs.appendFileSync(this.fsMapToBase(p), content, opts); 790 | } 791 | async writeFilePromise(p, content, opts) { 792 | return this.baseFs.writeFilePromise(this.fsMapToBase(p), content, opts); 793 | } 794 | writeFileSync(p, content, opts) { 795 | return this.baseFs.writeFileSync(this.fsMapToBase(p), content, opts); 796 | } 797 | async unlinkPromise(p) { 798 | return this.baseFs.unlinkPromise(this.mapToBase(p)); 799 | } 800 | unlinkSync(p) { 801 | return this.baseFs.unlinkSync(this.mapToBase(p)); 802 | } 803 | async utimesPromise(p, atime, mtime) { 804 | return this.baseFs.utimesPromise(this.mapToBase(p), atime, mtime); 805 | } 806 | utimesSync(p, atime, mtime) { 807 | return this.baseFs.utimesSync(this.mapToBase(p), atime, mtime); 808 | } 809 | async lutimesPromise(p, atime, mtime) { 810 | return this.baseFs.lutimesPromise(this.mapToBase(p), atime, mtime); 811 | } 812 | lutimesSync(p, atime, mtime) { 813 | return this.baseFs.lutimesSync(this.mapToBase(p), atime, mtime); 814 | } 815 | async mkdirPromise(p, opts) { 816 | return this.baseFs.mkdirPromise(this.mapToBase(p), opts); 817 | } 818 | mkdirSync(p, opts) { 819 | return this.baseFs.mkdirSync(this.mapToBase(p), opts); 820 | } 821 | async rmdirPromise(p, opts) { 822 | return this.baseFs.rmdirPromise(this.mapToBase(p), opts); 823 | } 824 | rmdirSync(p, opts) { 825 | return this.baseFs.rmdirSync(this.mapToBase(p), opts); 826 | } 827 | async linkPromise(existingP, newP) { 828 | return this.baseFs.linkPromise(this.mapToBase(existingP), this.mapToBase(newP)); 829 | } 830 | linkSync(existingP, newP) { 831 | return this.baseFs.linkSync(this.mapToBase(existingP), this.mapToBase(newP)); 832 | } 833 | async symlinkPromise(target, p, type) { 834 | const mappedP = this.mapToBase(p); 835 | if (this.pathUtils.isAbsolute(target)) 836 | return this.baseFs.symlinkPromise(this.mapToBase(target), mappedP, type); 837 | const mappedAbsoluteTarget = this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(p), target)); 838 | const mappedTarget = this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(mappedP), mappedAbsoluteTarget); 839 | return this.baseFs.symlinkPromise(mappedTarget, mappedP, type); 840 | } 841 | symlinkSync(target, p, type) { 842 | const mappedP = this.mapToBase(p); 843 | if (this.pathUtils.isAbsolute(target)) 844 | return this.baseFs.symlinkSync(this.mapToBase(target), mappedP, type); 845 | const mappedAbsoluteTarget = this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(p), target)); 846 | const mappedTarget = this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(mappedP), mappedAbsoluteTarget); 847 | return this.baseFs.symlinkSync(mappedTarget, mappedP, type); 848 | } 849 | async readFilePromise(p, encoding) { 850 | return this.baseFs.readFilePromise(this.fsMapToBase(p), encoding); 851 | } 852 | readFileSync(p, encoding) { 853 | return this.baseFs.readFileSync(this.fsMapToBase(p), encoding); 854 | } 855 | readdirPromise(p, opts) { 856 | return this.baseFs.readdirPromise(this.mapToBase(p), opts); 857 | } 858 | readdirSync(p, opts) { 859 | return this.baseFs.readdirSync(this.mapToBase(p), opts); 860 | } 861 | async readlinkPromise(p) { 862 | return this.mapFromBase(await this.baseFs.readlinkPromise(this.mapToBase(p))); 863 | } 864 | readlinkSync(p) { 865 | return this.mapFromBase(this.baseFs.readlinkSync(this.mapToBase(p))); 866 | } 867 | async truncatePromise(p, len) { 868 | return this.baseFs.truncatePromise(this.mapToBase(p), len); 869 | } 870 | truncateSync(p, len) { 871 | return this.baseFs.truncateSync(this.mapToBase(p), len); 872 | } 873 | async ftruncatePromise(fd, len) { 874 | return this.baseFs.ftruncatePromise(fd, len); 875 | } 876 | ftruncateSync(fd, len) { 877 | return this.baseFs.ftruncateSync(fd, len); 878 | } 879 | watch(p, a, b) { 880 | return this.baseFs.watch( 881 | this.mapToBase(p), 882 | a, 883 | b 884 | ); 885 | } 886 | watchFile(p, a, b) { 887 | return this.baseFs.watchFile( 888 | this.mapToBase(p), 889 | a, 890 | b 891 | ); 892 | } 893 | unwatchFile(p, cb) { 894 | return this.baseFs.unwatchFile(this.mapToBase(p), cb); 895 | } 896 | fsMapToBase(p) { 897 | if (typeof p === `number`) { 898 | return p; 899 | } else { 900 | return this.mapToBase(p); 901 | } 902 | } 903 | } 904 | 905 | class NodeFS extends BasePortableFakeFS { 906 | constructor(realFs = fs) { 907 | super(); 908 | this.realFs = realFs; 909 | } 910 | getExtractHint() { 911 | return false; 912 | } 913 | getRealPath() { 914 | return PortablePath.root; 915 | } 916 | resolve(p) { 917 | return ppath.resolve(p); 918 | } 919 | async openPromise(p, flags, mode) { 920 | return await new Promise((resolve, reject) => { 921 | this.realFs.open(npath.fromPortablePath(p), flags, mode, this.makeCallback(resolve, reject)); 922 | }); 923 | } 924 | openSync(p, flags, mode) { 925 | return this.realFs.openSync(npath.fromPortablePath(p), flags, mode); 926 | } 927 | async opendirPromise(p, opts) { 928 | return await new Promise((resolve, reject) => { 929 | if (typeof opts !== `undefined`) { 930 | this.realFs.opendir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); 931 | } else { 932 | this.realFs.opendir(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); 933 | } 934 | }).then((dir) => { 935 | const dirWithFixedPath = dir; 936 | Object.defineProperty(dirWithFixedPath, `path`, { 937 | value: p, 938 | configurable: true, 939 | writable: true 940 | }); 941 | return dirWithFixedPath; 942 | }); 943 | } 944 | opendirSync(p, opts) { 945 | const dir = typeof opts !== `undefined` ? this.realFs.opendirSync(npath.fromPortablePath(p), opts) : this.realFs.opendirSync(npath.fromPortablePath(p)); 946 | const dirWithFixedPath = dir; 947 | Object.defineProperty(dirWithFixedPath, `path`, { 948 | value: p, 949 | configurable: true, 950 | writable: true 951 | }); 952 | return dirWithFixedPath; 953 | } 954 | async readPromise(fd, buffer, offset = 0, length = 0, position = -1) { 955 | return await new Promise((resolve, reject) => { 956 | this.realFs.read(fd, buffer, offset, length, position, (error, bytesRead) => { 957 | if (error) { 958 | reject(error); 959 | } else { 960 | resolve(bytesRead); 961 | } 962 | }); 963 | }); 964 | } 965 | readSync(fd, buffer, offset, length, position) { 966 | return this.realFs.readSync(fd, buffer, offset, length, position); 967 | } 968 | async writePromise(fd, buffer, offset, length, position) { 969 | return await new Promise((resolve, reject) => { 970 | if (typeof buffer === `string`) { 971 | return this.realFs.write(fd, buffer, offset, this.makeCallback(resolve, reject)); 972 | } else { 973 | return this.realFs.write(fd, buffer, offset, length, position, this.makeCallback(resolve, reject)); 974 | } 975 | }); 976 | } 977 | writeSync(fd, buffer, offset, length, position) { 978 | if (typeof buffer === `string`) { 979 | return this.realFs.writeSync(fd, buffer, offset); 980 | } else { 981 | return this.realFs.writeSync(fd, buffer, offset, length, position); 982 | } 983 | } 984 | async closePromise(fd) { 985 | await new Promise((resolve, reject) => { 986 | this.realFs.close(fd, this.makeCallback(resolve, reject)); 987 | }); 988 | } 989 | closeSync(fd) { 990 | this.realFs.closeSync(fd); 991 | } 992 | createReadStream(p, opts) { 993 | const realPath = p !== null ? npath.fromPortablePath(p) : p; 994 | return this.realFs.createReadStream(realPath, opts); 995 | } 996 | createWriteStream(p, opts) { 997 | const realPath = p !== null ? npath.fromPortablePath(p) : p; 998 | return this.realFs.createWriteStream(realPath, opts); 999 | } 1000 | async realpathPromise(p) { 1001 | return await new Promise((resolve, reject) => { 1002 | this.realFs.realpath(npath.fromPortablePath(p), {}, this.makeCallback(resolve, reject)); 1003 | }).then((path) => { 1004 | return npath.toPortablePath(path); 1005 | }); 1006 | } 1007 | realpathSync(p) { 1008 | return npath.toPortablePath(this.realFs.realpathSync(npath.fromPortablePath(p), {})); 1009 | } 1010 | async existsPromise(p) { 1011 | return await new Promise((resolve) => { 1012 | this.realFs.exists(npath.fromPortablePath(p), resolve); 1013 | }); 1014 | } 1015 | accessSync(p, mode) { 1016 | return this.realFs.accessSync(npath.fromPortablePath(p), mode); 1017 | } 1018 | async accessPromise(p, mode) { 1019 | return await new Promise((resolve, reject) => { 1020 | this.realFs.access(npath.fromPortablePath(p), mode, this.makeCallback(resolve, reject)); 1021 | }); 1022 | } 1023 | existsSync(p) { 1024 | return this.realFs.existsSync(npath.fromPortablePath(p)); 1025 | } 1026 | async statPromise(p, opts) { 1027 | return await new Promise((resolve, reject) => { 1028 | if (opts) { 1029 | this.realFs.stat(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); 1030 | } else { 1031 | this.realFs.stat(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); 1032 | } 1033 | }); 1034 | } 1035 | statSync(p, opts) { 1036 | if (opts) { 1037 | return this.realFs.statSync(npath.fromPortablePath(p), opts); 1038 | } else { 1039 | return this.realFs.statSync(npath.fromPortablePath(p)); 1040 | } 1041 | } 1042 | async fstatPromise(fd, opts) { 1043 | return await new Promise((resolve, reject) => { 1044 | if (opts) { 1045 | this.realFs.fstat(fd, opts, this.makeCallback(resolve, reject)); 1046 | } else { 1047 | this.realFs.fstat(fd, this.makeCallback(resolve, reject)); 1048 | } 1049 | }); 1050 | } 1051 | fstatSync(fd, opts) { 1052 | if (opts) { 1053 | return this.realFs.fstatSync(fd, opts); 1054 | } else { 1055 | return this.realFs.fstatSync(fd); 1056 | } 1057 | } 1058 | async lstatPromise(p, opts) { 1059 | return await new Promise((resolve, reject) => { 1060 | if (opts) { 1061 | this.realFs.lstat(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); 1062 | } else { 1063 | this.realFs.lstat(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); 1064 | } 1065 | }); 1066 | } 1067 | lstatSync(p, opts) { 1068 | if (opts) { 1069 | return this.realFs.lstatSync(npath.fromPortablePath(p), opts); 1070 | } else { 1071 | return this.realFs.lstatSync(npath.fromPortablePath(p)); 1072 | } 1073 | } 1074 | async fchmodPromise(fd, mask) { 1075 | return await new Promise((resolve, reject) => { 1076 | this.realFs.fchmod(fd, mask, this.makeCallback(resolve, reject)); 1077 | }); 1078 | } 1079 | fchmodSync(fd, mask) { 1080 | return this.realFs.fchmodSync(fd, mask); 1081 | } 1082 | async chmodPromise(p, mask) { 1083 | return await new Promise((resolve, reject) => { 1084 | this.realFs.chmod(npath.fromPortablePath(p), mask, this.makeCallback(resolve, reject)); 1085 | }); 1086 | } 1087 | chmodSync(p, mask) { 1088 | return this.realFs.chmodSync(npath.fromPortablePath(p), mask); 1089 | } 1090 | async fchownPromise(fd, uid, gid) { 1091 | return await new Promise((resolve, reject) => { 1092 | this.realFs.fchown(fd, uid, gid, this.makeCallback(resolve, reject)); 1093 | }); 1094 | } 1095 | fchownSync(fd, uid, gid) { 1096 | return this.realFs.fchownSync(fd, uid, gid); 1097 | } 1098 | async chownPromise(p, uid, gid) { 1099 | return await new Promise((resolve, reject) => { 1100 | this.realFs.chown(npath.fromPortablePath(p), uid, gid, this.makeCallback(resolve, reject)); 1101 | }); 1102 | } 1103 | chownSync(p, uid, gid) { 1104 | return this.realFs.chownSync(npath.fromPortablePath(p), uid, gid); 1105 | } 1106 | async renamePromise(oldP, newP) { 1107 | return await new Promise((resolve, reject) => { 1108 | this.realFs.rename(npath.fromPortablePath(oldP), npath.fromPortablePath(newP), this.makeCallback(resolve, reject)); 1109 | }); 1110 | } 1111 | renameSync(oldP, newP) { 1112 | return this.realFs.renameSync(npath.fromPortablePath(oldP), npath.fromPortablePath(newP)); 1113 | } 1114 | async copyFilePromise(sourceP, destP, flags = 0) { 1115 | return await new Promise((resolve, reject) => { 1116 | this.realFs.copyFile(npath.fromPortablePath(sourceP), npath.fromPortablePath(destP), flags, this.makeCallback(resolve, reject)); 1117 | }); 1118 | } 1119 | copyFileSync(sourceP, destP, flags = 0) { 1120 | return this.realFs.copyFileSync(npath.fromPortablePath(sourceP), npath.fromPortablePath(destP), flags); 1121 | } 1122 | async appendFilePromise(p, content, opts) { 1123 | return await new Promise((resolve, reject) => { 1124 | const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; 1125 | if (opts) { 1126 | this.realFs.appendFile(fsNativePath, content, opts, this.makeCallback(resolve, reject)); 1127 | } else { 1128 | this.realFs.appendFile(fsNativePath, content, this.makeCallback(resolve, reject)); 1129 | } 1130 | }); 1131 | } 1132 | appendFileSync(p, content, opts) { 1133 | const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; 1134 | if (opts) { 1135 | this.realFs.appendFileSync(fsNativePath, content, opts); 1136 | } else { 1137 | this.realFs.appendFileSync(fsNativePath, content); 1138 | } 1139 | } 1140 | async writeFilePromise(p, content, opts) { 1141 | return await new Promise((resolve, reject) => { 1142 | const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; 1143 | if (opts) { 1144 | this.realFs.writeFile(fsNativePath, content, opts, this.makeCallback(resolve, reject)); 1145 | } else { 1146 | this.realFs.writeFile(fsNativePath, content, this.makeCallback(resolve, reject)); 1147 | } 1148 | }); 1149 | } 1150 | writeFileSync(p, content, opts) { 1151 | const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; 1152 | if (opts) { 1153 | this.realFs.writeFileSync(fsNativePath, content, opts); 1154 | } else { 1155 | this.realFs.writeFileSync(fsNativePath, content); 1156 | } 1157 | } 1158 | async unlinkPromise(p) { 1159 | return await new Promise((resolve, reject) => { 1160 | this.realFs.unlink(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); 1161 | }); 1162 | } 1163 | unlinkSync(p) { 1164 | return this.realFs.unlinkSync(npath.fromPortablePath(p)); 1165 | } 1166 | async utimesPromise(p, atime, mtime) { 1167 | return await new Promise((resolve, reject) => { 1168 | this.realFs.utimes(npath.fromPortablePath(p), atime, mtime, this.makeCallback(resolve, reject)); 1169 | }); 1170 | } 1171 | utimesSync(p, atime, mtime) { 1172 | this.realFs.utimesSync(npath.fromPortablePath(p), atime, mtime); 1173 | } 1174 | async lutimesPromise(p, atime, mtime) { 1175 | return await new Promise((resolve, reject) => { 1176 | this.realFs.lutimes(npath.fromPortablePath(p), atime, mtime, this.makeCallback(resolve, reject)); 1177 | }); 1178 | } 1179 | lutimesSync(p, atime, mtime) { 1180 | this.realFs.lutimesSync(npath.fromPortablePath(p), atime, mtime); 1181 | } 1182 | async mkdirPromise(p, opts) { 1183 | return await new Promise((resolve, reject) => { 1184 | this.realFs.mkdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); 1185 | }); 1186 | } 1187 | mkdirSync(p, opts) { 1188 | return this.realFs.mkdirSync(npath.fromPortablePath(p), opts); 1189 | } 1190 | async rmdirPromise(p, opts) { 1191 | return await new Promise((resolve, reject) => { 1192 | if (opts) { 1193 | this.realFs.rmdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); 1194 | } else { 1195 | this.realFs.rmdir(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); 1196 | } 1197 | }); 1198 | } 1199 | rmdirSync(p, opts) { 1200 | return this.realFs.rmdirSync(npath.fromPortablePath(p), opts); 1201 | } 1202 | async linkPromise(existingP, newP) { 1203 | return await new Promise((resolve, reject) => { 1204 | this.realFs.link(npath.fromPortablePath(existingP), npath.fromPortablePath(newP), this.makeCallback(resolve, reject)); 1205 | }); 1206 | } 1207 | linkSync(existingP, newP) { 1208 | return this.realFs.linkSync(npath.fromPortablePath(existingP), npath.fromPortablePath(newP)); 1209 | } 1210 | async symlinkPromise(target, p, type) { 1211 | return await new Promise((resolve, reject) => { 1212 | this.realFs.symlink(npath.fromPortablePath(target.replace(/\/+$/, ``)), npath.fromPortablePath(p), type, this.makeCallback(resolve, reject)); 1213 | }); 1214 | } 1215 | symlinkSync(target, p, type) { 1216 | return this.realFs.symlinkSync(npath.fromPortablePath(target.replace(/\/+$/, ``)), npath.fromPortablePath(p), type); 1217 | } 1218 | async readFilePromise(p, encoding) { 1219 | return await new Promise((resolve, reject) => { 1220 | const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; 1221 | this.realFs.readFile(fsNativePath, encoding, this.makeCallback(resolve, reject)); 1222 | }); 1223 | } 1224 | readFileSync(p, encoding) { 1225 | const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; 1226 | return this.realFs.readFileSync(fsNativePath, encoding); 1227 | } 1228 | async readdirPromise(p, opts) { 1229 | return await new Promise((resolve, reject) => { 1230 | if (opts) { 1231 | this.realFs.readdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); 1232 | } else { 1233 | this.realFs.readdir(npath.fromPortablePath(p), this.makeCallback((value) => resolve(value), reject)); 1234 | } 1235 | }); 1236 | } 1237 | readdirSync(p, opts) { 1238 | if (opts) { 1239 | return this.realFs.readdirSync(npath.fromPortablePath(p), opts); 1240 | } else { 1241 | return this.realFs.readdirSync(npath.fromPortablePath(p)); 1242 | } 1243 | } 1244 | async readlinkPromise(p) { 1245 | return await new Promise((resolve, reject) => { 1246 | this.realFs.readlink(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); 1247 | }).then((path) => { 1248 | return npath.toPortablePath(path); 1249 | }); 1250 | } 1251 | readlinkSync(p) { 1252 | return npath.toPortablePath(this.realFs.readlinkSync(npath.fromPortablePath(p))); 1253 | } 1254 | async truncatePromise(p, len) { 1255 | return await new Promise((resolve, reject) => { 1256 | this.realFs.truncate(npath.fromPortablePath(p), len, this.makeCallback(resolve, reject)); 1257 | }); 1258 | } 1259 | truncateSync(p, len) { 1260 | return this.realFs.truncateSync(npath.fromPortablePath(p), len); 1261 | } 1262 | async ftruncatePromise(fd, len) { 1263 | return await new Promise((resolve, reject) => { 1264 | this.realFs.ftruncate(fd, len, this.makeCallback(resolve, reject)); 1265 | }); 1266 | } 1267 | ftruncateSync(fd, len) { 1268 | return this.realFs.ftruncateSync(fd, len); 1269 | } 1270 | watch(p, a, b) { 1271 | return this.realFs.watch( 1272 | npath.fromPortablePath(p), 1273 | a, 1274 | b 1275 | ); 1276 | } 1277 | watchFile(p, a, b) { 1278 | return this.realFs.watchFile( 1279 | npath.fromPortablePath(p), 1280 | a, 1281 | b 1282 | ); 1283 | } 1284 | unwatchFile(p, cb) { 1285 | return this.realFs.unwatchFile(npath.fromPortablePath(p), cb); 1286 | } 1287 | makeCallback(resolve, reject) { 1288 | return (err, result) => { 1289 | if (err) { 1290 | reject(err); 1291 | } else { 1292 | resolve(result); 1293 | } 1294 | }; 1295 | } 1296 | } 1297 | 1298 | const NUMBER_REGEXP = /^[0-9]+$/; 1299 | const VIRTUAL_REGEXP = /^(\/(?:[^/]+\/)*?(?:\$\$virtual|__virtual__))((?:\/((?:[^/]+-)?[a-f0-9]+)(?:\/([^/]+))?)?((?:\/.*)?))$/; 1300 | const VALID_COMPONENT = /^([^/]+-)?[a-f0-9]+$/; 1301 | class VirtualFS extends ProxiedFS { 1302 | constructor({ baseFs = new NodeFS() } = {}) { 1303 | super(ppath); 1304 | this.baseFs = baseFs; 1305 | } 1306 | static makeVirtualPath(base, component, to) { 1307 | if (ppath.basename(base) !== `__virtual__`) 1308 | throw new Error(`Assertion failed: Virtual folders must be named "__virtual__"`); 1309 | if (!ppath.basename(component).match(VALID_COMPONENT)) 1310 | throw new Error(`Assertion failed: Virtual components must be ended by an hexadecimal hash`); 1311 | const target = ppath.relative(ppath.dirname(base), to); 1312 | const segments = target.split(`/`); 1313 | let depth = 0; 1314 | while (depth < segments.length && segments[depth] === `..`) 1315 | depth += 1; 1316 | const finalSegments = segments.slice(depth); 1317 | const fullVirtualPath = ppath.join(base, component, String(depth), ...finalSegments); 1318 | return fullVirtualPath; 1319 | } 1320 | static resolveVirtual(p) { 1321 | const match = p.match(VIRTUAL_REGEXP); 1322 | if (!match || !match[3] && match[5]) 1323 | return p; 1324 | const target = ppath.dirname(match[1]); 1325 | if (!match[3] || !match[4]) 1326 | return target; 1327 | const isnum = NUMBER_REGEXP.test(match[4]); 1328 | if (!isnum) 1329 | return p; 1330 | const depth = Number(match[4]); 1331 | const backstep = `../`.repeat(depth); 1332 | const subpath = match[5] || `.`; 1333 | return VirtualFS.resolveVirtual(ppath.join(target, backstep, subpath)); 1334 | } 1335 | getExtractHint(hints) { 1336 | return this.baseFs.getExtractHint(hints); 1337 | } 1338 | getRealPath() { 1339 | return this.baseFs.getRealPath(); 1340 | } 1341 | realpathSync(p) { 1342 | const match = p.match(VIRTUAL_REGEXP); 1343 | if (!match) 1344 | return this.baseFs.realpathSync(p); 1345 | if (!match[5]) 1346 | return p; 1347 | const realpath = this.baseFs.realpathSync(this.mapToBase(p)); 1348 | return VirtualFS.makeVirtualPath(match[1], match[3], realpath); 1349 | } 1350 | async realpathPromise(p) { 1351 | const match = p.match(VIRTUAL_REGEXP); 1352 | if (!match) 1353 | return await this.baseFs.realpathPromise(p); 1354 | if (!match[5]) 1355 | return p; 1356 | const realpath = await this.baseFs.realpathPromise(this.mapToBase(p)); 1357 | return VirtualFS.makeVirtualPath(match[1], match[3], realpath); 1358 | } 1359 | mapToBase(p) { 1360 | if (p === ``) 1361 | return p; 1362 | if (this.pathUtils.isAbsolute(p)) 1363 | return VirtualFS.resolveVirtual(p); 1364 | const resolvedRoot = VirtualFS.resolveVirtual(this.baseFs.resolve(PortablePath.dot)); 1365 | const resolvedP = VirtualFS.resolveVirtual(this.baseFs.resolve(p)); 1366 | return ppath.relative(resolvedRoot, resolvedP) || PortablePath.dot; 1367 | } 1368 | mapFromBase(p) { 1369 | return p; 1370 | } 1371 | } 1372 | 1373 | const [major, minor] = process.versions.node.split(`.`).map((value) => parseInt(value, 10)); 1374 | const WATCH_MODE_MESSAGE_USES_ARRAYS = major > 19 || major === 19 && minor >= 2 || major === 18 && minor >= 13; 1375 | const HAS_LAZY_LOADED_TRANSLATORS = major > 19 || major === 19 && minor >= 3; 1376 | 1377 | function readPackageScope(checkPath) { 1378 | const rootSeparatorIndex = checkPath.indexOf(npath.sep); 1379 | let separatorIndex; 1380 | do { 1381 | separatorIndex = checkPath.lastIndexOf(npath.sep); 1382 | checkPath = checkPath.slice(0, separatorIndex); 1383 | if (checkPath.endsWith(`${npath.sep}node_modules`)) 1384 | return false; 1385 | const pjson = readPackage(checkPath + npath.sep); 1386 | if (pjson) { 1387 | return { 1388 | data: pjson, 1389 | path: checkPath 1390 | }; 1391 | } 1392 | } while (separatorIndex > rootSeparatorIndex); 1393 | return false; 1394 | } 1395 | function readPackage(requestPath) { 1396 | const jsonPath = npath.resolve(requestPath, `package.json`); 1397 | if (!fs.existsSync(jsonPath)) 1398 | return null; 1399 | return JSON.parse(fs.readFileSync(jsonPath, `utf8`)); 1400 | } 1401 | 1402 | async function tryReadFile$1(path2) { 1403 | try { 1404 | return await fs.promises.readFile(path2, `utf8`); 1405 | } catch (error) { 1406 | if (error.code === `ENOENT`) 1407 | return null; 1408 | throw error; 1409 | } 1410 | } 1411 | function tryParseURL(str, base) { 1412 | try { 1413 | return new URL$1(str, base); 1414 | } catch { 1415 | return null; 1416 | } 1417 | } 1418 | let entrypointPath = null; 1419 | function setEntrypointPath(file) { 1420 | entrypointPath = file; 1421 | } 1422 | function getFileFormat(filepath) { 1423 | const ext = path.extname(filepath); 1424 | switch (ext) { 1425 | case `.mjs`: { 1426 | return `module`; 1427 | } 1428 | case `.cjs`: { 1429 | return `commonjs`; 1430 | } 1431 | case `.wasm`: { 1432 | throw new Error( 1433 | `Unknown file extension ".wasm" for ${filepath}` 1434 | ); 1435 | } 1436 | case `.json`: { 1437 | return `json`; 1438 | } 1439 | case `.js`: { 1440 | const pkg = readPackageScope(filepath); 1441 | if (!pkg) 1442 | return `commonjs`; 1443 | return pkg.data.type ?? `commonjs`; 1444 | } 1445 | default: { 1446 | if (entrypointPath !== filepath) 1447 | return null; 1448 | const pkg = readPackageScope(filepath); 1449 | if (!pkg) 1450 | return `commonjs`; 1451 | if (pkg.data.type === `module`) 1452 | return null; 1453 | return pkg.data.type ?? `commonjs`; 1454 | } 1455 | } 1456 | } 1457 | 1458 | async function load$1(urlString, context, nextLoad) { 1459 | const url = tryParseURL(urlString); 1460 | if (url?.protocol !== `file:`) 1461 | return nextLoad(urlString, context, nextLoad); 1462 | const filePath = fileURLToPath(url); 1463 | const format = getFileFormat(filePath); 1464 | if (!format) 1465 | return nextLoad(urlString, context, nextLoad); 1466 | if (format === `json` && context.importAssertions?.type !== `json`) { 1467 | const err = new TypeError(`[ERR_IMPORT_ASSERTION_TYPE_MISSING]: Module "${urlString}" needs an import assertion of type "json"`); 1468 | err.code = `ERR_IMPORT_ASSERTION_TYPE_MISSING`; 1469 | throw err; 1470 | } 1471 | if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) { 1472 | const pathToSend = pathToFileURL( 1473 | npath.fromPortablePath( 1474 | VirtualFS.resolveVirtual(npath.toPortablePath(filePath)) 1475 | ) 1476 | ).href; 1477 | process.send({ 1478 | "watch:import": WATCH_MODE_MESSAGE_USES_ARRAYS ? [pathToSend] : pathToSend 1479 | }); 1480 | } 1481 | return { 1482 | format, 1483 | source: format === `commonjs` ? void 0 : await fs.promises.readFile(filePath, `utf8`), 1484 | shortCircuit: true 1485 | }; 1486 | } 1487 | 1488 | const ArrayIsArray = Array.isArray; 1489 | const JSONStringify = JSON.stringify; 1490 | const ObjectGetOwnPropertyNames = Object.getOwnPropertyNames; 1491 | const ObjectPrototypeHasOwnProperty = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); 1492 | const RegExpPrototypeExec = (obj, string) => RegExp.prototype.exec.call(obj, string); 1493 | const RegExpPrototypeSymbolReplace = (obj, ...rest) => RegExp.prototype[Symbol.replace].apply(obj, rest); 1494 | const StringPrototypeEndsWith = (str, ...rest) => String.prototype.endsWith.apply(str, rest); 1495 | const StringPrototypeIncludes = (str, ...rest) => String.prototype.includes.apply(str, rest); 1496 | const StringPrototypeLastIndexOf = (str, ...rest) => String.prototype.lastIndexOf.apply(str, rest); 1497 | const StringPrototypeIndexOf = (str, ...rest) => String.prototype.indexOf.apply(str, rest); 1498 | const StringPrototypeReplace = (str, ...rest) => String.prototype.replace.apply(str, rest); 1499 | const StringPrototypeSlice = (str, ...rest) => String.prototype.slice.apply(str, rest); 1500 | const StringPrototypeStartsWith = (str, ...rest) => String.prototype.startsWith.apply(str, rest); 1501 | const SafeMap = Map; 1502 | const JSONParse = JSON.parse; 1503 | 1504 | function createErrorType(code, messageCreator, errorType) { 1505 | return class extends errorType { 1506 | constructor(...args) { 1507 | super(messageCreator(...args)); 1508 | this.code = code; 1509 | this.name = `${errorType.name} [${code}]`; 1510 | } 1511 | }; 1512 | } 1513 | const ERR_PACKAGE_IMPORT_NOT_DEFINED = createErrorType( 1514 | `ERR_PACKAGE_IMPORT_NOT_DEFINED`, 1515 | (specifier, packagePath, base) => { 1516 | return `Package import specifier "${specifier}" is not defined${packagePath ? ` in package ${packagePath}package.json` : ``} imported from ${base}`; 1517 | }, 1518 | TypeError 1519 | ); 1520 | const ERR_INVALID_MODULE_SPECIFIER = createErrorType( 1521 | `ERR_INVALID_MODULE_SPECIFIER`, 1522 | (request, reason, base = void 0) => { 1523 | return `Invalid module "${request}" ${reason}${base ? ` imported from ${base}` : ``}`; 1524 | }, 1525 | TypeError 1526 | ); 1527 | const ERR_INVALID_PACKAGE_TARGET = createErrorType( 1528 | `ERR_INVALID_PACKAGE_TARGET`, 1529 | (pkgPath, key, target, isImport = false, base = void 0) => { 1530 | const relError = typeof target === `string` && !isImport && target.length && !StringPrototypeStartsWith(target, `./`); 1531 | if (key === `.`) { 1532 | assert(isImport === false); 1533 | return `Invalid "exports" main target ${JSONStringify(target)} defined in the package config ${pkgPath}package.json${base ? ` imported from ${base}` : ``}${relError ? `; targets must start with "./"` : ``}`; 1534 | } 1535 | return `Invalid "${isImport ? `imports` : `exports`}" target ${JSONStringify( 1536 | target 1537 | )} defined for '${key}' in the package config ${pkgPath}package.json${base ? ` imported from ${base}` : ``}${relError ? `; targets must start with "./"` : ``}`; 1538 | }, 1539 | Error 1540 | ); 1541 | const ERR_INVALID_PACKAGE_CONFIG = createErrorType( 1542 | `ERR_INVALID_PACKAGE_CONFIG`, 1543 | (path, base, message) => { 1544 | return `Invalid package config ${path}${base ? ` while importing ${base}` : ``}${message ? `. ${message}` : ``}`; 1545 | }, 1546 | Error 1547 | ); 1548 | 1549 | function filterOwnProperties(source, keys) { 1550 | const filtered = /* @__PURE__ */ Object.create(null); 1551 | for (let i = 0; i < keys.length; i++) { 1552 | const key = keys[i]; 1553 | if (ObjectPrototypeHasOwnProperty(source, key)) { 1554 | filtered[key] = source[key]; 1555 | } 1556 | } 1557 | return filtered; 1558 | } 1559 | 1560 | const packageJSONCache = new SafeMap(); 1561 | function getPackageConfig(path, specifier, base, readFileSyncFn) { 1562 | const existing = packageJSONCache.get(path); 1563 | if (existing !== void 0) { 1564 | return existing; 1565 | } 1566 | const source = readFileSyncFn(path); 1567 | if (source === void 0) { 1568 | const packageConfig2 = { 1569 | pjsonPath: path, 1570 | exists: false, 1571 | main: void 0, 1572 | name: void 0, 1573 | type: "none", 1574 | exports: void 0, 1575 | imports: void 0 1576 | }; 1577 | packageJSONCache.set(path, packageConfig2); 1578 | return packageConfig2; 1579 | } 1580 | let packageJSON; 1581 | try { 1582 | packageJSON = JSONParse(source); 1583 | } catch (error) { 1584 | throw new ERR_INVALID_PACKAGE_CONFIG( 1585 | path, 1586 | (base ? `"${specifier}" from ` : "") + fileURLToPath(base || specifier), 1587 | error.message 1588 | ); 1589 | } 1590 | let { imports, main, name, type } = filterOwnProperties(packageJSON, [ 1591 | "imports", 1592 | "main", 1593 | "name", 1594 | "type" 1595 | ]); 1596 | const exports = ObjectPrototypeHasOwnProperty(packageJSON, "exports") ? packageJSON.exports : void 0; 1597 | if (typeof imports !== "object" || imports === null) { 1598 | imports = void 0; 1599 | } 1600 | if (typeof main !== "string") { 1601 | main = void 0; 1602 | } 1603 | if (typeof name !== "string") { 1604 | name = void 0; 1605 | } 1606 | if (type !== "module" && type !== "commonjs") { 1607 | type = "none"; 1608 | } 1609 | const packageConfig = { 1610 | pjsonPath: path, 1611 | exists: true, 1612 | main, 1613 | name, 1614 | type, 1615 | exports, 1616 | imports 1617 | }; 1618 | packageJSONCache.set(path, packageConfig); 1619 | return packageConfig; 1620 | } 1621 | function getPackageScopeConfig(resolved, readFileSyncFn) { 1622 | let packageJSONUrl = new URL("./package.json", resolved); 1623 | while (true) { 1624 | const packageJSONPath2 = packageJSONUrl.pathname; 1625 | if (StringPrototypeEndsWith(packageJSONPath2, "node_modules/package.json")) { 1626 | break; 1627 | } 1628 | const packageConfig2 = getPackageConfig( 1629 | fileURLToPath(packageJSONUrl), 1630 | resolved, 1631 | void 0, 1632 | readFileSyncFn 1633 | ); 1634 | if (packageConfig2.exists) { 1635 | return packageConfig2; 1636 | } 1637 | const lastPackageJSONUrl = packageJSONUrl; 1638 | packageJSONUrl = new URL("../package.json", packageJSONUrl); 1639 | if (packageJSONUrl.pathname === lastPackageJSONUrl.pathname) { 1640 | break; 1641 | } 1642 | } 1643 | const packageJSONPath = fileURLToPath(packageJSONUrl); 1644 | const packageConfig = { 1645 | pjsonPath: packageJSONPath, 1646 | exists: false, 1647 | main: void 0, 1648 | name: void 0, 1649 | type: "none", 1650 | exports: void 0, 1651 | imports: void 0 1652 | }; 1653 | packageJSONCache.set(packageJSONPath, packageConfig); 1654 | return packageConfig; 1655 | } 1656 | 1657 | /** 1658 | @license 1659 | Copyright Node.js contributors. All rights reserved. 1660 | 1661 | Permission is hereby granted, free of charge, to any person obtaining a copy 1662 | of this software and associated documentation files (the "Software"), to 1663 | deal in the Software without restriction, including without limitation the 1664 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 1665 | sell copies of the Software, and to permit persons to whom the Software is 1666 | furnished to do so, subject to the following conditions: 1667 | 1668 | The above copyright notice and this permission notice shall be included in 1669 | all copies or substantial portions of the Software. 1670 | 1671 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1672 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 1673 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 1674 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 1675 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 1676 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 1677 | IN THE SOFTWARE. 1678 | */ 1679 | function throwImportNotDefined(specifier, packageJSONUrl, base) { 1680 | throw new ERR_PACKAGE_IMPORT_NOT_DEFINED( 1681 | specifier, 1682 | packageJSONUrl && fileURLToPath(new URL(".", packageJSONUrl)), 1683 | fileURLToPath(base) 1684 | ); 1685 | } 1686 | function throwInvalidSubpath(subpath, packageJSONUrl, internal, base) { 1687 | const reason = `request is not a valid subpath for the "${internal ? "imports" : "exports"}" resolution of ${fileURLToPath(packageJSONUrl)}`; 1688 | throw new ERR_INVALID_MODULE_SPECIFIER( 1689 | subpath, 1690 | reason, 1691 | base && fileURLToPath(base) 1692 | ); 1693 | } 1694 | function throwInvalidPackageTarget(subpath, target, packageJSONUrl, internal, base) { 1695 | if (typeof target === "object" && target !== null) { 1696 | target = JSONStringify(target, null, ""); 1697 | } else { 1698 | target = `${target}`; 1699 | } 1700 | throw new ERR_INVALID_PACKAGE_TARGET( 1701 | fileURLToPath(new URL(".", packageJSONUrl)), 1702 | subpath, 1703 | target, 1704 | internal, 1705 | base && fileURLToPath(base) 1706 | ); 1707 | } 1708 | const invalidSegmentRegEx = /(^|\\|\/)((\.|%2e)(\.|%2e)?|(n|%6e|%4e)(o|%6f|%4f)(d|%64|%44)(e|%65|%45)(_|%5f)(m|%6d|%4d)(o|%6f|%4f)(d|%64|%44)(u|%75|%55)(l|%6c|%4c)(e|%65|%45)(s|%73|%53))(\\|\/|$)/i; 1709 | const patternRegEx = /\*/g; 1710 | function resolvePackageTargetString(target, subpath, match, packageJSONUrl, base, pattern, internal, conditions) { 1711 | if (subpath !== "" && !pattern && target[target.length - 1] !== "/") 1712 | throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); 1713 | if (!StringPrototypeStartsWith(target, "./")) { 1714 | if (internal && !StringPrototypeStartsWith(target, "../") && !StringPrototypeStartsWith(target, "/")) { 1715 | let isURL = false; 1716 | try { 1717 | new URL(target); 1718 | isURL = true; 1719 | } catch { 1720 | } 1721 | if (!isURL) { 1722 | const exportTarget = pattern ? RegExpPrototypeSymbolReplace(patternRegEx, target, () => subpath) : target + subpath; 1723 | return exportTarget; 1724 | } 1725 | } 1726 | throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); 1727 | } 1728 | if (RegExpPrototypeExec( 1729 | invalidSegmentRegEx, 1730 | StringPrototypeSlice(target, 2) 1731 | ) !== null) 1732 | throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); 1733 | const resolved = new URL(target, packageJSONUrl); 1734 | const resolvedPath = resolved.pathname; 1735 | const packagePath = new URL(".", packageJSONUrl).pathname; 1736 | if (!StringPrototypeStartsWith(resolvedPath, packagePath)) 1737 | throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); 1738 | if (subpath === "") 1739 | return resolved; 1740 | if (RegExpPrototypeExec(invalidSegmentRegEx, subpath) !== null) { 1741 | const request = pattern ? StringPrototypeReplace(match, "*", () => subpath) : match + subpath; 1742 | throwInvalidSubpath(request, packageJSONUrl, internal, base); 1743 | } 1744 | if (pattern) { 1745 | return new URL( 1746 | RegExpPrototypeSymbolReplace(patternRegEx, resolved.href, () => subpath) 1747 | ); 1748 | } 1749 | return new URL(subpath, resolved); 1750 | } 1751 | function isArrayIndex(key) { 1752 | const keyNum = +key; 1753 | if (`${keyNum}` !== key) 1754 | return false; 1755 | return keyNum >= 0 && keyNum < 4294967295; 1756 | } 1757 | function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, base, pattern, internal, conditions) { 1758 | if (typeof target === "string") { 1759 | return resolvePackageTargetString( 1760 | target, 1761 | subpath, 1762 | packageSubpath, 1763 | packageJSONUrl, 1764 | base, 1765 | pattern, 1766 | internal); 1767 | } else if (ArrayIsArray(target)) { 1768 | if (target.length === 0) { 1769 | return null; 1770 | } 1771 | let lastException; 1772 | for (let i = 0; i < target.length; i++) { 1773 | const targetItem = target[i]; 1774 | let resolveResult; 1775 | try { 1776 | resolveResult = resolvePackageTarget( 1777 | packageJSONUrl, 1778 | targetItem, 1779 | subpath, 1780 | packageSubpath, 1781 | base, 1782 | pattern, 1783 | internal, 1784 | conditions 1785 | ); 1786 | } catch (e) { 1787 | lastException = e; 1788 | if (e.code === "ERR_INVALID_PACKAGE_TARGET") { 1789 | continue; 1790 | } 1791 | throw e; 1792 | } 1793 | if (resolveResult === void 0) { 1794 | continue; 1795 | } 1796 | if (resolveResult === null) { 1797 | lastException = null; 1798 | continue; 1799 | } 1800 | return resolveResult; 1801 | } 1802 | if (lastException === void 0 || lastException === null) 1803 | return lastException; 1804 | throw lastException; 1805 | } else if (typeof target === "object" && target !== null) { 1806 | const keys = ObjectGetOwnPropertyNames(target); 1807 | for (let i = 0; i < keys.length; i++) { 1808 | const key = keys[i]; 1809 | if (isArrayIndex(key)) { 1810 | throw new ERR_INVALID_PACKAGE_CONFIG( 1811 | fileURLToPath(packageJSONUrl), 1812 | base, 1813 | '"exports" cannot contain numeric property keys.' 1814 | ); 1815 | } 1816 | } 1817 | for (let i = 0; i < keys.length; i++) { 1818 | const key = keys[i]; 1819 | if (key === "default" || conditions.has(key)) { 1820 | const conditionalTarget = target[key]; 1821 | const resolveResult = resolvePackageTarget( 1822 | packageJSONUrl, 1823 | conditionalTarget, 1824 | subpath, 1825 | packageSubpath, 1826 | base, 1827 | pattern, 1828 | internal, 1829 | conditions 1830 | ); 1831 | if (resolveResult === void 0) 1832 | continue; 1833 | return resolveResult; 1834 | } 1835 | } 1836 | return void 0; 1837 | } else if (target === null) { 1838 | return null; 1839 | } 1840 | throwInvalidPackageTarget( 1841 | packageSubpath, 1842 | target, 1843 | packageJSONUrl, 1844 | internal, 1845 | base 1846 | ); 1847 | } 1848 | function patternKeyCompare(a, b) { 1849 | const aPatternIndex = StringPrototypeIndexOf(a, "*"); 1850 | const bPatternIndex = StringPrototypeIndexOf(b, "*"); 1851 | const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1; 1852 | const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1; 1853 | if (baseLenA > baseLenB) 1854 | return -1; 1855 | if (baseLenB > baseLenA) 1856 | return 1; 1857 | if (aPatternIndex === -1) 1858 | return 1; 1859 | if (bPatternIndex === -1) 1860 | return -1; 1861 | if (a.length > b.length) 1862 | return -1; 1863 | if (b.length > a.length) 1864 | return 1; 1865 | return 0; 1866 | } 1867 | function packageImportsResolve({ name, base, conditions, readFileSyncFn }) { 1868 | if (name === "#" || StringPrototypeStartsWith(name, "#/") || StringPrototypeEndsWith(name, "/")) { 1869 | const reason = "is not a valid internal imports specifier name"; 1870 | throw new ERR_INVALID_MODULE_SPECIFIER(name, reason, fileURLToPath(base)); 1871 | } 1872 | let packageJSONUrl; 1873 | const packageConfig = getPackageScopeConfig(base, readFileSyncFn); 1874 | if (packageConfig.exists) { 1875 | packageJSONUrl = pathToFileURL(packageConfig.pjsonPath); 1876 | const imports = packageConfig.imports; 1877 | if (imports) { 1878 | if (ObjectPrototypeHasOwnProperty(imports, name) && !StringPrototypeIncludes(name, "*")) { 1879 | const resolveResult = resolvePackageTarget( 1880 | packageJSONUrl, 1881 | imports[name], 1882 | "", 1883 | name, 1884 | base, 1885 | false, 1886 | true, 1887 | conditions 1888 | ); 1889 | if (resolveResult != null) { 1890 | return resolveResult; 1891 | } 1892 | } else { 1893 | let bestMatch = ""; 1894 | let bestMatchSubpath; 1895 | const keys = ObjectGetOwnPropertyNames(imports); 1896 | for (let i = 0; i < keys.length; i++) { 1897 | const key = keys[i]; 1898 | const patternIndex = StringPrototypeIndexOf(key, "*"); 1899 | if (patternIndex !== -1 && StringPrototypeStartsWith( 1900 | name, 1901 | StringPrototypeSlice(key, 0, patternIndex) 1902 | )) { 1903 | const patternTrailer = StringPrototypeSlice(key, patternIndex + 1); 1904 | if (name.length >= key.length && StringPrototypeEndsWith(name, patternTrailer) && patternKeyCompare(bestMatch, key) === 1 && StringPrototypeLastIndexOf(key, "*") === patternIndex) { 1905 | bestMatch = key; 1906 | bestMatchSubpath = StringPrototypeSlice( 1907 | name, 1908 | patternIndex, 1909 | name.length - patternTrailer.length 1910 | ); 1911 | } 1912 | } 1913 | } 1914 | if (bestMatch) { 1915 | const target = imports[bestMatch]; 1916 | const resolveResult = resolvePackageTarget( 1917 | packageJSONUrl, 1918 | target, 1919 | bestMatchSubpath, 1920 | bestMatch, 1921 | base, 1922 | true, 1923 | true, 1924 | conditions 1925 | ); 1926 | if (resolveResult != null) { 1927 | return resolveResult; 1928 | } 1929 | } 1930 | } 1931 | } 1932 | } 1933 | throwImportNotDefined(name, packageJSONUrl, base); 1934 | } 1935 | 1936 | const pathRegExp = /^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:node:)?(?:@[^/]+\/)?[^/]+)\/*(.*|)$/; 1937 | const isRelativeRegexp = /^\.{0,2}\//; 1938 | function tryReadFile(filePath) { 1939 | try { 1940 | return fs.readFileSync(filePath, `utf8`); 1941 | } catch (err) { 1942 | if (err.code === `ENOENT`) 1943 | return void 0; 1944 | throw err; 1945 | } 1946 | } 1947 | async function resolvePrivateRequest(specifier, issuer, context, nextResolve) { 1948 | const resolved = packageImportsResolve({ 1949 | name: specifier, 1950 | base: pathToFileURL(issuer), 1951 | conditions: new Set(context.conditions), 1952 | readFileSyncFn: tryReadFile 1953 | }); 1954 | if (resolved instanceof URL) { 1955 | return { url: resolved.href, shortCircuit: true }; 1956 | } else { 1957 | if (resolved.startsWith(`#`)) 1958 | throw new Error(`Mapping from one private import to another isn't allowed`); 1959 | return resolve$1(resolved, context, nextResolve); 1960 | } 1961 | } 1962 | async function resolve$1(originalSpecifier, context, nextResolve) { 1963 | const { findPnpApi } = moduleExports; 1964 | if (!findPnpApi || isBuiltin(originalSpecifier)) 1965 | return nextResolve(originalSpecifier, context, nextResolve); 1966 | let specifier = originalSpecifier; 1967 | const url = tryParseURL(specifier, isRelativeRegexp.test(specifier) ? context.parentURL : void 0); 1968 | if (url) { 1969 | if (url.protocol !== `file:`) 1970 | return nextResolve(originalSpecifier, context, nextResolve); 1971 | specifier = fileURLToPath(url); 1972 | } 1973 | const { parentURL, conditions = [] } = context; 1974 | const issuer = parentURL && tryParseURL(parentURL)?.protocol === `file:` ? fileURLToPath(parentURL) : process.cwd(); 1975 | const pnpapi = findPnpApi(issuer) ?? (url ? findPnpApi(specifier) : null); 1976 | if (!pnpapi) 1977 | return nextResolve(originalSpecifier, context, nextResolve); 1978 | if (specifier.startsWith(`#`)) 1979 | return resolvePrivateRequest(specifier, issuer, context, nextResolve); 1980 | const dependencyNameMatch = specifier.match(pathRegExp); 1981 | let allowLegacyResolve = false; 1982 | if (dependencyNameMatch) { 1983 | const [, dependencyName, subPath] = dependencyNameMatch; 1984 | if (subPath === `` && dependencyName !== `pnpapi`) { 1985 | const resolved = pnpapi.resolveToUnqualified(`${dependencyName}/package.json`, issuer); 1986 | if (resolved) { 1987 | const content = await tryReadFile$1(resolved); 1988 | if (content) { 1989 | const pkg = JSON.parse(content); 1990 | allowLegacyResolve = pkg.exports == null; 1991 | } 1992 | } 1993 | } 1994 | } 1995 | let result; 1996 | try { 1997 | result = pnpapi.resolveRequest(specifier, issuer, { 1998 | conditions: new Set(conditions), 1999 | extensions: allowLegacyResolve ? void 0 : [] 2000 | }); 2001 | } catch (err) { 2002 | if (err instanceof Error && `code` in err && err.code === `MODULE_NOT_FOUND`) 2003 | err.code = `ERR_MODULE_NOT_FOUND`; 2004 | throw err; 2005 | } 2006 | if (!result) 2007 | throw new Error(`Resolving '${specifier}' from '${issuer}' failed`); 2008 | const resultURL = pathToFileURL(result); 2009 | if (url) { 2010 | resultURL.search = url.search; 2011 | resultURL.hash = url.hash; 2012 | } 2013 | if (!parentURL) 2014 | setEntrypointPath(fileURLToPath(resultURL)); 2015 | return { 2016 | url: resultURL.href, 2017 | shortCircuit: true 2018 | }; 2019 | } 2020 | 2021 | if (!HAS_LAZY_LOADED_TRANSLATORS) { 2022 | const binding = process.binding(`fs`); 2023 | const originalfstat = binding.fstat; 2024 | const ZIP_MASK = 4278190080; 2025 | const ZIP_MAGIC = 704643072; 2026 | binding.fstat = function(...args) { 2027 | const [fd, useBigint, req] = args; 2028 | if ((fd & ZIP_MASK) === ZIP_MAGIC && useBigint === false && req === void 0) { 2029 | try { 2030 | const stats = fs.fstatSync(fd); 2031 | return new Float64Array([ 2032 | stats.dev, 2033 | stats.mode, 2034 | stats.nlink, 2035 | stats.uid, 2036 | stats.gid, 2037 | stats.rdev, 2038 | stats.blksize, 2039 | stats.ino, 2040 | stats.size, 2041 | stats.blocks 2042 | ]); 2043 | } catch { 2044 | } 2045 | } 2046 | return originalfstat.apply(this, args); 2047 | }; 2048 | } 2049 | 2050 | const resolve = resolve$1; 2051 | const load = load$1; 2052 | 2053 | export { load, resolve }; 2054 | -------------------------------------------------------------------------------- /.yarn/install-state.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesandbox/excalidraw-firebase/65098d9cd5135a247f3ce4471fc37987f1f03875/.yarn/install-state.gz -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-4.0.1.cjs 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 CodeSandbox 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # excalidraw-firebase 2 | 3 | > A persistent catalogue of your and/or company Excalidraws 4 | 5 | ![screenshot](screenshot.png) 6 | 7 | ## How to set up 8 | 9 | 1. Create a Firebase Project on the [Firebase Console](https://console.firebase.google.com/u/0/) 10 | 2. Copy the Firebase config to the `src/firebase.config.json` file: 11 | 12 | ```json 13 | { 14 | "apiKey": "...", 15 | "authDomain": "...", 16 | "projectId": "...", 17 | "storageBucket": "...", 18 | "messagingSenderId": "...", 19 | "appId": "..." 20 | } 21 | ``` 22 | 23 | 3. Add **Google** as Authentication -> Sign In Method, in Firebase Console 24 | 4. Install the Firebase tools: `yarn add -g firebase-tools` and log in `firebase login` 25 | 5. Change the `firestore.rules` file to reflect your personal email or your company Google domain 26 | 6. (Optional) Go to Authentication -> Sign In Method and add a custom domain 27 | 7. Deploy it with `yarn deploy` 28 | -------------------------------------------------------------------------------- /__mocks__/@excalidraw/excalidraw.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /__mocks__/points-on-curve.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /__mocks__/roughjs/bin/math.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Random: class { 3 | next() { 4 | return 0; 5 | } 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /__mocks__/roughjs/bin/rough.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "hosting": { 7 | "public": "dist", 8 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /{document=**} { 5 | allow read, write: if request.auth.token.email.matches('.*@codesandbox.io$'); 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CSB Excalidraw 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "excalidraw-firebase", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "deploy": "yarn build && firebase deploy", 8 | "serve": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@excalidraw/excalidraw": "^0.16.1", 12 | "@fortawesome/fontawesome-svg-core": "^1.2.34", 13 | "@fortawesome/free-solid-svg-icons": "^5.15.2", 14 | "@fortawesome/react-fontawesome": "^0.1.14", 15 | "@loomhq/loom-sdk": "^1.4.11", 16 | "date-fns": "^2.19.0", 17 | "firebase": "^8.2.9", 18 | "lodash.debounce": "^4.0.8", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-router": "^5.2.0", 22 | "react-router-dom": "^6.18.0", 23 | "react-states": "^6.2.3", 24 | "tailwindcss": "^2.2.4" 25 | }, 26 | "devDependencies": { 27 | "@types/lodash.debounce": "^4.0.6", 28 | "@types/node": "^20", 29 | "@types/react": "^18.2.34", 30 | "@types/react-dom": "^18.2.14", 31 | "@types/react-router-dom": "^5.1.7", 32 | "@vitejs/plugin-react": "^4.1.1", 33 | "autoprefixer": "^10.2.6", 34 | "postcss": "^8.3.5", 35 | "typescript": "^4.1.2", 36 | "vite": "^4.5.0" 37 | }, 38 | "packageManager": "yarn@4.0.1" 39 | } 40 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Welcome to Firebase Hosting 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 24 | 25 | 26 | 40 | 41 | 42 |
43 |

Welcome

44 |

Firebase Hosting Setup Complete

45 |

You're seeing this because you've successfully setup Firebase Hosting. Now it's time to go build something extraordinary!

46 | Open Hosting Documentation 47 |
48 |

Firebase SDK Loading…

49 | 50 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codesandbox/excalidraw-firebase/65098d9cd5135a247f3ce4471fc37987f1f03875/screenshot.png -------------------------------------------------------------------------------- /src/environment-interface/authentication.ts: -------------------------------------------------------------------------------- 1 | import { TEmit, TSubscribe } from "react-states"; 2 | 3 | export type User = { 4 | uid: string; 5 | name: string; 6 | avatarUrl: string | null; 7 | }; 8 | 9 | export type AuthenticationEvent = 10 | | { 11 | type: "AUTHENTICATION:AUTHENTICATED"; 12 | user: User; 13 | loomApiKey: string | null; 14 | } 15 | | { 16 | type: "AUTHENTICATION:UNAUTHENTICATED"; 17 | } 18 | | { 19 | type: "AUTHENTICATION:SIGN_IN_ERROR"; 20 | error: string; 21 | }; 22 | 23 | export interface Authentication { 24 | subscribe: TSubscribe 25 | emit: TEmit 26 | signIn(): void; 27 | } 28 | -------------------------------------------------------------------------------- /src/environment-interface/copyImageToClipboard.ts: -------------------------------------------------------------------------------- 1 | export interface CopyImageToClipboard { 2 | (image: Blob): void; 3 | } 4 | -------------------------------------------------------------------------------- /src/environment-interface/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Authentication } from "./authentication"; 3 | import { CopyImageToClipboard } from "./copyImageToClipboard"; 4 | import { Storage } from "./storage"; 5 | import { Loom } from "./loom"; 6 | import { createContext, useContext } from "react"; 7 | 8 | 9 | export interface Environment { 10 | storage: Storage; 11 | authentication: Authentication; 12 | copyImageToClipboard: CopyImageToClipboard; 13 | loom: Loom; 14 | } 15 | 16 | const environmentContext = createContext({} as Environment) 17 | 18 | export const useEnvironment = () => useContext(environmentContext) 19 | 20 | export const EnvironmentProvider: React.FC<{ environment: Environment }> = ({ children, environment}) => ( 21 | {children} 22 | ) 23 | 24 | export const createEnvironment = (constr: () => Environment) => constr() -------------------------------------------------------------------------------- /src/environment-interface/loom.ts: -------------------------------------------------------------------------------- 1 | import { TEmit, TSubscribe } from "react-states"; 2 | 3 | export interface LoomVideo { 4 | id: string; 5 | title: string; 6 | height: number; 7 | width: number; 8 | sharedUrl: string; 9 | embedUrl: string; 10 | thumbnailHeight?: number; 11 | thumbnailWidth?: number; 12 | thumbnailUrl?: string; 13 | duration?: number; 14 | providerUrl: string; 15 | } 16 | 17 | export type LoomEvent = 18 | | { 19 | type: "LOOM:CONFIGURED"; 20 | } 21 | | { 22 | type: "LOOM:INSERT"; 23 | video: LoomVideo; 24 | } 25 | | { 26 | type: "LOOM:START"; 27 | } 28 | | { 29 | type: "LOOM:CANCEL"; 30 | } 31 | | { 32 | type: "LOOM:COMPLETE"; 33 | } 34 | | { 35 | type: "LOOM:ERROR"; 36 | error: string; 37 | }; 38 | 39 | export interface Loom { 40 | subscribe: TSubscribe; 41 | emit: TEmit 42 | configure(apiKey: string, buttonId: string): void; 43 | openVideo(video: LoomVideo): void; 44 | } 45 | -------------------------------------------------------------------------------- /src/environment-interface/storage.ts: -------------------------------------------------------------------------------- 1 | import { ExcalidrawElement } from "@excalidraw/excalidraw/types/element/types"; 2 | import { AppState } from "@excalidraw/excalidraw/types/types"; 3 | import { TEmit, TSubscribe } from "react-states"; 4 | 5 | 6 | export type StorageError = { 7 | type: "ERROR"; 8 | data: string; 9 | }; 10 | 11 | export type { ExcalidrawElement } from "@excalidraw/excalidraw/types/element/types"; 12 | 13 | export type ExcalidrawMetadata = { 14 | id: string; 15 | author: string; 16 | last_updated: Date; 17 | title: string; 18 | }; 19 | 20 | export type ExcalidrawData = { 21 | elements: readonly ExcalidrawElement[]; 22 | appState: AppState; 23 | version: number; 24 | }; 25 | 26 | export type ExcalidrawPreview = { 27 | metadata: ExcalidrawMetadata; 28 | user: { 29 | uid: string; 30 | name: string; 31 | avatarUrl: string | null; 32 | }; 33 | }; 34 | 35 | export type ExcalidrawPreviews = ExcalidrawPreview[]; 36 | 37 | export type StorageEvent = 38 | | { 39 | type: "STORAGE:FETCH_EXCALIDRAW_SUCCESS"; 40 | metadata: ExcalidrawMetadata; 41 | data: ExcalidrawData; 42 | image: Blob; 43 | } 44 | | { 45 | type: "STORAGE:FETCH_EXCALIDRAW_ERROR"; 46 | error: string; 47 | } 48 | | { 49 | type: "STORAGE:EXCALIDRAW_DATA_UPDATE"; 50 | id: string; 51 | data: ExcalidrawData; 52 | } 53 | | { 54 | type: "STORAGE:CREATE_EXCALIDRAW_SUCCESS"; 55 | id: string; 56 | } 57 | | { 58 | type: "STORAGE:CREATE_EXCALIDRAW_ERROR"; 59 | error: string; 60 | } 61 | | { 62 | type: "STORAGE:SAVE_EXCALIDRAW_SUCCESS"; 63 | metadata: ExcalidrawMetadata; 64 | image: Blob; 65 | } 66 | | { 67 | type: "STORAGE:SAVE_EXCALIDRAW_ERROR"; 68 | error: string; 69 | } 70 | | { 71 | type: "STORAGE:SAVE_EXCALIDRAW_OLD_VERSION"; 72 | } 73 | | { 74 | type: "STORAGE:FETCH_PREVIEWS_SUCCESS"; 75 | excalidraws: ExcalidrawPreviews; 76 | } 77 | | { 78 | type: "STORAGE:FETCH_PREVIEWS_ERROR"; 79 | error: string; 80 | } 81 | | { 82 | type: "STORAGE:FETCH_USER_PREVIEWS_SUCCESS"; 83 | excalidraws: ExcalidrawPreviews; 84 | } 85 | | { 86 | type: "STORAGE:FETCH_USER_PREVIEWS_ERROR"; 87 | error: string; 88 | } 89 | | { 90 | type: "STORAGE:IMAGE_SRC_SUCCESS"; 91 | id: string; 92 | src: string; 93 | } 94 | | { 95 | type: "STORAGE:IMAGE_SRC_ERROR"; 96 | id: string; 97 | error: string; 98 | } 99 | | { 100 | type: "STORAGE:SAVE_TITLE_SUCCESS"; 101 | id: string; 102 | title: string; 103 | } 104 | | { 105 | type: "STORAGE:SAVE_TITLE_ERROR"; 106 | id: string; 107 | title: string; 108 | error: string; 109 | }; 110 | 111 | export interface Storage { 112 | subscribe: TSubscribe 113 | emit: TEmit 114 | createExcalidraw(userId: string): void; 115 | fetchExcalidraw(userId: string, id: string): void; 116 | fetchPreviews(): void; 117 | fetchUserPreviews(uid: string): void; 118 | saveExcalidraw(userId: string, id: string, data: ExcalidrawData): void; 119 | getImageSrc(userId: string, id: string): void; 120 | saveTitle(userId: string, id: string, title: string): void; 121 | } 122 | -------------------------------------------------------------------------------- /src/environments/authentication/browser.ts: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import { createEmitter } from "react-states"; 3 | import { 4 | Authentication, 5 | AuthenticationEvent, 6 | User, 7 | } from "../../environment-interface/authentication"; 8 | 9 | const USERS_COLLECTION = "users"; 10 | const CONFIG_COLLECTION = "config"; 11 | const API_KEYS_DOCUMENT = "apiKeys"; 12 | 13 | const getUser = (firebaseUser: firebase.User): User => ({ 14 | uid: firebaseUser.uid, 15 | name: firebaseUser.email!.split("@")[0], 16 | avatarUrl: firebaseUser.providerData[0]?.photoURL ?? null, 17 | }); 18 | 19 | export const createAuthentication = (app: firebase.app.App): Authentication => { 20 | const { emit, subscribe } = createEmitter(); 21 | 22 | app.auth().onAuthStateChanged((firebaseUser) => { 23 | if (firebaseUser) { 24 | const user = getUser(firebaseUser); 25 | 26 | /* 27 | We update the user document with name and avatarUrl so other 28 | users can see it as well 29 | */ 30 | app.firestore().collection(USERS_COLLECTION).doc(user.uid).set( 31 | { 32 | name: user.name, 33 | avatarUrl: user.avatarUrl, 34 | }, 35 | { 36 | merge: true, 37 | } 38 | ); 39 | 40 | app 41 | .firestore() 42 | .collection(CONFIG_COLLECTION) 43 | .doc(API_KEYS_DOCUMENT) 44 | .get() 45 | .then((doc) => { 46 | const data = doc.data(); 47 | emit({ 48 | type: "AUTHENTICATION:AUTHENTICATED", 49 | user, 50 | loomApiKey: data?.loom ?? null, 51 | }); 52 | }); 53 | } else { 54 | emit({ 55 | type: "AUTHENTICATION:UNAUTHENTICATED", 56 | }); 57 | } 58 | }); 59 | 60 | return { 61 | emit, 62 | subscribe, 63 | signIn: () => { 64 | const provider = new firebase.auth.GoogleAuthProvider(); 65 | app 66 | .auth() 67 | .signInWithPopup(provider) 68 | .catch((error: Error) => { 69 | emit({ 70 | type: "AUTHENTICATION:SIGN_IN_ERROR", 71 | error: error.message, 72 | }); 73 | }); 74 | }, 75 | }; 76 | }; 77 | -------------------------------------------------------------------------------- /src/environments/authentication/test.ts: -------------------------------------------------------------------------------- 1 | import { createEmitter } from "react-states"; 2 | import { Authentication } from "../../environment-interface/authentication"; 3 | 4 | export const createAuthentication = (): Authentication => ({ 5 | ...createEmitter(), 6 | signIn: jest.fn(), 7 | }); 8 | -------------------------------------------------------------------------------- /src/environments/browser.ts: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import "firebase/auth"; 3 | import "firebase/firestore"; 4 | import "firebase/storage"; 5 | 6 | import config from "../firebase.config.json"; 7 | import { createEnvironment } from "../environment-interface"; 8 | import { createAuthentication } from "./authentication/browser"; 9 | import { createCopyImageToClipboard } from "./copyImageToClipboard/browser"; 10 | import { createLoom } from "./loom/browser"; 11 | import { createStorage } from "./storage/browser"; 12 | 13 | export const environment = createEnvironment(() => { 14 | const app = firebase.initializeApp(config); 15 | 16 | return { 17 | authentication: createAuthentication(app), 18 | copyImageToClipboard: createCopyImageToClipboard(), 19 | loom: createLoom(), 20 | storage: createStorage(app), 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /src/environments/copyImageToClipboard/browser.ts: -------------------------------------------------------------------------------- 1 | import { CopyImageToClipboard } from "../../environment-interface/copyImageToClipboard"; 2 | 3 | export const createCopyImageToClipboard = (): CopyImageToClipboard => ( 4 | image 5 | ) => { 6 | // @ts-ignore 7 | navigator.clipboard.write([ 8 | // @ts-ignore 9 | new window.ClipboardItem({ "image/png": image }), 10 | ]); 11 | }; 12 | -------------------------------------------------------------------------------- /src/environments/copyImageToClipboard/test.ts: -------------------------------------------------------------------------------- 1 | import { CopyImageToClipboard } from "../../environment-interface/copyImageToClipboard"; 2 | 3 | export const createCopyImageToClipboard = (): CopyImageToClipboard => jest.fn(); 4 | -------------------------------------------------------------------------------- /src/environments/loom/browser.ts: -------------------------------------------------------------------------------- 1 | import { setup } from "@loomhq/loom-sdk"; 2 | import { createEmitter } from "react-states"; 3 | import { Loom, LoomEvent } from "../../environment-interface/loom"; 4 | 5 | type ButtonFn = ReturnType extends Promise 6 | ? R extends { configureButton: any } 7 | ? R["configureButton"] 8 | : never 9 | : never; 10 | 11 | export const createLoom = (): Loom => { 12 | const { subscribe, emit } = createEmitter(); 13 | 14 | let configureButton: ButtonFn | undefined; 15 | 16 | function initialize(configure: ButtonFn, buttonId: string) { 17 | const element = document.querySelector(`#${buttonId}`); 18 | 19 | if (!element) { 20 | emit({ 21 | type: "LOOM:ERROR", 22 | error: "No button", 23 | }); 24 | return; 25 | } 26 | 27 | configure({ 28 | element: element as HTMLElement, 29 | hooks: { 30 | onInsertClicked: (video) => { 31 | if (video) { 32 | emit({ 33 | type: "LOOM:INSERT", 34 | video, 35 | }); 36 | } else { 37 | emit({ 38 | type: "LOOM:CANCEL", 39 | }); 40 | } 41 | }, 42 | onStart: () => { 43 | emit({ 44 | type: "LOOM:START", 45 | }); 46 | }, 47 | onCancel: () => { 48 | emit({ 49 | type: "LOOM:CANCEL", 50 | }); 51 | }, 52 | onComplete: () => { 53 | emit({ 54 | type: "LOOM:COMPLETE", 55 | }); 56 | }, 57 | }, 58 | }); 59 | } 60 | 61 | return { 62 | subscribe, 63 | emit, 64 | configure(apiKey, buttonId) { 65 | if (configureButton) { 66 | initialize(configureButton, buttonId); 67 | } else { 68 | setup({ 69 | apiKey, 70 | }).then((result) => { 71 | emit({ 72 | type: "LOOM:CONFIGURED", 73 | }); 74 | configureButton = result.configureButton; 75 | initialize(configureButton, buttonId); 76 | }); 77 | } 78 | }, 79 | openVideo(video) { 80 | window.open(video.sharedUrl); 81 | }, 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /src/environments/loom/test.ts: -------------------------------------------------------------------------------- 1 | import { createEmitter } from "react-states"; 2 | import { Loom } from "../../environment-interface/loom"; 3 | 4 | export const createLoom = (): Loom => ({ 5 | ...createEmitter(), 6 | configure: jest.fn(), 7 | openVideo: jest.fn(), 8 | }); 9 | -------------------------------------------------------------------------------- /src/environments/storage/browser.ts: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import { 3 | ExcalidrawData, 4 | ExcalidrawMetadata, 5 | ExcalidrawPreview, 6 | Storage, 7 | StorageEvent, 8 | } from "../../environment-interface/storage"; 9 | import { exportToBlob, getSceneVersion } from "@excalidraw/excalidraw"; 10 | 11 | import { subMonths } from "date-fns"; 12 | import { createEmitter } from "react-states"; 13 | 14 | export const createExcalidrawImage = ( 15 | elements: readonly any[], 16 | appState: any 17 | ) => 18 | exportToBlob({ 19 | elements: elements.filter((element) => !element.isDeleted), 20 | appState, 21 | files: null, 22 | }); 23 | 24 | const EXCALIDRAWS_COLLECTION = "excalidraws"; 25 | const EXCALIDRAWS_DATA_COLLECTION = "excalidrawsData"; 26 | const USERS_COLLECTION = "users"; 27 | 28 | export const createStorage = (app: firebase.app.App): Storage => { 29 | const { subscribe, emit } = createEmitter(); 30 | 31 | const excalidrawSnapshotSubscriptions: { 32 | [id: string]: () => void; 33 | } = {}; 34 | 35 | const lastSavedVersions: { 36 | [id: string]: number; 37 | } = {}; 38 | 39 | function getUserExcalidraws( 40 | { 41 | id, 42 | name, 43 | avatarUrl, 44 | }: { 45 | id: string; 46 | name: string; 47 | avatarUrl: string; 48 | }, 49 | since: Date 50 | ) { 51 | return app 52 | .firestore() 53 | .collection(USERS_COLLECTION) 54 | .doc(id) 55 | .collection(EXCALIDRAWS_COLLECTION) 56 | .where("last_updated", ">", since) 57 | .orderBy("last_updated", "desc") 58 | .get() 59 | .then((collection): ExcalidrawPreview[] => { 60 | return collection.docs.map((doc) => { 61 | const data = doc.data(); 62 | 63 | return { 64 | user: { 65 | uid: id, 66 | name, 67 | avatarUrl, 68 | }, 69 | metadata: { 70 | id: doc.id, 71 | title: data.title, 72 | last_updated: data.last_updated.toDate(), 73 | author: id, 74 | }, 75 | }; 76 | }); 77 | }); 78 | } 79 | 80 | function sortExcalidrawPreviews(a: ExcalidrawPreview, b: ExcalidrawPreview) { 81 | if (a.metadata.last_updated.getTime() > b.metadata.last_updated.getTime()) { 82 | return -1; 83 | } else if ( 84 | a.metadata.last_updated.getTime() < b.metadata.last_updated.getTime() 85 | ) { 86 | return 1; 87 | } 88 | 89 | return 0; 90 | } 91 | 92 | return { 93 | subscribe, 94 | emit, 95 | createExcalidraw(userId) { 96 | app 97 | .firestore() 98 | .collection(USERS_COLLECTION) 99 | .doc(userId) 100 | .collection(EXCALIDRAWS_COLLECTION) 101 | .add({ 102 | last_updated: firebase.firestore.FieldValue.serverTimestamp(), 103 | }) 104 | .then((ref) => { 105 | emit({ 106 | type: "STORAGE:CREATE_EXCALIDRAW_SUCCESS", 107 | id: ref.id, 108 | }); 109 | }) 110 | .catch((error: Error) => { 111 | emit({ 112 | type: "STORAGE:CREATE_EXCALIDRAW_ERROR", 113 | error: error.message, 114 | }); 115 | }); 116 | }, 117 | fetchExcalidraw(userId: string, id: string) { 118 | Promise.all([ 119 | app 120 | .firestore() 121 | .collection(USERS_COLLECTION) 122 | .doc(userId) 123 | .collection(EXCALIDRAWS_COLLECTION) 124 | .doc(id) 125 | .get(), 126 | app 127 | .firestore() 128 | .collection(USERS_COLLECTION) 129 | .doc(userId) 130 | .collection(EXCALIDRAWS_DATA_COLLECTION) 131 | .doc(id) 132 | .get(), 133 | ]) 134 | .then(([metadataDoc, dataDoc]) => { 135 | const metadata = { 136 | ...metadataDoc.data(), 137 | id, 138 | } as any; 139 | const data = dataDoc.exists 140 | ? dataDoc.data()! 141 | : { 142 | elements: JSON.stringify([]), 143 | appState: JSON.stringify({ 144 | viewBackgroundColor: "#FFF", 145 | currentItemFontFamily: 1, 146 | }), 147 | version: 0, 148 | }; 149 | 150 | return { 151 | metadata: { 152 | ...(metadata as ExcalidrawMetadata), 153 | id, 154 | last_updated: metadata.last_updated.toDate() as Date, 155 | }, 156 | data: { 157 | appState: JSON.parse(data.appState), 158 | elements: JSON.parse(data.elements), 159 | version: data.version, 160 | }, 161 | }; 162 | }) 163 | .then(({ metadata, data }) => { 164 | return createExcalidrawImage(data.elements, data.appState).then( 165 | (image) => 166 | image 167 | ? { 168 | image, 169 | metadata, 170 | data, 171 | } 172 | : Promise.reject("No image") 173 | ); 174 | }) 175 | .then(({ data, image, metadata }) => { 176 | emit({ 177 | type: "STORAGE:FETCH_EXCALIDRAW_SUCCESS", 178 | metadata, 179 | data, 180 | image, 181 | }); 182 | 183 | if (!excalidrawSnapshotSubscriptions[id]) { 184 | excalidrawSnapshotSubscriptions[id] = app 185 | .firestore() 186 | .collection(USERS_COLLECTION) 187 | .doc(userId) 188 | .collection(EXCALIDRAWS_DATA_COLLECTION) 189 | .doc(id) 190 | .onSnapshot((doc) => { 191 | if (doc.metadata.hasPendingWrites) return; 192 | 193 | const data = doc.data(); 194 | 195 | if (!data) { 196 | return; 197 | } 198 | 199 | if (lastSavedVersions[id] !== data.version) { 200 | lastSavedVersions[id] = data.version; 201 | emit({ 202 | type: "STORAGE:EXCALIDRAW_DATA_UPDATE", 203 | id, 204 | data: { 205 | appState: JSON.parse(data.appState), 206 | elements: JSON.parse(data.elements), 207 | version: data.version, 208 | }, 209 | }); 210 | } 211 | }); 212 | } 213 | }) 214 | .catch((error: Error) => { 215 | emit({ 216 | type: "STORAGE:FETCH_EXCALIDRAW_ERROR", 217 | error: error.message, 218 | }); 219 | }); 220 | }, 221 | saveExcalidraw(userId, id, data) { 222 | const dataDoc = app 223 | .firestore() 224 | .collection(USERS_COLLECTION) 225 | .doc(userId) 226 | .collection(EXCALIDRAWS_DATA_COLLECTION) 227 | .doc(id); 228 | 229 | app 230 | .firestore() 231 | .runTransaction((transaction) => { 232 | return transaction.get(dataDoc).then((existingDoc) => { 233 | if (existingDoc.exists) { 234 | const existingData = existingDoc.data()!; 235 | const parsedData: ExcalidrawData = { 236 | appState: JSON.parse(existingData.appState), 237 | elements: JSON.parse(existingData.elements), 238 | version: existingData.version, 239 | }; 240 | 241 | const newSceneVersion = getSceneVersion(data.elements); 242 | const currentSceneVersion = parsedData.version; 243 | 244 | if (newSceneVersion > currentSceneVersion) { 245 | transaction.update(dataDoc, { 246 | elements: JSON.stringify(data.elements), 247 | appState: JSON.stringify({ 248 | viewBackgroundColor: data.appState.viewBackgroundColor, 249 | }), 250 | version: data.version, 251 | }); 252 | lastSavedVersions[id] = data.version; 253 | } else { 254 | return Promise.reject("NEWER_VERSION"); 255 | } 256 | } else { 257 | transaction.set(dataDoc, { 258 | elements: JSON.stringify(data.elements), 259 | appState: JSON.stringify({ 260 | viewBackgroundColor: data.appState.viewBackgroundColor, 261 | }), 262 | version: data.version, 263 | }); 264 | lastSavedVersions[id] = data.version; 265 | } 266 | }); 267 | }) 268 | .then(() => 269 | app 270 | .firestore() 271 | .collection(USERS_COLLECTION) 272 | .doc(userId) 273 | .collection(EXCALIDRAWS_COLLECTION) 274 | .doc(id) 275 | .set( 276 | { 277 | last_updated: firebase.firestore.FieldValue.serverTimestamp(), 278 | }, 279 | { 280 | merge: true, 281 | } 282 | ) 283 | ) 284 | .then(() => 285 | app 286 | .firestore() 287 | .collection(USERS_COLLECTION) 288 | .doc(userId) 289 | .collection(EXCALIDRAWS_COLLECTION) 290 | .doc(id) 291 | .get() 292 | .then((doc) => { 293 | const metadata = doc.data()!; 294 | 295 | return createExcalidrawImage(data.elements, data.appState).then( 296 | (image) => 297 | image 298 | ? { 299 | metadata, 300 | image, 301 | } 302 | : Promise.reject("No image") 303 | ); 304 | }) 305 | ) 306 | .then(({ metadata, image }) => { 307 | emit({ 308 | type: "STORAGE:SAVE_EXCALIDRAW_SUCCESS", 309 | metadata: { 310 | ...(metadata as ExcalidrawMetadata), 311 | id, 312 | last_updated: metadata.last_updated.toDate() as Date, 313 | }, 314 | image, 315 | }); 316 | 317 | app.storage().ref().child(`previews/${userId}/${id}`).put(image); 318 | }) 319 | .catch((error) => { 320 | if (error === "NEWER_VERSION") { 321 | emit({ 322 | type: "STORAGE:SAVE_EXCALIDRAW_OLD_VERSION", 323 | }); 324 | 325 | return; 326 | } 327 | 328 | emit({ 329 | type: "STORAGE:SAVE_EXCALIDRAW_ERROR", 330 | error: error.message, 331 | }); 332 | }); 333 | }, 334 | fetchPreviews() { 335 | app 336 | .firestore() 337 | .collection(USERS_COLLECTION) 338 | .get() 339 | .then((collection) => 340 | Promise.all( 341 | collection.docs.map((userDoc) => 342 | getUserExcalidraws( 343 | { 344 | id: userDoc.id, 345 | avatarUrl: userDoc.data().avatarUrl, 346 | name: userDoc.data().name, 347 | }, 348 | subMonths(new Date(), 2) 349 | ) 350 | ) 351 | ) 352 | ) 353 | .then((excalidraws) => { 354 | const flattenedAndSortedExcalidraws = excalidraws 355 | .reduce( 356 | (aggr, userExcalidraws) => aggr.concat(userExcalidraws), 357 | [] 358 | ) 359 | .sort(sortExcalidrawPreviews); 360 | 361 | emit({ 362 | type: "STORAGE:FETCH_PREVIEWS_SUCCESS", 363 | excalidraws: flattenedAndSortedExcalidraws, 364 | }); 365 | }) 366 | .catch((error: Error) => { 367 | emit({ 368 | type: "STORAGE:FETCH_PREVIEWS_ERROR", 369 | error: error.message, 370 | }); 371 | }); 372 | }, 373 | fetchUserPreviews(uid) { 374 | app 375 | .firestore() 376 | .collection(USERS_COLLECTION) 377 | .doc(uid) 378 | .get() 379 | .then((userDoc) => { 380 | const data = userDoc.data(); 381 | 382 | if (data) { 383 | return getUserExcalidraws( 384 | { 385 | id: uid, 386 | avatarUrl: data.avatarUrl, 387 | name: data.name, 388 | }, 389 | subMonths(new Date(), 6) 390 | ); 391 | } 392 | 393 | throw new Error("Invalid user"); 394 | }) 395 | .then((excalidraws) => { 396 | const sortedExcalidraws = excalidraws.sort(sortExcalidrawPreviews); 397 | 398 | emit({ 399 | type: "STORAGE:FETCH_USER_PREVIEWS_SUCCESS", 400 | excalidraws: sortedExcalidraws, 401 | }); 402 | }) 403 | .catch((error: Error) => { 404 | emit({ 405 | type: "STORAGE:FETCH_USER_PREVIEWS_ERROR", 406 | error: error.message, 407 | }); 408 | }); 409 | }, 410 | getImageSrc(userId, id) { 411 | firebase 412 | .storage() 413 | .ref() 414 | .child(`previews/${userId}/${id}`) 415 | .getDownloadURL() 416 | .then((src) => { 417 | emit({ 418 | type: "STORAGE:IMAGE_SRC_SUCCESS", 419 | id, 420 | src, 421 | }); 422 | }) 423 | .catch((error: Error) => { 424 | emit({ 425 | type: "STORAGE:IMAGE_SRC_ERROR", 426 | id, 427 | error: error.message, 428 | }); 429 | }); 430 | }, 431 | saveTitle(userId, id, title) { 432 | emit({ 433 | type: "STORAGE:SAVE_TITLE_SUCCESS", 434 | id, 435 | title, 436 | }); 437 | 438 | app 439 | .firestore() 440 | .collection(USERS_COLLECTION) 441 | .doc(userId) 442 | .collection(EXCALIDRAWS_COLLECTION) 443 | .doc(id) 444 | .set( 445 | { 446 | last_updated: firebase.firestore.FieldValue.serverTimestamp(), 447 | title, 448 | }, 449 | { 450 | merge: true, 451 | } 452 | ) 453 | .catch((error) => { 454 | emit({ 455 | type: "STORAGE:SAVE_TITLE_ERROR", 456 | id, 457 | title, 458 | error: error.message, 459 | }); 460 | }); 461 | }, 462 | }; 463 | }; 464 | -------------------------------------------------------------------------------- /src/environments/storage/test.ts: -------------------------------------------------------------------------------- 1 | import { createEmitter } from "react-states"; 2 | import { Storage } from "../../environment-interface/storage"; 3 | 4 | export const createStorage = (): Storage => ({ 5 | ...createEmitter(), 6 | createExcalidraw: jest.fn(), 7 | fetchExcalidraw: jest.fn(), 8 | fetchPreviews: jest.fn(), 9 | fetchUserPreviews: jest.fn(), 10 | saveExcalidraw: jest.fn(), 11 | getImageSrc: jest.fn(), 12 | saveTitle: jest.fn(), 13 | }); 14 | -------------------------------------------------------------------------------- /src/environments/test.ts: -------------------------------------------------------------------------------- 1 | import { createEnvironment } from "../environment-interface"; 2 | import { createAuthentication } from "./authentication/test"; 3 | import { createCopyImageToClipboard } from "./copyImageToClipboard/test"; 4 | import { createLoom } from "./loom/test"; 5 | import { createStorage } from "./storage/test"; 6 | 7 | export const createTestEnvironment = () => 8 | createEnvironment(() => { 9 | return { 10 | authentication: createAuthentication(), 11 | copyImageToClipboard: createCopyImageToClipboard(), 12 | loom: createLoom(), 13 | storage: createStorage(), 14 | }; 15 | }); 16 | -------------------------------------------------------------------------------- /src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/firebase.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiKey": "AIzaSyD0We3AAFK0UNTBsf5Cweisi_5hV9X9h9I", 3 | "authDomain": "excalidraw-8b385.firebaseapp.com", 4 | "projectId": "excalidraw-8b385", 5 | "storageBucket": "excalidraw-8b385.appspot.com", 6 | "messagingSenderId": "807535976681", 7 | "appId": "1:807535976681:web:2fe48e87a254911d81d078" 8 | } 9 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .lds-dual-ring { 6 | display: inline-block; 7 | position: absolute; 8 | left: 50%; 9 | top: 50%; 10 | transform: translate(-50%, -50%); 11 | } 12 | 13 | .lds-dual-ring:after { 14 | content: " "; 15 | display: block; 16 | width: 12px; 17 | height: 12px; 18 | border-radius: 50%; 19 | border: 4px solid #333; 20 | border-color: #333 transparent #333 transparent; 21 | animation: lds-dual-ring 1.2s linear infinite; 22 | opacity: 0.4; 23 | } 24 | 25 | @keyframes lds-dual-ring { 26 | 0% { 27 | transform: rotate(0deg); 28 | } 29 | 100% { 30 | transform: rotate(360deg); 31 | } 32 | } 33 | 34 | .library-button { 35 | display: none !important; 36 | } 37 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { DevtoolsProvider } from "react-states/devtools"; 4 | import "./index.css"; 5 | import { Pages } from "./pages"; 6 | 7 | import { EnvironmentProvider } from "./environment-interface"; 8 | import { environment } from "./environments/browser"; 9 | 10 | // Polyfill for Loom 11 | if (typeof (window as any).global === "undefined") { 12 | (window as any).global = window; 13 | } 14 | 15 | const app = ( 16 | 17 | 18 | 19 | ); 20 | 21 | const container = document.getElementById("root")!; 22 | const root = createRoot(container); // createRoot(container!) if you use TypeScript 23 | root.render( 24 | 25 | {import.meta.env.PROD ? app : {app}} 26 | , 27 | ); 28 | -------------------------------------------------------------------------------- /src/pages/dashboard/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ExcalidrawPreview as ExcalidrawPreviewComponent } from "./ExcalidrawPreview"; 3 | import { ExcalidrawPreview } from "../../environment-interface/storage"; 4 | 5 | export const Dashboard = ({ 6 | excalidraws, 7 | }: { 8 | excalidraws: ExcalidrawPreview[]; 9 | }) => { 10 | return ( 11 |
12 |
    13 | {excalidraws.map((excalidraw) => ( 14 | 19 | ))} 20 |
21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/pages/dashboard/ExcalidrawPreview.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { match, transition, useStateEffect } from "react-states"; 3 | import { 4 | ExcalidrawMetadata, 5 | StorageEvent, 6 | } from "../../environment-interface/storage"; 7 | import formatDistanceToNow from "date-fns/formatDistanceToNow"; 8 | 9 | import { Link } from "react-router-dom"; 10 | import { User } from "../../environment-interface/authentication"; 11 | import { useEnvironment } from "../../environment-interface"; 12 | 13 | type State = 14 | | { 15 | state: "LOADING_PREVIEW"; 16 | id: string; 17 | } 18 | | { 19 | state: "PREVIEW_LOADED"; 20 | src: string; 21 | } 22 | | { 23 | state: "LOADING_ERROR"; 24 | error: string; 25 | }; 26 | 27 | const reducer = (state: State, action: StorageEvent) => 28 | transition(state, action, { 29 | LOADING_PREVIEW: { 30 | "STORAGE:IMAGE_SRC_SUCCESS": (state, { id, src }): State => 31 | id === state.id 32 | ? { 33 | state: "PREVIEW_LOADED", 34 | src, 35 | } 36 | : state, 37 | "STORAGE:IMAGE_SRC_ERROR": (state, { id, error }): State => 38 | state.id === id 39 | ? { 40 | state: "LOADING_ERROR", 41 | error, 42 | } 43 | : state, 44 | }, 45 | PREVIEW_LOADED: {}, 46 | LOADING_ERROR: {}, 47 | }); 48 | 49 | export const ExcalidrawPreview = ({ 50 | user, 51 | metadata, 52 | }: { 53 | user: User; 54 | metadata: ExcalidrawMetadata; 55 | }) => { 56 | const { storage } = useEnvironment(); 57 | const [preview, dispatch] = React.useReducer(reducer, { 58 | state: "LOADING_PREVIEW", 59 | id: metadata.id, 60 | }); 61 | 62 | React.useEffect(() => storage.subscribe(dispatch), []); 63 | 64 | useStateEffect(preview, "LOADING_PREVIEW", () => { 65 | storage.getImageSrc(user.uid, metadata.id); 66 | }); 67 | 68 | const renderPreview = (background: string) => ( 69 |
70 | ); 71 | 72 | return ( 73 | 74 |
75 |
76 | {user.avatarUrl ? ( 77 | 82 | ) : null} 83 |

84 |

87 |
88 |
89 | {match(preview, { 90 | LOADING_PREVIEW: () =>
, 91 | PREVIEW_LOADED: ({ src }) => 92 | renderPreview(`center / contain no-repeat url(${src})`), 93 | LOADING_ERROR: () => renderPreview("#FFF"), 94 | })} 95 |
96 |
97 | 98 | {formatDistanceToNow(metadata.last_updated)} ago 99 | 100 |
101 |
102 | 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /src/pages/dashboard/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import { match } from "react-states"; 4 | 5 | import { useNavigation } from "./useNavigation"; 6 | 7 | export const Navigation: React.FC = () => { 8 | const navigate = useNavigate(); 9 | const [state, dispatch] = useNavigation({ 10 | navigate: (path) => { 11 | navigate(path); 12 | }, 13 | }); 14 | 15 | return ( 16 |
17 |
18 | 19 | 29 | 35 | 41 | 42 | 43 |

44 | CodeSandbox Excalidraw 45 |

46 |
47 |
48 |
49 | 64 | 79 |
80 |
81 | 90 |
91 |
92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/pages/dashboard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Dashboard } from "./Dashboard"; 3 | import { match, PickState } from "react-states"; 4 | 5 | import { Navigation } from "./Navigation"; 6 | import { useMatch } from "react-router-dom"; 7 | 8 | import { useDashboard } from "./useDashboard"; 9 | import { useUserDashboard } from "./useUserDashboard"; 10 | 11 | const SharedDashboard = () => { 12 | const state = useDashboard(); 13 | 14 | return match(state, { 15 | LOADING_PREVIEWS: () =>
, 16 | PREVIEWS_ERROR: ({ error }) => ( 17 |

There was an error: {error}

18 | ), 19 | PREVIEWS_LOADED: ({ excalidraws }) => ( 20 | 21 | ), 22 | }); 23 | }; 24 | 25 | const UserDashboard: React.FC<{ uid: string }> = ({ uid }) => { 26 | const [state] = useUserDashboard({ uid }); 27 | 28 | return match(state, { 29 | LOADING_PREVIEWS: () =>
, 30 | PREVIEWS_ERROR: ({ error }) => ( 31 |

There was an error: {error}

32 | ), 33 | PREVIEWS_LOADED: ({ excalidraws }) => ( 34 | 35 | ), 36 | }); 37 | }; 38 | 39 | export const DashboardPage = () => { 40 | const match = useMatch("/:userId"); 41 | 42 | return ( 43 |
44 | 45 | {match?.params.userId ? ( 46 | 47 | ) : ( 48 | 49 | )} 50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/pages/dashboard/useDashboard.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { act } from "@testing-library/react"; 3 | 4 | import { createStorage } from "../../environments/storage/test"; 5 | import { createAuthentication } from "../../environments/authentication/test"; 6 | import { createTestEnvironment } from "../../environments/test"; 7 | import { DashboardState, useDashboard } from "./useDashboard"; 8 | import { EnvironmentProvider } from "../../environment-interface"; 9 | import { renderReducer } from "react-states/test"; 10 | 11 | describe("Dashboard", () => { 12 | test("Should go to PREVIEWS_LOADED when mounting and successfully downloading previews", () => { 13 | const environment = createTestEnvironment(); 14 | 15 | const [state] = renderReducer( 16 | () => [useDashboard(), () => {}], 17 | (UseDashboard) => ( 18 | 19 | 20 | 21 | ) 22 | ); 23 | 24 | const mockedPreviews = [ 25 | { 26 | user: { 27 | avatarUrl: "", 28 | name: "Kate", 29 | uid: "123", 30 | }, 31 | metadata: { 32 | author: "Kate", 33 | id: "456", 34 | title: "Test", 35 | last_updated: new Date(), 36 | }, 37 | }, 38 | ]; 39 | 40 | expect(environment.storage.fetchPreviews).toBeCalled(); 41 | 42 | act(() => { 43 | environment.storage.emit({ 44 | type: "STORAGE:FETCH_PREVIEWS_SUCCESS", 45 | excalidraws: mockedPreviews, 46 | }); 47 | }); 48 | 49 | expect(state).toEqual({ 50 | state: "PREVIEWS_LOADED", 51 | excalidraws: mockedPreviews, 52 | }); 53 | }); 54 | test("Should go to PREVIEWS_ERROR when mounting and unsuccessfully downloading previews", () => { 55 | const environment = createTestEnvironment(); 56 | const [state] = renderReducer( 57 | () => [useDashboard(), () => {}], 58 | (UseDashboard) => ( 59 | 60 | 61 | 62 | ) 63 | ); 64 | 65 | expect(environment.storage.fetchPreviews).toBeCalled(); 66 | 67 | act(() => { 68 | environment.storage.emit({ 69 | type: "STORAGE:FETCH_PREVIEWS_ERROR", 70 | error: "Unable to download", 71 | }); 72 | }); 73 | 74 | expect(state).toEqual({ 75 | state: "PREVIEWS_ERROR", 76 | error: "Unable to download", 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/pages/dashboard/useDashboard.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, useEffect, useReducer } from "react"; 2 | import { transition, useDevtools, useStateEffect } from "react-states"; 3 | import { useEnvironment } from "../../environment-interface"; 4 | import { 5 | ExcalidrawPreviews, 6 | StorageEvent, 7 | } from "../../environment-interface/storage"; 8 | 9 | export type DashboardState = 10 | | { 11 | state: "LOADING_PREVIEWS"; 12 | } 13 | | { 14 | state: "PREVIEWS_LOADED"; 15 | excalidraws: ExcalidrawPreviews; 16 | } 17 | | { 18 | state: "PREVIEWS_ERROR"; 19 | error: string; 20 | }; 21 | 22 | const reducer = (state: DashboardState, action: StorageEvent) => 23 | transition(state, action, { 24 | LOADING_PREVIEWS: { 25 | "STORAGE:FETCH_PREVIEWS_SUCCESS": ( 26 | _, 27 | { excalidraws } 28 | ): DashboardState => ({ 29 | state: "PREVIEWS_LOADED", 30 | excalidraws, 31 | }), 32 | "STORAGE:FETCH_PREVIEWS_ERROR": (_, { error }): DashboardState => ({ 33 | state: "PREVIEWS_ERROR", 34 | error, 35 | }), 36 | }, 37 | PREVIEWS_LOADED: {}, 38 | PREVIEWS_ERROR: {}, 39 | }); 40 | 41 | export const useDashboard = ( 42 | initialState: DashboardState = { 43 | state: "LOADING_PREVIEWS", 44 | } 45 | ): DashboardState => { 46 | const { storage } = useEnvironment(); 47 | const dashboardReducer = useReducer(reducer, initialState); 48 | 49 | useDevtools("dashboard", dashboardReducer); 50 | 51 | const [state, dispatch] = dashboardReducer; 52 | 53 | useEffect(() => storage.subscribe(dispatch), []); 54 | 55 | useStateEffect(state, "LOADING_PREVIEWS", () => storage.fetchPreviews()); 56 | 57 | return state; 58 | }; 59 | -------------------------------------------------------------------------------- /src/pages/dashboard/useNavigation.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { act } from "@testing-library/react"; 3 | 4 | import { createStorage } from "../../environments/storage/test"; 5 | import { createAuthentication } from "../../environments/authentication/test"; 6 | import { createMemoryHistory } from "history"; 7 | import { Router } from "react-router-dom"; 8 | import { renderReducer } from "react-states/test"; 9 | import { createTestEnvironment } from "../../environments/test"; 10 | import { NavigationState, useNavigation } from "./useNavigation"; 11 | import { EnvironmentProvider } from "../../environment-interface"; 12 | import { AuthenticatedAuthProvider } from "../useAuth"; 13 | 14 | describe("Dashboard", () => { 15 | test("Should go to EXCALIDRAW_CREATED when creating a new Excalidraw successfully", () => { 16 | const environment = createTestEnvironment(); 17 | const history = createMemoryHistory(); 18 | const navigate = jest.fn(); 19 | const [state, dispatch] = renderReducer( 20 | () => 21 | useNavigation({ 22 | navigate, 23 | }), 24 | (UseNavigation) => ( 25 | 26 | 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | ); 44 | 45 | act(() => { 46 | dispatch({ type: "CREATE_EXCALIDRAW" }); 47 | }); 48 | 49 | expect(state).toEqual({ 50 | state: "CREATING_EXCALIDRAW", 51 | }); 52 | 53 | expect(environment.storage.createExcalidraw).toBeCalled(); 54 | 55 | act(() => { 56 | environment.storage.emit({ 57 | type: "STORAGE:CREATE_EXCALIDRAW_SUCCESS", 58 | id: "456", 59 | }); 60 | }); 61 | 62 | expect(state).toEqual({ 63 | state: "EXCALIDRAW_CREATED", 64 | id: "456", 65 | }); 66 | expect(history.entries[1].pathname).toBe("/123/456"); 67 | }); 68 | test("Should go to CREATE_EXCALIDRAW_ERROR when creating a new Excalidraw unsuccessfully", () => { 69 | const environment = createTestEnvironment(); 70 | 71 | const navigate = jest.fn(); 72 | const [state, dispatch] = renderReducer( 73 | () => 74 | useNavigation({ 75 | navigate: navigate, 76 | }), 77 | (UseNavigation) => ( 78 | 79 | 90 | 91 | 92 | 93 | ) 94 | ); 95 | 96 | act(() => { 97 | dispatch({ type: "CREATE_EXCALIDRAW" }); 98 | }); 99 | 100 | expect(state).toEqual({ 101 | state: "CREATING_EXCALIDRAW", 102 | }); 103 | 104 | expect(environment.storage.createExcalidraw).toBeCalled(); 105 | 106 | act(() => { 107 | environment.storage.emit({ 108 | type: "STORAGE:CREATE_EXCALIDRAW_ERROR", 109 | error: "Could not create Excalidraw", 110 | }); 111 | }); 112 | 113 | expect(state).toEqual({ 114 | state: "CREATE_EXCALIDRAW_ERROR", 115 | error: "Could not create Excalidraw", 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/pages/dashboard/useNavigation.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, Dispatch, useEffect, useReducer } from "react"; 2 | import { StorageEvent } from "../../environment-interface/storage"; 3 | import { useEnvironment } from "../../environment-interface"; 4 | import { 5 | PickState, 6 | transition, 7 | useDevtools, 8 | useStateEffect, 9 | } from "react-states"; 10 | 11 | import { useAuthenticatedAuth } from "../useAuth"; 12 | 13 | export type NavigationState = 14 | | { 15 | state: "ALL_EXCALIDRAWS"; 16 | } 17 | | { 18 | state: "USER_EXCALIDRAWS"; 19 | } 20 | | { 21 | state: "CREATING_EXCALIDRAW"; 22 | } 23 | | { 24 | state: "EXCALIDRAW_CREATED"; 25 | id: string; 26 | } 27 | | { 28 | state: "CREATE_EXCALIDRAW_ERROR"; 29 | error: string; 30 | }; 31 | 32 | export type NavigationAction = 33 | | { 34 | type: "CREATE_EXCALIDRAW"; 35 | } 36 | | { 37 | type: "SHOW_ALL_EXCALIDRAWS"; 38 | } 39 | | { 40 | type: "SHOW_MY_EXCALIDRAWSS"; 41 | }; 42 | 43 | const reducer = ( 44 | state: NavigationState, 45 | action: NavigationAction | StorageEvent 46 | ) => 47 | transition(state, action, { 48 | ALL_EXCALIDRAWS: { 49 | CREATE_EXCALIDRAW: (state): NavigationState => ({ 50 | ...state, 51 | state: "CREATING_EXCALIDRAW", 52 | }), 53 | SHOW_MY_EXCALIDRAWSS: (): NavigationState => ({ 54 | state: "USER_EXCALIDRAWS", 55 | }), 56 | }, 57 | USER_EXCALIDRAWS: { 58 | CREATE_EXCALIDRAW: (state): NavigationState => ({ 59 | ...state, 60 | state: "CREATING_EXCALIDRAW", 61 | }), 62 | SHOW_ALL_EXCALIDRAWS: (): NavigationState => ({ 63 | state: "ALL_EXCALIDRAWS", 64 | }), 65 | }, 66 | CREATING_EXCALIDRAW: { 67 | "STORAGE:CREATE_EXCALIDRAW_SUCCESS": ( 68 | state, 69 | { id } 70 | ): NavigationState => ({ 71 | ...state, 72 | state: "EXCALIDRAW_CREATED", 73 | id, 74 | }), 75 | "STORAGE:CREATE_EXCALIDRAW_ERROR": ( 76 | state, 77 | { error } 78 | ): NavigationState => ({ 79 | ...state, 80 | state: "CREATE_EXCALIDRAW_ERROR", 81 | error, 82 | }), 83 | }, 84 | CREATE_EXCALIDRAW_ERROR: { 85 | CREATE_EXCALIDRAW: (state): NavigationState => ({ 86 | ...state, 87 | state: "CREATING_EXCALIDRAW", 88 | }), 89 | }, 90 | EXCALIDRAW_CREATED: {}, 91 | }); 92 | 93 | export const useNavigation = ({ 94 | initialState = { 95 | state: "ALL_EXCALIDRAWS", 96 | }, 97 | navigate, 98 | }: { 99 | navigate: (url: string) => void; 100 | initialState?: NavigationState; 101 | }): [NavigationState, Dispatch] => { 102 | const auth = useAuthenticatedAuth(); 103 | const { storage } = useEnvironment(); 104 | const navigationReducer = useReducer(reducer, initialState); 105 | 106 | useDevtools("navigation", navigationReducer); 107 | 108 | const [state, dispatch] = navigationReducer; 109 | 110 | useEffect(() => storage.subscribe(dispatch), []); 111 | 112 | useStateEffect(state, "CREATING_EXCALIDRAW", () => 113 | storage.createExcalidraw(auth.user.uid) 114 | ); 115 | 116 | useStateEffect(state, "EXCALIDRAW_CREATED", ({ id }) => { 117 | navigate(`/${auth.user.uid}/${id}`); 118 | }); 119 | 120 | useStateEffect(state, "ALL_EXCALIDRAWS", () => { 121 | navigate("/"); 122 | }); 123 | 124 | useStateEffect(state, "USER_EXCALIDRAWS", () => { 125 | navigate(`/${auth.user.uid}`); 126 | }); 127 | 128 | return navigationReducer; 129 | }; 130 | -------------------------------------------------------------------------------- /src/pages/dashboard/useUserDashboard.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, Dispatch, useEffect, useReducer } from "react"; 2 | import { transition, useDevtools, useStateEffect } from "react-states"; 3 | import { useEnvironment } from "../../environment-interface"; 4 | import { 5 | ExcalidrawPreviews, 6 | StorageEvent, 7 | } from "../../environment-interface/storage"; 8 | 9 | export type UserDashboardState = 10 | | { 11 | state: "LOADING_PREVIEWS"; 12 | } 13 | | { 14 | state: "PREVIEWS_LOADED"; 15 | excalidraws: ExcalidrawPreviews; 16 | } 17 | | { 18 | state: "PREVIEWS_ERROR"; 19 | error: string; 20 | }; 21 | 22 | export type UserDashboardAction = { 23 | type: "CREATE_EXCALIDRAW"; 24 | }; 25 | 26 | const reducer = ( 27 | state: UserDashboardState, 28 | action: UserDashboardAction | StorageEvent 29 | ) => 30 | transition(state, action, { 31 | LOADING_PREVIEWS: { 32 | "STORAGE:FETCH_USER_PREVIEWS_SUCCESS": ( 33 | _, 34 | { excalidraws } 35 | ): UserDashboardState => ({ 36 | state: "PREVIEWS_LOADED", 37 | excalidraws, 38 | }), 39 | "STORAGE:FETCH_USER_PREVIEWS_ERROR": ( 40 | _, 41 | { error } 42 | ): UserDashboardState => ({ 43 | state: "PREVIEWS_ERROR", 44 | error, 45 | }), 46 | }, 47 | PREVIEWS_LOADED: {}, 48 | PREVIEWS_ERROR: {}, 49 | }); 50 | 51 | export const useUserDashboard = ({ 52 | uid, 53 | initialState = { 54 | state: "LOADING_PREVIEWS", 55 | }, 56 | }: { 57 | uid: string; 58 | initialState?: UserDashboardState; 59 | }): [UserDashboardState, Dispatch] => { 60 | const { storage } = useEnvironment(); 61 | const userDashboardReducer = useReducer(reducer, initialState); 62 | 63 | useDevtools("dashboard", userDashboardReducer); 64 | 65 | const [state, dispatch] = userDashboardReducer; 66 | 67 | useEffect(() => storage.subscribe(dispatch), []); 68 | 69 | useStateEffect(state, "LOADING_PREVIEWS", () => 70 | storage.fetchUserPreviews(uid) 71 | ); 72 | 73 | return userDashboardReducer; 74 | }; 75 | -------------------------------------------------------------------------------- /src/pages/excalidraw/Excalidraw.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, useMemo, useState } from "react"; 2 | import debounce from "lodash.debounce"; 3 | import { getSceneVersion } from "@excalidraw/excalidraw"; 4 | import { PickState, match } from "react-states"; 5 | import { ExcalidrawCanvas } from "./ExcalidrawCanvas"; 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 7 | import { 8 | faCheck, 9 | faClipboard, 10 | faVideo, 11 | } from "@fortawesome/free-solid-svg-icons"; 12 | 13 | import { useExcalidraw } from "./useExcalidraw"; 14 | import { ExcalidrawAction, ExcalidrawState } from "./useExcalidraw/types"; 15 | import { useRecording } from "./useRecording"; 16 | import { useAuthenticatedAuth } from "../useAuth"; 17 | 18 | type EditExcalidrawState = PickState< 19 | ExcalidrawState, 20 | "LOADED" | "EDIT" | "SYNCING" | "DIRTY" | "SYNCING_DIRTY" 21 | >; 22 | 23 | type EditExcalidrawReducer = [EditExcalidrawState, Dispatch]; 24 | 25 | const EditExcalidraw = ({ 26 | excalidrawReducer: [state, dispatch], 27 | }: { 28 | excalidrawReducer: EditExcalidrawReducer; 29 | }) => { 30 | const auth = useAuthenticatedAuth(); 31 | 32 | const [title, setTitle] = useState(state.metadata.title || ""); 33 | 34 | const onChange = useMemo( 35 | () => 36 | debounce((elements, appState) => { 37 | dispatch({ 38 | type: "EXCALIDRAW_CHANGE", 39 | data: { 40 | elements, 41 | appState, 42 | version: getSceneVersion(elements), 43 | }, 44 | }); 45 | }, 100), 46 | [], 47 | ); 48 | 49 | const copyToClipboard = () => { 50 | dispatch({ type: "COPY_TO_CLIPBOARD" }); 51 | }; 52 | 53 | const variants = { 54 | default: () => ({ 55 | className: 56 | "text-gray-500 bg-gray-50 hover:bg-gray-100 focus:ring-gray-50", 57 | content: , 58 | onClick: copyToClipboard, 59 | }), 60 | active: () => ({ 61 | className: 62 | "text-green-500 bg-green-50 hover:bg-green-100 focus:ring-green-50", 63 | content: , 64 | onClick: undefined, 65 | }), 66 | loading: () => ({ 67 | className: 68 | "opacity-50 text-gray-500 bg-gray-50 hover:bg-gray-100 focus:ring-gray-50", 69 | content:
, 70 | onClick: undefined, 71 | }), 72 | }; 73 | 74 | const variant = match(state, { 75 | DIRTY: variants.loading, 76 | LOADED: variants.loading, 77 | SYNCING: variants.loading, 78 | SYNCING_DIRTY: variants.loading, 79 | EDIT: () => 80 | match(state.clipboard, { 81 | COPIED: variants.active, 82 | NOT_COPIED: variants.default, 83 | }), 84 | }); 85 | 86 | return ( 87 |
88 | { 93 | dispatch({ type: "INITIALIZE_CANVAS_SUCCESS" }); 94 | }} 95 | /> 96 |
97 |
98 | { 103 | setTitle(event.target.value); 104 | }} 105 | onKeyDown={(event) => { 106 | if (event.key === "Enter") { 107 | dispatch({ 108 | type: "SAVE_TITLE", 109 | title, 110 | }); 111 | } 112 | }} 113 | className="focus:ring-blue-500 focus:border-blue-500 block w-full pl-3 sm:text-sm border-gray-300 rounded-md h-10" 114 | placeholder="Title..." 115 | /> 116 | {!title || title === state.metadata.title ? null : ( 117 |
{ 120 | dispatch({ 121 | type: "SAVE_TITLE", 122 | title, 123 | }); 124 | }} 125 | > 126 | 127 |
128 | )} 129 |
130 | 136 |
137 |
138 | ); 139 | }; 140 | 141 | export const Excalidraw: React.FC<{ id: string; userId: string }> = ({ 142 | id, 143 | userId, 144 | }) => { 145 | const [state, dispatch] = useExcalidraw({ 146 | id, 147 | userId, 148 | }); 149 | 150 | const renderExcalidraw = (state: EditExcalidrawState) => ( 151 | 152 | ); 153 | 154 | return match(state, { 155 | LOADING: () => ( 156 |
157 |
158 |
159 | ), 160 | ERROR: ({ error }) => ( 161 |
162 |

OMG, error, {error}

163 |
164 | ), 165 | LOADED: renderExcalidraw, 166 | EDIT: renderExcalidraw, 167 | SYNCING: renderExcalidraw, 168 | DIRTY: renderExcalidraw, 169 | SYNCING_DIRTY: renderExcalidraw, 170 | }); 171 | }; 172 | -------------------------------------------------------------------------------- /src/pages/excalidraw/ExcalidrawCanvas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { getSceneVersion } from "@excalidraw/excalidraw"; 3 | 4 | import { getChangedData } from "../../utils"; 5 | import { 6 | ExcalidrawData, 7 | ExcalidrawElement, 8 | } from "../../environment-interface/storage"; 9 | import { useEnvironment } from "../../environment-interface"; 10 | 11 | export type ResolvablePromise = Promise & { 12 | resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; 13 | reject: (error: Error) => void; 14 | }; 15 | 16 | function resolvablePromise() { 17 | let resolve!: any; 18 | let reject!: any; 19 | const promise = new Promise((_resolve, _reject) => { 20 | resolve = _resolve; 21 | reject = _reject; 22 | }); 23 | (promise as any).resolve = resolve; 24 | (promise as any).reject = reject; 25 | return promise as ResolvablePromise; 26 | } 27 | 28 | export const ExcalidrawCanvas = React.memo( 29 | ({ 30 | data, 31 | onChange, 32 | onInitialized, 33 | readOnly, 34 | }: { 35 | data: ExcalidrawData; 36 | onChange: (elements: readonly ExcalidrawElement[], appState: any) => void; 37 | onInitialized: () => void; 38 | readOnly: boolean; 39 | }) => { 40 | const { storage } = useEnvironment(); 41 | const excalidrawRef = useRef({ 42 | readyPromise: resolvablePromise(), 43 | }); 44 | const [Comp, setComp] = useState | null>(null); 45 | 46 | useEffect(() => { 47 | import("@excalidraw/excalidraw").then((comp) => { 48 | setComp(comp.Excalidraw); 49 | }); 50 | }, [Comp]); 51 | 52 | const excalidrawWrapperRef = useRef(null); 53 | 54 | useEffect(() => { 55 | excalidrawRef.current.readyPromise.then(onInitialized); 56 | }, []); 57 | 58 | useEffect( 59 | () => 60 | storage.subscribe((event) => { 61 | if (event.type === "STORAGE:EXCALIDRAW_DATA_UPDATE") { 62 | excalidrawRef.current.readyPromise.then( 63 | ({ getSceneElementsIncludingDeleted, getAppState }: any) => { 64 | const currentElements = getSceneElementsIncludingDeleted(); 65 | const changedData = getChangedData(event.data, { 66 | appState: getAppState(), 67 | elements: currentElements, 68 | version: getSceneVersion(currentElements), 69 | }); 70 | 71 | if (changedData) { 72 | excalidrawRef.current.updateScene(changedData); 73 | } 74 | }, 75 | ); 76 | } 77 | }), 78 | [], 79 | ); 80 | 81 | return ( 82 |
83 | {Comp ? ( 84 | 90 | ) : null} 91 |
92 | ); 93 | }, 94 | ); 95 | -------------------------------------------------------------------------------- /src/pages/excalidraw/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Excalidraw } from "./Excalidraw"; 4 | 5 | import { useParams } from "react-router-dom"; 6 | 7 | export const ExcalidrawPage = () => { 8 | const { id, userId } = useParams<{ id: string; userId: string }>(); 9 | 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /src/pages/excalidraw/useExcalidraw/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { act, waitFor } from "@testing-library/react"; 3 | 4 | import { createTestEnvironment } from "../../../environments/test"; 5 | import { useExcalidraw } from "."; 6 | import { EnvironmentProvider } from "../../../environment-interface"; 7 | import { renderReducer } from "react-states/test"; 8 | import { 9 | ExcalidrawData, 10 | ExcalidrawMetadata, 11 | } from "../../../environment-interface/storage"; 12 | import { ExcalidrawState } from "./types"; 13 | 14 | describe("Excalidraw", () => { 15 | test("Should go to EDIT when loaded excalidraw and canvas is ready", () => { 16 | const userId = "123"; 17 | const id = "456"; 18 | const environment = createTestEnvironment(); 19 | 20 | const [state, dispatch] = renderReducer( 21 | () => 22 | useExcalidraw({ 23 | id, 24 | userId, 25 | }), 26 | (UseExcalidraw) => ( 27 | 28 | 29 | 30 | ) 31 | ); 32 | 33 | const data: ExcalidrawData = { 34 | appState: { viewBackgroundColor: "#FFF" } as ExcalidrawData["appState"], 35 | elements: [], 36 | version: 0, 37 | }; 38 | const image = {} as Blob; 39 | const metadata: ExcalidrawMetadata = { 40 | author: "123", 41 | id: "456", 42 | last_updated: new Date(), 43 | title: "Test", 44 | }; 45 | 46 | act(() => { 47 | environment.storage.emit({ 48 | type: "STORAGE:FETCH_EXCALIDRAW_SUCCESS", 49 | data, 50 | image, 51 | metadata, 52 | }); 53 | }); 54 | 55 | expect(state).toEqual({ 56 | state: "LOADED", 57 | data, 58 | metadata, 59 | image, 60 | clipboard: { 61 | state: "NOT_COPIED", 62 | }, 63 | }); 64 | 65 | act(() => { 66 | dispatch({ 67 | type: "INITIALIZE_CANVAS_SUCCESS", 68 | }); 69 | }); 70 | 71 | expect(state).toEqual({ 72 | state: "EDIT", 73 | data, 74 | metadata, 75 | image, 76 | clipboard: { 77 | state: "NOT_COPIED", 78 | }, 79 | }); 80 | }); 81 | test("Should go to SYNCING when excalidraw is changed", async () => { 82 | const userId = "123"; 83 | const id = "456"; 84 | const environment = createTestEnvironment(); 85 | 86 | const image = {} as Blob; 87 | const metadata = { 88 | author: "123", 89 | id: "456", 90 | last_updated: new Date(), 91 | title: "Test", 92 | }; 93 | const [excalidraw, send] = renderReducer( 94 | () => 95 | useExcalidraw({ 96 | id, 97 | userId, 98 | initialState: { 99 | state: "EDIT", 100 | data: { 101 | appState: { 102 | viewBackgroundColor: "#FFF", 103 | } as ExcalidrawData["appState"], 104 | elements: [], 105 | version: 0, 106 | }, 107 | image, 108 | metadata, 109 | clipboard: { 110 | state: "NOT_COPIED", 111 | }, 112 | }, 113 | }), 114 | (UseExcalidraw) => ( 115 | 116 | 117 | 118 | ) 119 | ); 120 | 121 | const newData: ExcalidrawData = { 122 | appState: { viewBackgroundColor: "#FFF" } as ExcalidrawData["appState"], 123 | elements: [{ id: "4", version: 0 } as ExcalidrawData["elements"][number]], 124 | version: 1, 125 | }; 126 | 127 | act(() => { 128 | send({ type: "EXCALIDRAW_CHANGE", data: newData }); 129 | }); 130 | 131 | expect(excalidraw).toEqual({ 132 | state: "DIRTY", 133 | data: newData, 134 | metadata, 135 | image, 136 | clipboard: { 137 | state: "NOT_COPIED", 138 | }, 139 | }); 140 | 141 | await waitFor(() => 142 | expect(excalidraw).toEqual({ 143 | state: "SYNCING", 144 | data: newData, 145 | metadata, 146 | image, 147 | clipboard: { 148 | state: "NOT_COPIED", 149 | }, 150 | }) 151 | ); 152 | }); 153 | test("Should go to DIRTY when excalidraw is changed during SYNCING that is successful", async () => { 154 | const userId = "123"; 155 | const id = "456"; 156 | const environment = createTestEnvironment(); 157 | 158 | const image = {} as Blob; 159 | const metadata = { 160 | author: "123", 161 | id: "456", 162 | last_updated: new Date(), 163 | title: "Test", 164 | }; 165 | const [excalidraw, send] = renderReducer( 166 | () => 167 | useExcalidraw({ 168 | id, 169 | userId, 170 | initialState: { 171 | state: "SYNCING", 172 | data: { 173 | appState: { 174 | viewBackgroundColor: "#FFF", 175 | } as ExcalidrawData["appState"], 176 | elements: [], 177 | version: 0, 178 | }, 179 | image, 180 | metadata, 181 | clipboard: { 182 | state: "NOT_COPIED", 183 | }, 184 | }, 185 | }), 186 | (UseExcalidraw) => ( 187 | 188 | 189 | 190 | ) 191 | ); 192 | 193 | const newData: ExcalidrawData = { 194 | appState: { viewBackgroundColor: "#FFF" } as ExcalidrawData["appState"], 195 | elements: [{ id: "4", version: 0 } as ExcalidrawData["elements"][number]], 196 | version: 1, 197 | }; 198 | 199 | act(() => { 200 | send({ type: "EXCALIDRAW_CHANGE", data: newData }); 201 | }); 202 | 203 | expect(excalidraw).toEqual({ 204 | state: "SYNCING_DIRTY", 205 | data: newData, 206 | metadata, 207 | image, 208 | clipboard: { 209 | state: "NOT_COPIED", 210 | }, 211 | }); 212 | 213 | const newImage = {} as Blob; 214 | const newMetadata = { 215 | author: "123", 216 | id: "456", 217 | last_updated: new Date(), 218 | title: "Test", 219 | }; 220 | 221 | act(() => { 222 | environment.storage.emit({ 223 | type: "STORAGE:SAVE_EXCALIDRAW_SUCCESS", 224 | image: newImage, 225 | metadata: newMetadata, 226 | }); 227 | }); 228 | 229 | expect(excalidraw).toEqual({ 230 | state: "DIRTY", 231 | data: newData, 232 | metadata: newMetadata, 233 | image: newImage, 234 | clipboard: { 235 | state: "NOT_COPIED", 236 | }, 237 | }); 238 | }); 239 | }); 240 | -------------------------------------------------------------------------------- /src/pages/excalidraw/useExcalidraw/index.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, useEffect, useReducer } from "react"; 2 | 3 | import { reducer } from "./reducer"; 4 | import { ExcalidrawAction, ExcalidrawState } from "./types"; 5 | 6 | import { useEnvironment } from "../../../environment-interface"; 7 | import { useDevtools, useTransitionEffect } from "react-states"; 8 | 9 | export const useExcalidraw = ({ 10 | id, 11 | userId, 12 | 13 | initialState = { 14 | state: "LOADING", 15 | }, 16 | }: { 17 | id: string; 18 | userId: string; 19 | 20 | initialState?: ExcalidrawState; 21 | }): [ExcalidrawState, Dispatch] => { 22 | const { storage, copyImageToClipboard } = useEnvironment(); 23 | const excalidrawReducer = useReducer(reducer, initialState); 24 | 25 | useDevtools("excalidraw", excalidrawReducer); 26 | 27 | const [state, dispatch] = excalidrawReducer; 28 | 29 | useEffect(() => storage.subscribe(dispatch), []); 30 | 31 | useTransitionEffect(state, "EDIT", "COPY_TO_CLIPBOARD", ({ image }) => { 32 | copyImageToClipboard(image); 33 | }); 34 | 35 | useTransitionEffect(state, "EDIT", "SAVE_TITLE", (_, { title }) => { 36 | storage.saveTitle(userId, id, title); 37 | }); 38 | 39 | useTransitionEffect(state, "LOADING", () => 40 | storage.fetchExcalidraw(userId, id) 41 | ); 42 | 43 | useTransitionEffect(state, "SYNCING", ({ data }) => { 44 | storage.saveExcalidraw(userId, id, data); 45 | }); 46 | 47 | useTransitionEffect(state, "DIRTY", () => { 48 | const id = setTimeout(() => { 49 | dispatch({ 50 | type: "SYNC", 51 | }); 52 | }, 500); 53 | 54 | return () => { 55 | clearTimeout(id); 56 | }; 57 | }); 58 | 59 | return excalidrawReducer; 60 | }; 61 | -------------------------------------------------------------------------------- /src/pages/excalidraw/useExcalidraw/reducer.ts: -------------------------------------------------------------------------------- 1 | import { ExcalidrawState, ExcalidrawAction, PrivateAction } from "./types"; 2 | import { StorageEvent } from "../../../environment-interface/storage"; 3 | 4 | import { transition } from "react-states"; 5 | import { hasChangedExcalidraw } from "../../../utils"; 6 | 7 | export const reducer = ( 8 | state: ExcalidrawState, 9 | action: ExcalidrawAction | PrivateAction | StorageEvent 10 | ) => 11 | transition(state, action, { 12 | LOADING: { 13 | "STORAGE:FETCH_EXCALIDRAW_SUCCESS": ( 14 | _, 15 | { data, metadata, image } 16 | ): ExcalidrawState => ({ 17 | state: "LOADED", 18 | data, 19 | metadata, 20 | image, 21 | clipboard: { 22 | state: "NOT_COPIED", 23 | }, 24 | }), 25 | "STORAGE:FETCH_EXCALIDRAW_ERROR": (_, { error }): ExcalidrawState => ({ 26 | state: "ERROR", 27 | error, 28 | }), 29 | }, 30 | LOADED: { 31 | INITIALIZE_CANVAS_SUCCESS: (state): ExcalidrawState => ({ 32 | ...state, 33 | state: "EDIT", 34 | }), 35 | }, 36 | EDIT: { 37 | EXCALIDRAW_CHANGE: (state, { data }): ExcalidrawState => 38 | hasChangedExcalidraw(state.data, data) 39 | ? { 40 | ...state, 41 | clipboard: { 42 | state: "NOT_COPIED", 43 | }, 44 | state: "DIRTY", 45 | data, 46 | } 47 | : state, 48 | COPY_TO_CLIPBOARD: (state): ExcalidrawState => ({ 49 | ...state, 50 | clipboard: { 51 | state: "COPIED", 52 | }, 53 | }), 54 | "STORAGE:SAVE_TITLE_SUCCESS": (state, { title }): ExcalidrawState => ({ 55 | ...state, 56 | metadata: { 57 | ...state.metadata, 58 | title, 59 | }, 60 | }), 61 | SAVE_TITLE: (state, { title }): ExcalidrawState => ({ 62 | ...state, 63 | }), 64 | }, 65 | DIRTY: { 66 | SYNC: (state): ExcalidrawState => ({ 67 | ...state, 68 | state: "SYNCING", 69 | }), 70 | EXCALIDRAW_CHANGE: (state, { data }): ExcalidrawState => 71 | hasChangedExcalidraw(state.data, data) 72 | ? { 73 | ...state, 74 | state: "DIRTY", 75 | data, 76 | } 77 | : state, 78 | }, 79 | SYNCING: { 80 | EXCALIDRAW_CHANGE: (state, { data }): ExcalidrawState => 81 | hasChangedExcalidraw(state.data, data) 82 | ? { 83 | ...state, 84 | state: "SYNCING_DIRTY", 85 | data, 86 | } 87 | : state, 88 | "STORAGE:SAVE_EXCALIDRAW_SUCCESS": ( 89 | state, 90 | { image, metadata } 91 | ): ExcalidrawState => ({ 92 | ...state, 93 | state: "EDIT", 94 | metadata, 95 | image, 96 | }), 97 | "STORAGE:SAVE_EXCALIDRAW_ERROR": (_, { error }): ExcalidrawState => ({ 98 | state: "ERROR", 99 | error, 100 | }), 101 | "STORAGE:SAVE_EXCALIDRAW_OLD_VERSION": (state): ExcalidrawState => ({ 102 | ...state, 103 | state: "EDIT", 104 | }), 105 | }, 106 | SYNCING_DIRTY: { 107 | EXCALIDRAW_CHANGE: (state, { data }): ExcalidrawState => 108 | hasChangedExcalidraw(state.data, data) 109 | ? { 110 | ...state, 111 | state: "SYNCING_DIRTY", 112 | data, 113 | } 114 | : state, 115 | "STORAGE:SAVE_EXCALIDRAW_SUCCESS": ( 116 | state, 117 | { metadata } 118 | ): ExcalidrawState => ({ 119 | ...state, 120 | metadata, 121 | state: "DIRTY", 122 | }), 123 | "STORAGE:SAVE_EXCALIDRAW_ERROR": (state): ExcalidrawState => ({ 124 | ...state, 125 | state: "DIRTY", 126 | }), 127 | "STORAGE:SAVE_EXCALIDRAW_OLD_VERSION": (state): ExcalidrawState => ({ 128 | ...state, 129 | state: "DIRTY", 130 | }), 131 | }, 132 | ERROR: {}, 133 | }); 134 | -------------------------------------------------------------------------------- /src/pages/excalidraw/useExcalidraw/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExcalidrawData, 3 | ExcalidrawMetadata, 4 | } from "../../../environment-interface/storage"; 5 | 6 | export type ClipboardState = 7 | | { 8 | state: "COPIED"; 9 | } 10 | | { 11 | state: "NOT_COPIED"; 12 | }; 13 | 14 | export type BaseState = { 15 | data: ExcalidrawData; 16 | remoteData?: ExcalidrawData; 17 | metadata: ExcalidrawMetadata; 18 | image: Blob; 19 | clipboard: ClipboardState; 20 | }; 21 | 22 | export type ExcalidrawState = 23 | | { 24 | state: "LOADING"; 25 | } 26 | | { 27 | state: "ERROR"; 28 | error: string; 29 | } 30 | | (BaseState & 31 | ( 32 | | { 33 | state: "LOADED"; 34 | } 35 | | { 36 | state: "EDIT"; 37 | } 38 | | { 39 | state: "DIRTY"; 40 | } 41 | | { 42 | state: "SYNCING"; 43 | } 44 | | { 45 | state: "SYNCING_DIRTY"; 46 | } 47 | )); 48 | 49 | export type ExcalidrawAction = 50 | | { 51 | type: "INITIALIZE_CANVAS_SUCCESS"; 52 | } 53 | | { 54 | type: "COPY_TO_CLIPBOARD"; 55 | } 56 | | { 57 | type: "EXCALIDRAW_CHANGE"; 58 | data: ExcalidrawData; 59 | } 60 | | { 61 | type: "SAVE_TITLE"; 62 | title: string; 63 | }; 64 | 65 | export type PrivateAction = { 66 | type: "SYNC"; 67 | }; 68 | -------------------------------------------------------------------------------- /src/pages/excalidraw/useRecording.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, useEffect, useReducer } from "react"; 2 | import { transition, useDevtools, useTransitionEffect } from "react-states"; 3 | import { useEnvironment } from "../../environment-interface"; 4 | 5 | import { LoomEvent, LoomVideo } from "../../environment-interface/loom"; 6 | 7 | export type RecordingState = 8 | | { 9 | state: "DISABLED"; 10 | } 11 | | { 12 | state: "NOT_CONFIGURED"; 13 | apiKey: string; 14 | buttonId: "loom-record"; 15 | } 16 | | { 17 | state: "READY"; 18 | } 19 | | { 20 | state: "RECORDING"; 21 | }; 22 | 23 | type RecordingAction = { 24 | type: "RECORD"; 25 | }; 26 | 27 | const reducer = (state: RecordingState, action: RecordingAction | LoomEvent) => 28 | transition(state, action, { 29 | DISABLED: {}, 30 | NOT_CONFIGURED: { 31 | "LOOM:CONFIGURED": (state): RecordingState => ({ 32 | ...state, 33 | state: "READY", 34 | }), 35 | }, 36 | READY: { 37 | "LOOM:INSERT": (state): RecordingState => ({ 38 | ...state, 39 | }), 40 | "LOOM:START": (): RecordingState => ({ 41 | state: "RECORDING", 42 | }), 43 | }, 44 | RECORDING: { 45 | "LOOM:CANCEL": (): RecordingState => ({ 46 | state: "READY", 47 | }), 48 | "LOOM:COMPLETE": (): RecordingState => ({ 49 | state: "READY", 50 | }), 51 | }, 52 | }); 53 | 54 | export const useRecording = ({ 55 | apiKey, 56 | initialState = apiKey 57 | ? { 58 | state: "NOT_CONFIGURED", 59 | apiKey, 60 | buttonId: "loom-record", 61 | } 62 | : { 63 | state: "DISABLED", 64 | }, 65 | }: { 66 | apiKey: string | null; 67 | initialState?: RecordingState; 68 | }): [RecordingState, Dispatch] => { 69 | const { loom } = useEnvironment(); 70 | const recording = useReducer(reducer, initialState); 71 | 72 | useDevtools("recording", recording); 73 | 74 | const [state, dispatch] = recording; 75 | 76 | useEffect(() => loom.subscribe(dispatch), []); 77 | 78 | useTransitionEffect(state, "NOT_CONFIGURED", ({ apiKey, buttonId }) => { 79 | loom.configure(apiKey, buttonId); 80 | }); 81 | 82 | useTransitionEffect(state, "READY", "LOOM:INSERT", (_, { video }) => { 83 | loom.openVideo(video); 84 | }); 85 | 86 | return recording; 87 | }; 88 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 3 | import { match } from "react-states"; 4 | import { AuthenticatedAuthProvider, useAuth } from "./useAuth"; 5 | import { DashboardPage } from "./dashboard"; 6 | import { ExcalidrawPage } from "./excalidraw"; 7 | 8 | const router = createBrowserRouter([ 9 | { 10 | path: "/", 11 | element: , 12 | }, 13 | { 14 | path: "/:userId", 15 | element: , 16 | }, 17 | { 18 | path: "/:userId/:id", 19 | element: , 20 | }, 21 | ]); 22 | 23 | export const Pages = () => { 24 | const [auth, dispatch] = useAuth(); 25 | 26 | return ( 27 |
28 | {match(auth, { 29 | UNAUTHENTICATED: () => ( 30 |
31 | 37 |
38 | ), 39 | CHECKING_AUTHENTICATION: () => ( 40 |
41 |
42 |
43 | ), 44 | AUTHENTICATED: (authenticatedAuth) => ( 45 | 46 | 47 | 48 | ), 49 | SIGNING_IN: () => ( 50 |
51 |
52 |
53 | ), 54 | ERROR: ({ error }) => ( 55 |
56 |

Uh oh, something bad happened

57 | {error} 58 |
59 | ), 60 | })} 61 |
62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/pages/useAuth.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { renderReducer } from "react-states/test"; 3 | import { act } from "@testing-library/react"; 4 | import { useAuth, AuthState } from "./useAuth"; 5 | import { createTestEnvironment } from "../environments/test"; 6 | import { EnvironmentProvider } from "../environment-interface"; 7 | 8 | describe("Auth", () => { 9 | test("Should go to AUTHENTICATED when mounted and is logged in", () => { 10 | const environment = createTestEnvironment(); 11 | const [state] = renderReducer( 12 | () => useAuth(), 13 | (UseAuth) => ( 14 | 15 | 16 | 17 | ) 18 | ); 19 | 20 | act(() => { 21 | environment.authentication.emit({ 22 | type: "AUTHENTICATION:AUTHENTICATED", 23 | user: { 24 | avatarUrl: "", 25 | name: "Karen", 26 | uid: "123", 27 | }, 28 | loomApiKey: "", 29 | }); 30 | }); 31 | 32 | expect(state).toEqual({ 33 | state: "AUTHENTICATED", 34 | user: { 35 | avatarUrl: "", 36 | name: "Karen", 37 | uid: "123", 38 | }, 39 | loomApiKey: "", 40 | }); 41 | }); 42 | test("Should go to UNAUTHENTICATED when mounted and is not logged in", () => { 43 | const environment = createTestEnvironment(); 44 | const [state] = renderReducer( 45 | () => useAuth(), 46 | (UseAuth) => ( 47 | 48 | 49 | 50 | ) 51 | ); 52 | 53 | act(() => { 54 | environment.authentication.emit({ 55 | type: "AUTHENTICATION:UNAUTHENTICATED", 56 | }); 57 | }); 58 | 59 | expect(state).toEqual({ 60 | state: "UNAUTHENTICATED", 61 | }); 62 | }); 63 | test("Should go to AUTHENTICATED when signing in successfully", () => { 64 | const environment = createTestEnvironment(); 65 | 66 | const [state, dispatch] = renderReducer( 67 | () => 68 | useAuth({ 69 | state: "UNAUTHENTICATED", 70 | }), 71 | (UseAuth) => ( 72 | 73 | 74 | 75 | ) 76 | ); 77 | 78 | act(() => { 79 | dispatch({ 80 | type: "SIGN_IN", 81 | }); 82 | }); 83 | 84 | expect(state.state).toEqual({ 85 | state: "SIGNING_IN", 86 | }); 87 | expect(environment.authentication.signIn).toBeCalled(); 88 | 89 | act(() => { 90 | environment.authentication.emit({ 91 | type: "AUTHENTICATION:AUTHENTICATED", 92 | user: { 93 | avatarUrl: "", 94 | name: "Karen", 95 | uid: "123", 96 | }, 97 | loomApiKey: "", 98 | }); 99 | }); 100 | 101 | expect(state).toEqual({ 102 | state: "AUTHENTICATED", 103 | user: { 104 | avatarUrl: "", 105 | name: "Karen", 106 | uid: "123", 107 | }, 108 | loomApiKey: "", 109 | }); 110 | }); 111 | test("Should go to ERROR when signing in unsuccsessfully", () => { 112 | const environment = createTestEnvironment(); 113 | const [state, dispatch] = renderReducer( 114 | () => 115 | useAuth({ 116 | state: "UNAUTHENTICATED", 117 | }), 118 | (UseAuth) => ( 119 | 120 | 121 | 122 | ) 123 | ); 124 | 125 | act(() => { 126 | dispatch({ 127 | type: "SIGN_IN", 128 | }); 129 | }); 130 | 131 | expect(state.state).toBe("SIGNING_IN"); 132 | expect(environment.authentication.signIn).toBeCalled(); 133 | 134 | act(() => { 135 | environment.authentication.emit({ 136 | type: "AUTHENTICATION:SIGN_IN_ERROR", 137 | error: "Something bad happened", 138 | }); 139 | }); 140 | 141 | expect(state).toEqual({ 142 | state: "ERROR", 143 | error: "Something bad happened", 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/pages/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | Dispatch, 4 | useContext, 5 | useEffect, 6 | useReducer, 7 | } from "react"; 8 | import { PickState, transition, useStateEffect } from "react-states"; 9 | 10 | import { useDevtools } from "react-states"; 11 | import { useEnvironment } from "../environment-interface"; 12 | import { 13 | AuthenticationEvent, 14 | User, 15 | } from "../environment-interface/authentication"; 16 | 17 | export type AuthState = 18 | | { 19 | state: "CHECKING_AUTHENTICATION"; 20 | } 21 | | { 22 | state: "UNAUTHENTICATED"; 23 | } 24 | | { 25 | state: "SIGNING_IN"; 26 | } 27 | | { 28 | state: "AUTHENTICATED"; 29 | user: User; 30 | loomApiKey: string | null; 31 | } 32 | | { 33 | state: "ERROR"; 34 | error: string; 35 | }; 36 | 37 | export type AuthAction = { 38 | type: "SIGN_IN"; 39 | }; 40 | 41 | const reducer = (state: AuthState, action: AuthAction | AuthenticationEvent) => 42 | transition(state, action, { 43 | CHECKING_AUTHENTICATION: { 44 | "AUTHENTICATION:AUTHENTICATED": (_, { user, loomApiKey }): AuthState => ({ 45 | state: "AUTHENTICATED", 46 | user, 47 | loomApiKey, 48 | }), 49 | "AUTHENTICATION:UNAUTHENTICATED": (): AuthState => ({ 50 | state: "UNAUTHENTICATED", 51 | }), 52 | }, 53 | UNAUTHENTICATED: { 54 | SIGN_IN: (): AuthState => ({ state: "SIGNING_IN" }), 55 | }, 56 | SIGNING_IN: { 57 | "AUTHENTICATION:AUTHENTICATED": (_, { user, loomApiKey }): AuthState => ({ 58 | state: "AUTHENTICATED", 59 | user, 60 | loomApiKey, 61 | }), 62 | "AUTHENTICATION:SIGN_IN_ERROR": (_, { error }): AuthState => ({ 63 | state: "ERROR", 64 | error, 65 | }), 66 | }, 67 | AUTHENTICATED: {}, 68 | ERROR: {}, 69 | }); 70 | 71 | const authenticatedAuthContext = createContext( 72 | null as unknown as PickState 73 | ); 74 | 75 | export const AuthenticatedAuthProvider: React.FC<{ 76 | auth: PickState; 77 | }> = ({ auth, children }) => ( 78 | 79 | {children} 80 | 81 | ); 82 | 83 | export const useAuthenticatedAuth = () => useContext(authenticatedAuthContext); 84 | 85 | export const useAuth = ( 86 | initialState: AuthState = { 87 | state: "CHECKING_AUTHENTICATION", 88 | } 89 | ): [AuthState, Dispatch] => { 90 | const { authentication } = useEnvironment(); 91 | const auth = useReducer(reducer, initialState); 92 | 93 | useDevtools("auth", auth); 94 | 95 | const [state, dispatch] = auth; 96 | 97 | useEffect(() => authentication.subscribe(dispatch), []); 98 | 99 | useStateEffect(state, "SIGNING_IN", () => authentication.signIn()); 100 | 101 | return auth; 102 | }; 103 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { AppState } from "@excalidraw/excalidraw/types/types"; 2 | import { 3 | ExcalidrawData, 4 | ExcalidrawElement, 5 | } from "./providers/Excalidraw/types"; 6 | 7 | export const hasChangedExcalidraw = ( 8 | oldData: ExcalidrawData, 9 | newData: ExcalidrawData 10 | ) => { 11 | return ( 12 | oldData.version !== newData.version || 13 | oldData.appState.viewBackgroundColor !== 14 | newData.appState.viewBackgroundColor 15 | ); 16 | }; 17 | 18 | export const getChangedData = ( 19 | newData: ExcalidrawData, 20 | oldData: ExcalidrawData 21 | ): ExcalidrawData => { 22 | if (newData.version === oldData.version) { 23 | return oldData; 24 | } 25 | 26 | return { 27 | ...newData, 28 | elements: reconcileElements( 29 | oldData.elements, 30 | newData.elements, 31 | oldData.appState 32 | ), 33 | }; 34 | }; 35 | 36 | export type ReconciledElements = readonly ExcalidrawElement[] & { 37 | _brand: "reconciledElements"; 38 | }; 39 | 40 | export type BroadcastedExcalidrawElement = ExcalidrawElement & { 41 | parent?: string; 42 | }; 43 | 44 | const shouldDiscardRemoteElement = ( 45 | localAppState: AppState, 46 | local: ExcalidrawElement | undefined, 47 | remote: BroadcastedExcalidrawElement 48 | ): boolean => { 49 | if ( 50 | local && 51 | // local element is being edited 52 | (local.id === localAppState.editingElement?.id || 53 | local.id === localAppState.resizingElement?.id || 54 | local.id === localAppState.draggingElement?.id || 55 | // local element is newer 56 | local.version > remote.version || 57 | // resolve conflicting edits deterministically by taking the one with 58 | // the lowest versionNonce 59 | (local.version === remote.version && 60 | local.versionNonce < remote.versionNonce)) 61 | ) { 62 | return true; 63 | } 64 | return false; 65 | }; 66 | 67 | const getElementsMapWithIndex = ( 68 | elements: readonly T[] 69 | ) => 70 | elements.reduce( 71 | ( 72 | acc: { 73 | [key: string]: [element: T, index: number] | undefined; 74 | }, 75 | element: T, 76 | idx 77 | ) => { 78 | acc[element.id] = [element, idx]; 79 | return acc; 80 | }, 81 | {} 82 | ); 83 | 84 | export const reconcileElements = ( 85 | localElements: readonly ExcalidrawElement[], 86 | remoteElements: readonly BroadcastedExcalidrawElement[], 87 | localAppState: AppState 88 | ): ReconciledElements => { 89 | const localElementsData = 90 | getElementsMapWithIndex(localElements); 91 | 92 | const reconciledElements: ExcalidrawElement[] = localElements.slice(); 93 | 94 | const duplicates = new WeakMap(); 95 | 96 | let cursor = 0; 97 | let offset = 0; 98 | 99 | let remoteElementIdx = -1; 100 | for (const remoteElement of remoteElements) { 101 | remoteElementIdx++; 102 | 103 | const local = localElementsData[remoteElement.id]; 104 | 105 | if (shouldDiscardRemoteElement(localAppState, local?.[0], remoteElement)) { 106 | if (remoteElement.parent) { 107 | delete remoteElement.parent; 108 | } 109 | 110 | continue; 111 | } 112 | 113 | if (local) { 114 | // mark for removal since it'll be replaced with the remote element 115 | duplicates.set(local[0], true); 116 | } 117 | 118 | // parent may not be defined in case the remote client is running an older 119 | // excalidraw version 120 | const parent = 121 | remoteElement.parent || remoteElements[remoteElementIdx - 1]?.id || null; 122 | 123 | if (parent != null) { 124 | delete remoteElement.parent; 125 | 126 | // ^ indicates the element is the first in elements array 127 | if (parent === "^") { 128 | offset++; 129 | if (cursor === 0) { 130 | reconciledElements.unshift(remoteElement); 131 | localElementsData[remoteElement.id] = [ 132 | remoteElement, 133 | cursor - offset, 134 | ]; 135 | } else { 136 | reconciledElements.splice(cursor + 1, 0, remoteElement); 137 | localElementsData[remoteElement.id] = [ 138 | remoteElement, 139 | cursor + 1 - offset, 140 | ]; 141 | cursor++; 142 | } 143 | } else { 144 | let idx = localElementsData[parent] 145 | ? localElementsData[parent]![1] 146 | : null; 147 | if (idx != null) { 148 | idx += offset; 149 | } 150 | if (idx != null && idx >= cursor) { 151 | reconciledElements.splice(idx + 1, 0, remoteElement); 152 | offset++; 153 | localElementsData[remoteElement.id] = [ 154 | remoteElement, 155 | idx + 1 - offset, 156 | ]; 157 | cursor = idx + 1; 158 | } else if (idx != null) { 159 | reconciledElements.splice(cursor + 1, 0, remoteElement); 160 | offset++; 161 | localElementsData[remoteElement.id] = [ 162 | remoteElement, 163 | cursor + 1 - offset, 164 | ]; 165 | cursor++; 166 | } else { 167 | reconciledElements.push(remoteElement); 168 | localElementsData[remoteElement.id] = [ 169 | remoteElement, 170 | reconciledElements.length - 1 - offset, 171 | ]; 172 | } 173 | } 174 | // no parent z-index information, local element exists → replace in place 175 | } else if (local) { 176 | reconciledElements[local[1]] = remoteElement; 177 | localElementsData[remoteElement.id] = [remoteElement, local[1]]; 178 | // otherwise push to the end 179 | } else { 180 | reconciledElements.push(remoteElement); 181 | localElementsData[remoteElement.id] = [ 182 | remoteElement, 183 | reconciledElements.length - 1 - offset, 184 | ]; 185 | } 186 | } 187 | 188 | const ret: readonly ExcalidrawElement[] = reconciledElements.filter( 189 | (element) => !duplicates.has(element) 190 | ); 191 | 192 | return ret as ReconciledElements; 193 | }; 194 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: { 3 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 4 | safelist: [ 5 | "text-gray-500", 6 | "bg-gray-50", 7 | "hover:bg-gray-100", 8 | "focus:ring-gray-50", 9 | "opacity-50", 10 | "text-green-500", 11 | "bg-green-50", 12 | "hover:bg-green-100", 13 | "focus:ring-green-50", 14 | ], 15 | }, 16 | darkMode: false, // or 'media' or 'class' 17 | theme: { 18 | extend: {}, 19 | }, 20 | variants: { 21 | extend: {}, 22 | }, 23 | plugins: [], 24 | }; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "types": ["vite/client", "jest", "node"], 6 | "paths": { 7 | "@excalidraw/excalidraw": ["./src/excalidraw.d.ts"] 8 | }, 9 | "allowJs": false, 10 | "skipLibCheck": false, 11 | "esModuleInterop": false, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "ESNext", 16 | "moduleResolution": "Node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": ["./src"] 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | import react from "@vitejs/plugin-react"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | }); 9 | --------------------------------------------------------------------------------