├── .babelrc ├── .eslintignore ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE ├── README.md ├── backend ├── data.test.ts ├── data.ts ├── pg.ts └── postgres-storage.ts ├── frontend ├── client-state.ts ├── collaborator.module.css ├── collaborator.tsx ├── declarations.d.ts ├── designer.tsx ├── events.tsx ├── mutators.ts ├── nav.module.css ├── nav.tsx ├── rand.ts ├── rect-controller.tsx ├── rect.tsx ├── selection.tsx ├── shape.ts ├── smoothie.ts ├── subscriptions.ts └── undo-redo.tsx ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── _document.js ├── api │ ├── echo.ts │ ├── replicache-pull.ts │ └── replicache-push.ts ├── d │ └── [id].tsx └── index.tsx ├── styles └── globals.css ├── tsconfig.json └── util └── json.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "next/babel", 5 | { 6 | "preset-env": { 7 | "targets": { 8 | "esmodules": true 9 | } 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # react-designer 37 | lib 38 | 39 | 40 | # Supabase 41 | **/supabase/.branches 42 | **/supabase/.temp 43 | **/supabase/.env 44 | supabase/config.toml 45 | 46 | .env 47 | 48 | 49 | tsconfig.tsbuildinfo 50 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | unsafe-perm = true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Attribution 4.0 International (CC BY 4.0) 2 | https://creativecommons.org/licenses/by/4.0/ 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Replidraw 2 | 3 | A tiny Figma-like multiplayer graphics editor. 4 | 5 | Built with [Replicache](https://replicache.dev), [Next.js](https://nextjs.org/), 6 | [Pusher](https://pusher.com/), and [Postgres](https://www.postgresql.org/). 7 | 8 | Running live at https://replidraw.herokuapp.com/. 9 | 10 | # Prerequisites 11 | 12 | 1. [Get a Replicache license key](https://doc.replicache.dev/licensing) 13 | 2. Install PostgreSQL. On MacOS, we recommend using [Postgres.app](https://postgresapp.com/). For other OSes and options, see [Postgres Downloads](https://www.postgresql.org/download/). 14 | 3. [Sign up for a free pusher.com account](https://pusher.com/) and create a new "channels" app. 15 | 16 | # To run locally 17 | 18 | Get the Pusher environment variables from the ["App Keys" section](https://i.imgur.com/7DNmTKZ.png) of the Pusher App UI. 19 | 20 | **Note:** These instructions assume you installed PostgreSQL via Postgres.app on MacOS. If you installed some other way, or configured PostgreSQL specially, you may additionally need to set the `PGUSER` and `PGPASSWORD` environment variables.

21 | 22 | ``` 23 | export DATABASE_URL=postgresql://:@localhost:/replidraw 24 | export NEXT_PUBLIC_REPLICACHE_LICENSE_KEY="" 25 | export NEXT_PUBLIC_PUSHER_APP_ID= 26 | export NEXT_PUBLIC_PUSHER_KEY= 27 | export NEXT_PUBLIC_PUSHER_SECRET= 28 | export NEXT_PUBLIC_PUSHER_CLUSTER= 29 | 30 | # Create a new database for Replidraw 31 | psql -d postgres -c 'create database replidraw' 32 | 33 | npm install 34 | npm run dev 35 | ``` 36 | -------------------------------------------------------------------------------- /backend/data.test.ts: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import {setup, test} from 'mocha'; 3 | import type {ReadonlyJSONValue} from 'replicache'; 4 | import {createDatabase, delEntry, getEntries, getEntry, putEntry} from './data'; 5 | import {transact, withExecutor} from './pg'; 6 | 7 | setup(async () => { 8 | await transact(executor => createDatabase(executor)); 9 | }); 10 | 11 | test('getEntry', async () => { 12 | type Case = { 13 | name: string; 14 | exists: boolean; 15 | deleted: boolean; 16 | validJSON: boolean; 17 | }; 18 | const cases: Case[] = [ 19 | { 20 | name: 'does not exist', 21 | exists: false, 22 | deleted: false, 23 | validJSON: false, 24 | }, 25 | { 26 | name: 'exists, deleted', 27 | exists: true, 28 | deleted: true, 29 | validJSON: true, 30 | }, 31 | { 32 | name: 'exists, not deleted, invalid JSON', 33 | exists: true, 34 | deleted: false, 35 | validJSON: false, 36 | }, 37 | { 38 | name: 'exists, not deleted, valid JSON', 39 | exists: true, 40 | deleted: false, 41 | validJSON: true, 42 | }, 43 | ]; 44 | 45 | await withExecutor(async executor => { 46 | for (const c of cases) { 47 | await executor(`delete from entry where spaceid = 's1' and key = 'foo'`); 48 | if (c.exists) { 49 | await executor( 50 | `insert into entry (spaceid, key, value, deleted, version, lastmodified) values ('s1', 'foo', $1, $2, 1, now())`, 51 | [c.validJSON ? JSON.stringify(42) : 'not json', c.deleted], 52 | ); 53 | } 54 | 55 | const promise = getEntry(executor, 's1', 'foo'); 56 | let result: ReadonlyJSONValue | undefined; 57 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 58 | let error: any | undefined; 59 | await promise.then( 60 | r => (result = r), 61 | e => (error = String(e)), 62 | ); 63 | if (!c.exists) { 64 | expect(result, c.name).undefined; 65 | expect(error, c.name).undefined; 66 | } else if (c.deleted) { 67 | expect(result, c.name).undefined; 68 | expect(error, c.name).undefined; 69 | } else if (!c.validJSON) { 70 | expect(result, c.name).undefined; 71 | expect(error, c.name).contains('SyntaxError'); 72 | } else { 73 | expect(result, c.name).eq(42); 74 | expect(error, c.name).undefined; 75 | } 76 | } 77 | }); 78 | }); 79 | 80 | test('getEntry RoundTrip types', async () => { 81 | await withExecutor(async executor => { 82 | await putEntry(executor, 's1', 'boolean', true, 1); 83 | await putEntry(executor, 's1', 'number', 42, 1); 84 | await putEntry(executor, 's1', 'string', 'foo', 1); 85 | await putEntry(executor, 's1', 'array', [1, 2, 3], 1); 86 | await putEntry(executor, 's1', 'object', {a: 1, b: 2}, 1); 87 | 88 | expect(await getEntry(executor, 's1', 'boolean')).eq(true); 89 | expect(await getEntry(executor, 's1', 'number')).eq(42); 90 | expect(await getEntry(executor, 's1', 'string')).eq('foo'); 91 | expect(await getEntry(executor, 's1', 'array')).deep.equal([1, 2, 3]); 92 | expect(await getEntry(executor, 's1', 'object')).deep.equal({a: 1, b: 2}); 93 | }); 94 | }); 95 | 96 | test('getEntries', async () => { 97 | await withExecutor(async executor => { 98 | await executor(`delete from entry where spaceid = 's1'`); 99 | await putEntry(executor, 's1', 'foo', 'foo', 1); 100 | await putEntry(executor, 's1', 'bar', 'bar', 1); 101 | await putEntry(executor, 's1', 'baz', 'baz', 1); 102 | 103 | type Case = { 104 | name: string; 105 | fromKey: string; 106 | expect: string[]; 107 | }; 108 | const cases: Case[] = [ 109 | { 110 | name: 'fromEmpty', 111 | fromKey: '', 112 | expect: ['bar', 'baz', 'foo'], 113 | }, 114 | { 115 | name: 'fromB', 116 | fromKey: 'b', 117 | expect: ['bar', 'baz', 'foo'], 118 | }, 119 | { 120 | name: 'fromBar', 121 | fromKey: 'bar', 122 | expect: ['bar', 'baz', 'foo'], 123 | }, 124 | { 125 | name: 'fromBas', 126 | fromKey: 'bas', 127 | expect: ['baz', 'foo'], 128 | }, 129 | { 130 | name: 'fromF', 131 | fromKey: 'f', 132 | expect: ['foo'], 133 | }, 134 | { 135 | name: 'fromFooa', 136 | fromKey: 'fooa', 137 | expect: [], 138 | }, 139 | ]; 140 | 141 | for (const c of cases) { 142 | const entries = []; 143 | for await (const entry of getEntries(executor, 's1', c.fromKey)) { 144 | entries.push(entry); 145 | } 146 | expect(entries).deep.equal( 147 | c.expect.map(k => [k, k]), 148 | c.name, 149 | ); 150 | } 151 | }); 152 | }); 153 | 154 | test('putEntry', async () => { 155 | type Case = { 156 | name: string; 157 | duplicate: boolean; 158 | deleted: boolean; 159 | }; 160 | 161 | const cases: Case[] = [ 162 | { 163 | name: 'not duplicate', 164 | duplicate: false, 165 | deleted: false, 166 | }, 167 | { 168 | name: 'duplicate', 169 | duplicate: true, 170 | deleted: false, 171 | }, 172 | { 173 | name: 'deleted', 174 | duplicate: true, 175 | deleted: true, 176 | }, 177 | ]; 178 | 179 | await withExecutor(async executor => { 180 | for (const c of cases) { 181 | await executor(`delete from entry where spaceid = 's1' and key = 'foo'`); 182 | 183 | if (c.duplicate) { 184 | await putEntry(executor, 's1', 'foo', 41, 1); 185 | if (c.deleted) { 186 | await delEntry(executor, 's1', 'foo', 1); 187 | } 188 | } 189 | const res = putEntry(executor, 's1', 'foo', 42, 2); 190 | 191 | await res.catch(() => ({})); 192 | 193 | const qr = await executor( 194 | `select spaceid, key, value, deleted, version 195 | from entry where spaceid = 's1' and key = 'foo'`, 196 | ); 197 | const [row] = qr.rows; 198 | 199 | expect(row, c.name).not.undefined; 200 | const {spaceid, key, value, deleted, version} = row; 201 | expect(spaceid, c.name).eq('s1'); 202 | expect(key, c.name).eq('foo'); 203 | expect(value, c.name).eq('42'); 204 | expect(deleted, c.name).false; 205 | expect(version, c.name).eq(2); 206 | } 207 | }); 208 | }); 209 | 210 | test('delEntry', async () => { 211 | type Case = { 212 | name: string; 213 | exists: boolean; 214 | }; 215 | const cases: Case[] = [ 216 | { 217 | name: 'does not exist', 218 | exists: false, 219 | }, 220 | { 221 | name: 'exists', 222 | exists: true, 223 | }, 224 | ]; 225 | for (const c of cases) { 226 | await withExecutor(async executor => { 227 | await executor(`delete from entry where spaceid = 's1' and key = 'foo'`); 228 | if (c.exists) { 229 | await executor( 230 | `insert into entry (spaceid, key, value, deleted, version, lastmodified) values ('s1', 'foo', '42', false, 1, now())`, 231 | ); 232 | } 233 | 234 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 235 | let error: any | undefined; 236 | await delEntry(executor, 's1', 'foo', 2).catch(e => (error = String(e))); 237 | 238 | const qr = await executor( 239 | `select spaceid, key, value, deleted, version from entry where spaceid = 's1' and key = 'foo'`, 240 | ); 241 | const [row] = qr.rows; 242 | 243 | if (c.exists) { 244 | expect(row, c.name).not.undefined; 245 | const {spaceid, key, value, deleted, version} = row; 246 | expect(spaceid, c.name).eq('s1'); 247 | expect(key, c.name).eq('foo'); 248 | expect(value, c.name).eq('42'); 249 | expect(deleted, c.name).true; 250 | expect(version, c.name).eq(2); 251 | } else { 252 | expect(row, c.name).undefined; 253 | expect(error, c.name).undefined; 254 | } 255 | }); 256 | } 257 | }); 258 | -------------------------------------------------------------------------------- /backend/data.ts: -------------------------------------------------------------------------------- 1 | import type {JSONValue, ReadonlyJSONValue} from 'replicache'; 2 | import {z} from 'zod'; 3 | import type {Executor} from './pg'; 4 | 5 | export async function createDatabase(executor: Executor) { 6 | const schemaVersion = await getSchemaVersion(executor); 7 | if (schemaVersion < 0 || schemaVersion > 1) { 8 | throw new Error('Unexpected schema version: ' + schemaVersion); 9 | } 10 | if (schemaVersion === 0) { 11 | await createSchemaVersion1(executor); 12 | } 13 | console.log('schemaVersion is 1 - nothing to do'); 14 | } 15 | 16 | async function getSchemaVersion(executor: Executor) { 17 | const metaExists = await executor(`select exists( 18 | select from pg_tables where schemaname = 'public' and tablename = 'meta')`); 19 | if (!metaExists.rows[0].exists) { 20 | return 0; 21 | } 22 | 23 | const qr = await executor( 24 | `select value from meta where key = 'schemaVersion'`, 25 | ); 26 | return qr.rows[0].value; 27 | } 28 | 29 | export async function createSchemaVersion1(executor: Executor) { 30 | await executor('create table meta (key text primary key, value json)'); 31 | await executor("insert into meta (key, value) values ('schemaVersion', '1')"); 32 | 33 | await executor(`create table space ( 34 | id text primary key not null, 35 | version integer not null, 36 | lastmodified timestamp(6) not null 37 | )`); 38 | 39 | await executor(`create table client ( 40 | id text primary key not null, 41 | lastmutationid integer not null, 42 | lastmodified timestamp(6) not null 43 | )`); 44 | 45 | await executor(`create table entry ( 46 | spaceid text not null, 47 | key text not null, 48 | value text not null, 49 | deleted boolean not null, 50 | version integer not null, 51 | lastmodified timestamp(6) not null 52 | )`); 53 | 54 | await executor(`create unique index on entry (spaceid, key)`); 55 | await executor(`create index on entry (spaceid)`); 56 | await executor(`create index on entry (deleted)`); 57 | await executor(`create index on entry (version)`); 58 | } 59 | 60 | export async function getEntry( 61 | executor: Executor, 62 | spaceid: string, 63 | key: string, 64 | ): Promise { 65 | const {rows} = await executor( 66 | 'select value from entry where spaceid = $1 and key = $2 and deleted = false', 67 | [spaceid, key], 68 | ); 69 | const value = rows[0]?.value; 70 | if (value === undefined) { 71 | return undefined; 72 | } 73 | return JSON.parse(value); 74 | } 75 | 76 | export async function putEntry( 77 | executor: Executor, 78 | spaceID: string, 79 | key: string, 80 | value: ReadonlyJSONValue, 81 | version: number, 82 | ): Promise { 83 | await executor( 84 | ` 85 | insert into entry (spaceid, key, value, deleted, version, lastmodified) 86 | values ($1, $2, $3, false, $4, now()) 87 | on conflict (spaceid, key) do update set 88 | value = $3, deleted = false, version = $4, lastmodified = now() 89 | `, 90 | [spaceID, key, JSON.stringify(value), version], 91 | ); 92 | } 93 | 94 | export async function delEntry( 95 | executor: Executor, 96 | spaceID: string, 97 | key: string, 98 | version: number, 99 | ): Promise { 100 | await executor( 101 | `update entry set deleted = true, version = $3 where spaceid = $1 and key = $2`, 102 | [spaceID, key, version], 103 | ); 104 | } 105 | 106 | export async function* getEntries( 107 | executor: Executor, 108 | spaceID: string, 109 | fromKey: string, 110 | ): AsyncIterable { 111 | const {rows} = await executor( 112 | `select key, value from entry where spaceid = $1 and key >= $2 and deleted = false order by key`, 113 | [spaceID, fromKey], 114 | ); 115 | for (const row of rows) { 116 | yield [ 117 | row.key as string, 118 | JSON.parse(row.value) as ReadonlyJSONValue, 119 | ] as const; 120 | } 121 | } 122 | 123 | export async function getChangedEntries( 124 | executor: Executor, 125 | spaceID: string, 126 | prevVersion: number, 127 | // TODO(arv): Change this to ReadonlyJSONValue 128 | ): Promise<[key: string, value: JSONValue, deleted: boolean][]> { 129 | const {rows} = await executor( 130 | `select key, value, deleted from entry where spaceid = $1 and version > $2`, 131 | [spaceID, prevVersion], 132 | ); 133 | return rows.map(row => [row.key, JSON.parse(row.value), row.deleted]); 134 | } 135 | 136 | export async function getCookie( 137 | executor: Executor, 138 | spaceID: string, 139 | ): Promise { 140 | const {rows} = await executor(`select version from space where id = $1`, [ 141 | spaceID, 142 | ]); 143 | const value = rows[0]?.version; 144 | if (value === undefined) { 145 | return undefined; 146 | } 147 | return z.number().parse(value); 148 | } 149 | 150 | export async function setCookie( 151 | executor: Executor, 152 | spaceID: string, 153 | version: number, 154 | ): Promise { 155 | await executor( 156 | ` 157 | insert into space (id, version, lastmodified) values ($1, $2, now()) 158 | on conflict (id) do update set version = $2, lastmodified = now() 159 | `, 160 | [spaceID, version], 161 | ); 162 | } 163 | 164 | export async function getLastMutationID( 165 | executor: Executor, 166 | clientID: string, 167 | ): Promise { 168 | const {rows} = await executor( 169 | `select lastmutationid from client where id = $1`, 170 | [clientID], 171 | ); 172 | const value = rows[0]?.lastmutationid; 173 | if (value === undefined) { 174 | return undefined; 175 | } 176 | return z.number().parse(value); 177 | } 178 | 179 | export async function setLastMutationID( 180 | executor: Executor, 181 | clientID: string, 182 | lastMutationID: number, 183 | ): Promise { 184 | await executor( 185 | ` 186 | insert into client (id, lastmutationid, lastmodified) 187 | values ($1, $2, now()) 188 | on conflict (id) do update set lastmutationid = $2, lastmodified = now() 189 | `, 190 | [clientID, lastMutationID], 191 | ); 192 | } 193 | -------------------------------------------------------------------------------- /backend/pg.ts: -------------------------------------------------------------------------------- 1 | // Low-level config and utilities for Postgres. 2 | 3 | import {Pool, QueryResult} from 'pg'; 4 | 5 | const pool = new Pool( 6 | process.env.DATABASE_URL 7 | ? { 8 | connectionString: process.env.DATABASE_URL, 9 | ssl: 10 | process.env.NODE_ENV === 'production' 11 | ? { 12 | rejectUnauthorized: false, 13 | } 14 | : undefined, 15 | } 16 | : undefined, 17 | ); 18 | 19 | // the pool will emit an error on behalf of any idle clients 20 | // it contains if a backend error or network partition happens 21 | pool.on('error', err => { 22 | console.error('Unexpected error on idle client', err); 23 | process.exit(-1); 24 | }); 25 | 26 | pool.on('connect', client => { 27 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 28 | client.query( 29 | 'SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE', 30 | ); 31 | }); 32 | 33 | export async function withExecutor( 34 | f: (executor: Executor) => R, 35 | ): Promise { 36 | const client = await pool.connect(); 37 | 38 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 39 | const executor = async (sql: string, params?: any[]) => { 40 | try { 41 | return await client.query(sql, params); 42 | } catch (e) { 43 | throw new Error( 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | `Error executing SQL: ${sql}: ${(e as unknown as any).toString()}`, 46 | ); 47 | } 48 | }; 49 | 50 | try { 51 | return await f(executor); 52 | } finally { 53 | client.release(); 54 | } 55 | } 56 | 57 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 58 | export type Executor = (sql: string, params?: any[]) => Promise; 59 | export type TransactionBodyFn = (executor: Executor) => Promise; 60 | 61 | /** 62 | * Invokes a supplied function within an RDS transaction. 63 | * @param body Function to invoke. If this throws, the transaction will be rolled 64 | * back. The thrown error will be re-thrown. 65 | */ 66 | export async function transact(body: TransactionBodyFn) { 67 | return await withExecutor(async executor => { 68 | return await transactWithExecutor(executor, body); 69 | }); 70 | } 71 | 72 | async function transactWithExecutor( 73 | executor: Executor, 74 | body: TransactionBodyFn, 75 | ) { 76 | for (let i = 0; i < 10; i++) { 77 | try { 78 | await executor('begin'); 79 | try { 80 | const r = await body(executor); 81 | await executor('commit'); 82 | return r; 83 | } catch (e) { 84 | console.log('caught error', e, 'rolling back'); 85 | await executor('rollback'); 86 | throw e; 87 | } 88 | } catch (e) { 89 | if (shouldRetryTransaction(e)) { 90 | console.log( 91 | `Retrying transaction due to error ${e} - attempt number ${i}`, 92 | ); 93 | continue; 94 | } 95 | throw e; 96 | } 97 | } 98 | throw new Error('Tried to execute transacation too many times. Giving up.'); 99 | } 100 | 101 | //stackoverflow.com/questions/60339223/node-js-transaction-coflicts-in-postgresql-optimistic-concurrency-control-and 102 | function shouldRetryTransaction(err: unknown) { 103 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 104 | const code = typeof err === 'object' ? String((err as any).code) : null; 105 | return code === '40001' || code === '40P01'; 106 | } 107 | -------------------------------------------------------------------------------- /backend/postgres-storage.ts: -------------------------------------------------------------------------------- 1 | import type {ReadonlyJSONValue} from 'replicache'; 2 | import type {Storage} from 'replicache-transaction'; 3 | import {putEntry, getEntry, getEntries, delEntry} from './data'; 4 | import type {Executor} from './pg'; 5 | 6 | // Implements the Storage interface required by replicache-transaction in terms 7 | // of our Postgres database. 8 | export class PostgresStorage implements Storage { 9 | private _spaceID: string; 10 | private _version: number; 11 | private _executor: Executor; 12 | 13 | constructor(spaceID: string, version: number, executor: Executor) { 14 | this._spaceID = spaceID; 15 | this._version = version; 16 | this._executor = executor; 17 | } 18 | 19 | putEntry(key: string, value: ReadonlyJSONValue): Promise { 20 | return putEntry(this._executor, this._spaceID, key, value, this._version); 21 | } 22 | 23 | async hasEntry(key: string): Promise { 24 | const v = await this.getEntry(key); 25 | return v !== undefined; 26 | } 27 | 28 | getEntry(key: string): Promise { 29 | return getEntry(this._executor, this._spaceID, key); 30 | } 31 | 32 | getEntries( 33 | fromKey: string, 34 | ): AsyncIterable { 35 | return getEntries(this._executor, this._spaceID, fromKey); 36 | } 37 | 38 | delEntry(key: string): Promise { 39 | return delEntry(this._executor, this._spaceID, key, this._version); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/client-state.ts: -------------------------------------------------------------------------------- 1 | import type {ReadTransaction, WriteTransaction} from 'replicache'; 2 | import {z} from 'zod'; 3 | import {randInt} from './rand'; 4 | 5 | const colors = [ 6 | '#f94144', 7 | '#f3722c', 8 | '#f8961e', 9 | '#f9844a', 10 | '#f9c74f', 11 | '#90be6d', 12 | '#43aa8b', 13 | '#4d908e', 14 | '#577590', 15 | '#277da1', 16 | ]; 17 | const avatars = [ 18 | ['🐶', 'Puppy'], 19 | ['🐱', 'Kitty'], 20 | ['🐭', 'Mouse'], 21 | ['🐹', 'Hamster'], 22 | ['🐰', 'Bunny'], 23 | ['🦊', 'Fox'], 24 | ['🐻', 'Bear'], 25 | ['🐼', 'Panda'], 26 | ['🐻‍❄️', 'Polar Bear'], 27 | ['🐨', 'Koala'], 28 | ['🐯', 'Tiger'], 29 | ['🦁', 'Lion'], 30 | ['🐮', 'Cow'], 31 | ['🐷', 'Piggy'], 32 | ['🐵', 'Monkey'], 33 | ['🐣', 'Chick'], 34 | ]; 35 | 36 | export const userInfoSchema = z.object({ 37 | avatar: z.string(), 38 | name: z.string(), 39 | color: z.string(), 40 | }); 41 | 42 | export const clientStatePrefix = `clientState-`; 43 | 44 | export const clientStateKey = (id: string) => `${clientStatePrefix}${id}`; 45 | 46 | export const clientStateID = (key: string) => { 47 | if (!key.startsWith(clientStatePrefix)) { 48 | throw new Error(`Invalid key: ${key}`); 49 | } 50 | return key.substring(clientStatePrefix.length); 51 | }; 52 | 53 | export const clientStateSchema = z.object({ 54 | id: z.string(), 55 | cursor: z.object({ 56 | x: z.number(), 57 | y: z.number(), 58 | }), 59 | overID: z.string(), 60 | selectedID: z.string(), 61 | userInfo: userInfoSchema, 62 | }); 63 | 64 | export type UserInfo = z.TypeOf; 65 | export type ClientState = z.TypeOf; 66 | 67 | const clientStateValueSchema = clientStateSchema.omit({id: true}); 68 | 69 | export async function initClientState( 70 | tx: WriteTransaction, 71 | {id, defaultUserInfo}: {id: string; defaultUserInfo: UserInfo}, 72 | ): Promise { 73 | if (await tx.has(clientStateKey(id))) { 74 | return; 75 | } 76 | await putClientState(tx, { 77 | id, 78 | cursor: { 79 | x: 0, 80 | y: 0, 81 | }, 82 | overID: '', 83 | selectedID: '', 84 | userInfo: defaultUserInfo, 85 | }); 86 | } 87 | 88 | export async function getClientState( 89 | tx: ReadTransaction, 90 | id: string, 91 | ): Promise { 92 | const val = await tx.get(clientStateKey(id)); 93 | if (val === undefined) { 94 | throw new Error('Expected clientState to be initialized already: ' + id); 95 | } 96 | return { 97 | id, 98 | ...clientStateValueSchema.parse(val), 99 | }; 100 | } 101 | 102 | export async function putClientState( 103 | tx: WriteTransaction, 104 | clientState: ClientState, 105 | ): Promise { 106 | await tx.put(clientStateKey(clientState.id), clientState); 107 | } 108 | 109 | export async function setCursor( 110 | tx: WriteTransaction, 111 | {id, x, y}: {id: string; x: number; y: number}, 112 | ): Promise { 113 | const clientState = await getClientState(tx, id); 114 | clientState.cursor.x = x; 115 | clientState.cursor.y = y; 116 | await putClientState(tx, clientState); 117 | } 118 | 119 | export async function overShape( 120 | tx: WriteTransaction, 121 | {clientID, shapeID}: {clientID: string; shapeID: string}, 122 | ): Promise { 123 | const clientState = await getClientState(tx, clientID); 124 | clientState.overID = shapeID; 125 | await putClientState(tx, clientState); 126 | } 127 | 128 | export async function selectShape( 129 | tx: WriteTransaction, 130 | {clientID, shapeID}: {clientID: string; shapeID: string}, 131 | ): Promise { 132 | const clientState = await getClientState(tx, clientID); 133 | clientState.selectedID = shapeID; 134 | await putClientState(tx, clientState); 135 | } 136 | 137 | export function randUserInfo(): UserInfo { 138 | const [avatar, name] = avatars[randInt(0, avatars.length - 1)]; 139 | return { 140 | avatar, 141 | name, 142 | color: colors[randInt(0, colors.length - 1)], 143 | }; 144 | } 145 | -------------------------------------------------------------------------------- /frontend/collaborator.module.css: -------------------------------------------------------------------------------- 1 | .collaborator { 2 | position: absolute; 3 | left: 0; 4 | top: 0; 5 | width: 100%; 6 | height: 100%; 7 | transition: opacity 100ms linear; 8 | pointer-events: none; 9 | } 10 | 11 | .cursor { 12 | position: absolute; 13 | font-family: 'Inter', sans-serif; 14 | font-size: 11px; 15 | font-weight: 400; 16 | line-height: 1em; 17 | cursor: pointer; 18 | } 19 | 20 | .pointer { 21 | display: inline-block; 22 | transform: rotate(-127deg); 23 | font-size: 16px; 24 | } 25 | 26 | .userinfo { 27 | display: block; 28 | margin: 4px 16px; 29 | padding: 5px; 30 | white-space: nowrap; 31 | } 32 | -------------------------------------------------------------------------------- /frontend/collaborator.tsx: -------------------------------------------------------------------------------- 1 | import styles from './collaborator.module.css'; 2 | import {useEffect, useState} from 'react'; 3 | import {Rect} from './rect'; 4 | import {useCursor} from './smoothie'; 5 | import type {Replicache} from 'replicache'; 6 | import type {M} from './mutators'; 7 | import {useClientInfo} from './subscriptions'; 8 | 9 | const hideCollaboratorDelay = 5000; 10 | 11 | interface Position { 12 | pos: { 13 | x: number; 14 | y: number; 15 | }; 16 | ts: number; 17 | } 18 | 19 | export function Collaborator({ 20 | rep, 21 | clientID, 22 | }: { 23 | rep: Replicache; 24 | clientID: string; 25 | }) { 26 | const clientInfo = useClientInfo(rep, clientID); 27 | const [lastPos, setLastPos] = useState(null); 28 | const [gotFirstChange, setGotFirstChange] = useState(false); 29 | const [, setPoke] = useState({}); 30 | const cursor = useCursor(rep, clientID); 31 | 32 | let curPos = null; 33 | let userInfo = null; 34 | if (clientInfo) { 35 | curPos = cursor; 36 | userInfo = clientInfo.userInfo; 37 | } 38 | 39 | let elapsed = 0; 40 | let remaining = 0; 41 | let visible = false; 42 | 43 | if (curPos) { 44 | if (!lastPos) { 45 | console.log(`Cursor ${clientID} - got initial position`, curPos); 46 | setLastPos({pos: curPos, ts: Date.now()}); 47 | } else { 48 | if (lastPos.pos.x !== curPos.x || lastPos.pos.y !== curPos.y) { 49 | console.log(`Cursor ${clientID} - got change to`, curPos); 50 | setLastPos({pos: curPos, ts: Date.now()}); 51 | setGotFirstChange(true); 52 | } 53 | if (gotFirstChange) { 54 | elapsed = Date.now() - lastPos.ts; 55 | remaining = hideCollaboratorDelay - elapsed; 56 | visible = remaining > 0; 57 | } 58 | } 59 | } 60 | 61 | useEffect(() => { 62 | if (remaining > 0) { 63 | console.log(`Cursor ${clientID} - setting timer for ${remaining}ms`); 64 | const timerID = setTimeout(() => setPoke({}), remaining); 65 | return () => clearTimeout(timerID); 66 | } 67 | }); 68 | 69 | console.log( 70 | `Cursor ${clientID} - elapsed ${elapsed}, remaining: ${remaining}, visible: ${visible}`, 71 | ); 72 | if (!clientInfo || !curPos || !userInfo) { 73 | return null; 74 | } 75 | 76 | return ( 77 |
78 | {clientInfo.selectedID && ( 79 | 88 | )} 89 | 90 |
98 |
99 | ➤ 100 |
101 |
108 | {userInfo.avatar} {userInfo.name} 109 |
110 |
111 |
112 | ); 113 | } 114 | -------------------------------------------------------------------------------- /frontend/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cubic-hermite'; 2 | -------------------------------------------------------------------------------- /frontend/designer.tsx: -------------------------------------------------------------------------------- 1 | import React, {useRef, useState} from 'react'; 2 | import {Rect} from './rect'; 3 | import {HotKeys} from 'react-hotkeys'; 4 | import {Collaborator} from './collaborator'; 5 | import {RectController} from './rect-controller'; 6 | import {touchToMouse} from './events'; 7 | import {Selection} from './selection'; 8 | import {DraggableCore} from 'react-draggable'; 9 | import { 10 | useShapeIDs, 11 | useOverShapeID, 12 | useSelectedShapeID, 13 | useCollaboratorIDs, 14 | } from './subscriptions'; 15 | import type {Replicache} from 'replicache'; 16 | import type {M} from './mutators'; 17 | import type {UndoManager} from '@rocicorp/undo'; 18 | import {getShape, Shape} from './shape'; 19 | 20 | export function Designer({ 21 | rep, 22 | undoManager, 23 | }: { 24 | rep: Replicache; 25 | undoManager: UndoManager; 26 | }) { 27 | const ids = useShapeIDs(rep); 28 | const overID = useOverShapeID(rep); 29 | const selectedID = useSelectedShapeID(rep); 30 | const collaboratorIDs = useCollaboratorIDs(rep); 31 | 32 | const ref = useRef(null); 33 | const [dragging, setDragging] = useState(false); 34 | 35 | const move = async (dx = 0, dy = 0, animate = true) => { 36 | await rep.mutate.moveShape({id: selectedID, dx, dy, animate}); 37 | }; 38 | 39 | const handlers = { 40 | moveLeft: () => { 41 | void move(-20, 0); 42 | void undoManager.add({ 43 | redo: () => move(-20, 0, false), 44 | undo: () => move(20, 0, false), 45 | }); 46 | }, 47 | moveRight: () => { 48 | void move(20, 0); 49 | void undoManager.add({ 50 | redo: () => move(20, 0, false), 51 | undo: () => move(-20, 0, false), 52 | }); 53 | }, 54 | moveUp: () => { 55 | void move(0, -20); 56 | void undoManager.add({ 57 | redo: () => move(0, -20, false), 58 | undo: () => move(0, 20, false), 59 | }); 60 | }, 61 | moveDown: () => { 62 | void move(0, 20); 63 | void undoManager.add({ 64 | redo: () => move(0, 20, false), 65 | undo: () => move(0, -20, false), 66 | }); 67 | }, 68 | deleteShape: async (e?: KeyboardEvent) => { 69 | // Prevent navigating backward on some browsers. 70 | e && e.preventDefault(); 71 | const shapeBeforeDelete = await rep.query(tx => getShape(tx, selectedID)); 72 | const deleteShape = () => rep.mutate.deleteShape(selectedID); 73 | const createShape = () => 74 | rep.mutate.createShape(shapeBeforeDelete as Shape); 75 | 76 | void undoManager.add({ 77 | execute: deleteShape, 78 | undo: createShape, 79 | }); 80 | }, 81 | undo: () => undoManager.undo(), 82 | redo: () => undoManager.redo(), 83 | }; 84 | 85 | const onMouseMove = async ({ 86 | pageX, 87 | pageY, 88 | }: { 89 | pageX: number; 90 | pageY: number; 91 | }) => { 92 | if (ref && ref.current) { 93 | void rep.mutate.setCursor({ 94 | id: await rep.clientID, 95 | x: pageX, 96 | y: pageY - ref.current.offsetTop, 97 | }); 98 | } 99 | }; 100 | 101 | return ( 102 | 109 | setDragging(true)} 111 | onStop={() => setDragging(false)} 112 | > 113 |
touchToMouse(e, onMouseMove), 124 | }} 125 | > 126 | {ids.map(id => ( 127 | // draggable rects 128 | 136 | ))} 137 | 138 | { 139 | // self-highlight 140 | !dragging && overID && ( 141 | 149 | ) 150 | } 151 | 152 | { 153 | // self-selection 154 | selectedID && ( 155 | 165 | ) 166 | } 167 | 168 | { 169 | // collaborators 170 | // foreignObject seems super buggy in Safari, so instead we do the 171 | // text labels in an HTML context, then do collaborator selection 172 | // rectangles as their own independent svg content. Le. Sigh. 173 | collaboratorIDs.map(id => ( 174 | 181 | )) 182 | } 183 |
184 |
185 |
186 | ); 187 | } 188 | 189 | const keyMap = { 190 | moveLeft: ['left', 'shift+left'], 191 | moveRight: ['right', 'shift+right'], 192 | moveUp: ['up', 'shift+up'], 193 | moveDown: ['down', 'shift+down'], 194 | deleteShape: ['del', 'backspace'], 195 | undo: ['ctrl+z', 'command+z'], 196 | redo: ['ctrl+y', 'command+shift+z'], 197 | }; 198 | -------------------------------------------------------------------------------- /frontend/events.tsx: -------------------------------------------------------------------------------- 1 | import type {TouchEvent} from 'react'; 2 | 3 | export function touchToMouse( 4 | e: TouchEvent, 5 | handler: ({pageX, pageY}: {pageX: number; pageY: number}) => void, 6 | ) { 7 | if (e.touches.length === 1) { 8 | handler(e.touches[0]); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/mutators.ts: -------------------------------------------------------------------------------- 1 | import { 2 | initClientState, 3 | setCursor, 4 | overShape, 5 | selectShape, 6 | } from './client-state'; 7 | import { 8 | putShape, 9 | deleteShape, 10 | moveShape, 11 | resizeShape, 12 | rotateShape, 13 | initShapes, 14 | } from './shape'; 15 | 16 | export type M = typeof mutators; 17 | 18 | export const mutators = { 19 | createShape: putShape, 20 | deleteShape, 21 | moveShape, 22 | resizeShape, 23 | rotateShape, 24 | initClientState, 25 | setCursor, 26 | overShape, 27 | selectShape, 28 | initShapes, 29 | } as const; 30 | -------------------------------------------------------------------------------- /frontend/nav.module.css: -------------------------------------------------------------------------------- 1 | .nav { 2 | display: flex; 3 | background: rgb(44, 44, 44); 4 | flex: 0 0 auto; 5 | } 6 | 7 | .button { 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | min-width: 40px; 12 | height: 40px; 13 | opacity: '0.8'; 14 | fill: 'white'; 15 | stroke-width: 0; 16 | color: white; 17 | font-size: 13px; 18 | font-weight: 400; 19 | padding: 0 10px; 20 | } 21 | 22 | .button:hover { 23 | background-color: rgb(75, 158, 244); 24 | opacity: 1; 25 | } 26 | 27 | .spacer { 28 | flex: 1; 29 | } 30 | 31 | .about { 32 | display: flex; 33 | margin: auto 12px 0 auto; 34 | width: auto; 35 | align-items: center; 36 | } 37 | 38 | .user { 39 | color: white; 40 | display: flex; 41 | margin: auto 12px auto auto; 42 | padding: 0 10px; 43 | height: 25px; 44 | align-items: center; 45 | font-family: 'Inter', sans-serif; 46 | font-size: 13px; 47 | font-weight: 400; 48 | border-radius: 6px; 49 | line-height: 1em; 50 | } 51 | -------------------------------------------------------------------------------- /frontend/nav.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef, useState} from 'react'; 2 | import styles from './nav.module.css'; 3 | import {randomShape} from '../frontend/shape'; 4 | import Modal from 'react-bootstrap/Modal'; 5 | import Button from 'react-bootstrap/Button'; 6 | import Form from 'react-bootstrap/Form'; 7 | import {useUserInfo} from './subscriptions'; 8 | import type {Replicache} from 'replicache'; 9 | import type {M} from './mutators'; 10 | import type {UndoManager} from '@rocicorp/undo'; 11 | import {UndoRedo} from './undo-redo'; 12 | 13 | type NavProps = { 14 | rep: Replicache; 15 | undoManager: UndoManager; 16 | canUndoRedo: {canUndo: boolean; canRedo: boolean}; 17 | }; 18 | 19 | export function Nav({rep, undoManager, canUndoRedo}: NavProps) { 20 | const [aboutVisible, showAbout] = useState(false); 21 | const [shareVisible, showShare] = useState(false); 22 | const urlBox = useRef(null); 23 | const userInfo = useUserInfo(rep); 24 | 25 | useEffect(() => { 26 | if (shareVisible) { 27 | urlBox.current && urlBox.current.select(); 28 | } 29 | }); 30 | 31 | const onRectangle = () => { 32 | const shape = randomShape(); 33 | const execute = () => rep.mutate.createShape(shape); 34 | const undo = () => rep.mutate.deleteShape(shape.id); 35 | void undoManager.add({execute, undo}); 36 | }; 37 | 38 | return ( 39 | <> 40 |
41 |
onRectangle()} 43 | className={styles.button} 44 | title="Square" 45 | > 46 | 52 | 57 | 58 |
59 | undoManager.undo()} 61 | title="Undo" 62 | canUndoRedo={canUndoRedo} 63 | /> 64 | undoManager.redo()} 67 | title="Redo" 68 | canUndoRedo={canUndoRedo} 69 | /> 70 | {/* 71 |
rep.mutate.deleteAllShapes()} 75 | > 76 | 83 | 90 | 91 |
*/} 92 |
showShare(true)}> 93 | Share 94 |
95 |
showAbout(true)} 98 | > 99 | About this Demo 100 |
101 |
102 | {userInfo && ( 103 |
109 | {userInfo.avatar} {userInfo.name} 110 |
111 | )} 112 |
113 | showAbout(false)} centered> 114 | 115 | About Replidraw 116 | 117 | 118 |

119 | This is a demonstration of{' '} 120 | 121 | Replicache 122 | {' '} 123 | — a JavaScript library that enables realtime, collaborative web apps 124 | for any backend stack. 125 |

126 |

127 | Try{' '} 128 | 129 | opening this page 130 | {' '} 131 | in two browser windows and moving the boxes around. 132 |

133 |
134 | 135 | 142 | 149 | 150 |
151 | showShare(false)} centered> 152 | 153 | Share Drawing 154 | 155 | 156 |
157 | Copy this URL and send to anyone: 158 | 164 | 165 |
166 | 167 | 170 | 171 |
172 | 173 | ); 174 | } 175 | -------------------------------------------------------------------------------- /frontend/rand.ts: -------------------------------------------------------------------------------- 1 | export function randInt(min: number, max: number) { 2 | min = Math.ceil(min); 3 | max = Math.floor(max); 4 | return Math.floor(Math.random() * (max - min + 1) + min); //The maximum is inclusive and the minimum is inclusive 5 | } 6 | -------------------------------------------------------------------------------- /frontend/rect-controller.tsx: -------------------------------------------------------------------------------- 1 | import {Rect} from './rect'; 2 | import {DraggableCore, DraggableEvent, DraggableData} from 'react-draggable'; 3 | import {useShapeByID} from './subscriptions'; 4 | import type {Replicache} from 'replicache'; 5 | import type {M} from './mutators'; 6 | import type {UndoManager} from '@rocicorp/undo'; 7 | 8 | // TODO: In the future I imagine this becoming ShapeController and 9 | // there also be a Shape that wraps Rect and also knows how to draw Circle, etc. 10 | export function RectController({ 11 | rep, 12 | id, 13 | undoManager, 14 | }: { 15 | rep: Replicache; 16 | id: string; 17 | undoManager: UndoManager; 18 | }) { 19 | const shape = useShapeByID(rep, id); 20 | 21 | const onMouseEnter = async () => 22 | rep.mutate.overShape({clientID: await rep.clientID, shapeID: id}); 23 | const onMouseLeave = async () => 24 | rep.mutate.overShape({clientID: await rep.clientID, shapeID: ''}); 25 | 26 | const onDragStart = (_e: DraggableEvent, _d: DraggableData) => { 27 | // Can't mark onDragStart async because it changes return type and onDragStart 28 | // must return void. 29 | undoManager.startGroup(); 30 | void (async () => 31 | rep.mutate.selectShape({clientID: await rep.clientID, shapeID: id}))(); 32 | }; 33 | const onDrag = (e: DraggableEvent, d: DraggableData) => { 34 | // This is subtle, and worth drawing attention to: 35 | // In order to properly resolve conflicts, what we want to capture in 36 | // mutation arguments is the *intent* of the mutation, not the effect. 37 | // In this case, the intent is the amount the mouse was moved by, locally. 38 | // We will apply this movement to whatever the state happens to be when we 39 | // replay. If somebody else was moving the object at the same moment, we'll 40 | // then end up with a union of the two vectors, which is what we want! 41 | void rep.mutate.moveShape({ 42 | id, 43 | dx: d.deltaX, 44 | dy: d.deltaY, 45 | animate: true, 46 | }); 47 | void undoManager.add({ 48 | undo: () => 49 | rep.mutate.moveShape({ 50 | id, 51 | dx: -d.deltaX, 52 | dy: -d.deltaY, 53 | animate: false, 54 | }), 55 | redo: () => 56 | rep.mutate.moveShape({ 57 | id, 58 | dx: d.deltaX, 59 | dy: d.deltaY, 60 | animate: false, 61 | }), 62 | }); 63 | }; 64 | const onDragStop = (_e: DraggableEvent, _d: DraggableData) => { 65 | undoManager.endGroup(); 66 | }; 67 | 68 | if (!shape) { 69 | return null; 70 | } 71 | 72 | return ( 73 | 74 |
75 | 84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /frontend/rect.tsx: -------------------------------------------------------------------------------- 1 | import React, {MouseEventHandler, TouchEventHandler} from 'react'; 2 | import type {Replicache} from 'replicache'; 3 | import type {M} from './mutators'; 4 | import {useShape} from './smoothie'; 5 | import {useShapeByID} from './subscriptions'; 6 | 7 | export function Rect({ 8 | rep, 9 | id, 10 | highlight = false, 11 | highlightColor = 'rgb(74,158,255)', 12 | onMouseDown, 13 | onTouchStart, 14 | onMouseEnter, 15 | onMouseLeave, 16 | }: { 17 | rep: Replicache; 18 | id: string; 19 | highlight?: boolean | undefined; 20 | highlightColor?: string | undefined; 21 | onMouseDown?: MouseEventHandler | undefined; 22 | onTouchStart?: TouchEventHandler | undefined; 23 | onMouseEnter?: MouseEventHandler | undefined; 24 | onMouseLeave?: MouseEventHandler | undefined; 25 | }) { 26 | const shape = useShapeByID(rep, id); 27 | const coords = useShape(rep, id); 28 | if (!shape || !coords) { 29 | return null; 30 | } 31 | 32 | const {x, y, w, h, r} = coords; 33 | const enableEvents = 34 | onMouseDown || onTouchStart || onMouseEnter || onMouseLeave; 35 | 36 | return ( 37 | // @ts-expect-error @types/react-dom is too old to be correct 38 | 55 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /frontend/selection.tsx: -------------------------------------------------------------------------------- 1 | import {Rect} from './rect'; 2 | import {useShape} from './smoothie'; 3 | import {DraggableCore, DraggableEvent, DraggableData} from 'react-draggable'; 4 | import type {Replicache} from 'replicache'; 5 | import type {M} from './mutators'; 6 | import type {UndoManager} from '@rocicorp/undo'; 7 | 8 | export function Selection({ 9 | rep, 10 | id, 11 | containerOffsetTop, 12 | undoManager, 13 | }: { 14 | rep: Replicache; 15 | id: string; 16 | containerOffsetTop: number | null; 17 | undoManager: UndoManager; 18 | }) { 19 | const coords = useShape(rep, id); 20 | const gripSize = 19; 21 | 22 | const center = (coords: NonNullable>) => { 23 | return { 24 | x: coords.x + coords.w / 2, 25 | y: coords.y + coords.h / 2, 26 | }; 27 | }; 28 | 29 | const onResizeStart = (_e: DraggableEvent, _d: DraggableData) => { 30 | undoManager.startGroup(); 31 | }; 32 | 33 | const onResizeEnd = (_e: DraggableEvent, _d: DraggableData) => { 34 | undoManager.endGroup(); 35 | }; 36 | 37 | const onResize = (e: DraggableEvent, d: DraggableData) => { 38 | if (!coords) { 39 | return; 40 | } 41 | 42 | const shapeCenter = center(coords); 43 | 44 | const size = (x1: number, x2: number, y1: number, y2: number) => { 45 | const distanceSqFromCenterToCursor = 46 | Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2); 47 | return Math.sqrt(distanceSqFromCenterToCursor / 2) * 2; 48 | }; 49 | 50 | const s0 = size( 51 | shapeCenter.x, 52 | d.x - d.deltaX, 53 | shapeCenter.y, 54 | d.y - d.deltaY, 55 | ); 56 | const s1 = size(shapeCenter.x, d.x, shapeCenter.y, d.y); 57 | 58 | void rep.mutate.resizeShape({id, ds: s1 - s0}); 59 | void undoManager.add({ 60 | redo: () => rep.mutate.resizeShape({id, ds: s1 - s0, animate: false}), 61 | undo: () => rep.mutate.resizeShape({id, ds: s0 - s1, animate: false}), 62 | }); 63 | }; 64 | 65 | const onRotateStart = (_e: DraggableEvent, _d: DraggableData) => { 66 | undoManager.startGroup(); 67 | }; 68 | 69 | const onRotateEnd = (_e: DraggableEvent, _d: DraggableData) => { 70 | undoManager.endGroup(); 71 | }; 72 | 73 | const onRotate = (e: DraggableEvent, d: DraggableData) => { 74 | if (!coords || containerOffsetTop === null) { 75 | return; 76 | } 77 | 78 | const offsetY = d.y - containerOffsetTop; 79 | 80 | const shapeCenter = center(coords); 81 | const before = Math.atan2( 82 | offsetY - d.deltaY - shapeCenter.y, 83 | d.x - d.deltaX - shapeCenter.x, 84 | ); 85 | const after = Math.atan2(offsetY - shapeCenter.y, d.x - shapeCenter.x); 86 | const ddeg = ((after - before) * 180) / Math.PI; 87 | void rep.mutate.rotateShape({ 88 | id, 89 | ddeg, 90 | }); 91 | 92 | void undoManager.add({ 93 | redo: () => 94 | rep.mutate.rotateShape({ 95 | id, 96 | ddeg, 97 | animate: false, 98 | }), 99 | undo: () => 100 | rep.mutate.rotateShape({ 101 | id, 102 | ddeg: -ddeg, 103 | animate: false, 104 | }), 105 | }); 106 | }; 107 | 108 | if (!coords) { 109 | return null; 110 | } 111 | 112 | const {x, y, w, h, r} = coords; 113 | 114 | return ( 115 |
116 | 123 |
132 | 137 | 149 | 156 | 157 | 158 | 163 | 175 | 184 | 185 | 186 |
187 |
188 | ); 189 | } 190 | -------------------------------------------------------------------------------- /frontend/shape.ts: -------------------------------------------------------------------------------- 1 | import type {ReadTransaction, WriteTransaction} from 'replicache'; 2 | import {z} from 'zod'; 3 | 4 | import {nanoid} from 'nanoid'; 5 | import {randInt} from './rand'; 6 | 7 | export const shapePrefix = `shape-`; 8 | 9 | export const shapeKey = (id: string) => `${shapePrefix}${id}`; 10 | 11 | export const shapeID = (key: string) => { 12 | if (!key.startsWith(shapePrefix)) { 13 | throw new Error(`Invalid key: ${key}`); 14 | } 15 | return key.substring(shapePrefix.length); 16 | }; 17 | 18 | export const shapeSchema = z.object({ 19 | id: z.string(), 20 | type: z.literal('rect'), 21 | x: z.number(), 22 | y: z.number(), 23 | width: z.number(), 24 | height: z.number(), 25 | rotate: z.number(), 26 | fill: z.string(), 27 | animate: z.boolean(), 28 | }); 29 | 30 | export type Shape = Readonly>; 31 | 32 | const shapeValueSchema = shapeSchema.omit({id: true}); 33 | 34 | export async function getShape( 35 | tx: ReadTransaction, 36 | id: string, 37 | ): Promise { 38 | const val = await tx.get(shapeKey(id)); 39 | if (val === undefined) { 40 | console.log(`Specified shape ${id} not found.`); 41 | return undefined; 42 | } 43 | return { 44 | id, 45 | ...shapeValueSchema.parse(val), 46 | }; 47 | } 48 | 49 | export async function putShape( 50 | tx: WriteTransaction, 51 | shape: Shape, 52 | ): Promise { 53 | await tx.put(shapeKey(shape.id), shape); 54 | } 55 | 56 | export async function deleteShape( 57 | tx: WriteTransaction, 58 | id: string, 59 | ): Promise { 60 | await tx.del(shapeKey(id)); 61 | } 62 | 63 | export async function moveShape( 64 | tx: WriteTransaction, 65 | { 66 | id, 67 | dx, 68 | dy, 69 | animate = true, 70 | }: {id: string; dx: number; dy: number; animate?: boolean}, 71 | ): Promise { 72 | const shape = await getShape(tx, id); 73 | if (shape) { 74 | await putShape(tx, { 75 | ...shape, 76 | x: shape.x + dx, 77 | y: shape.y + dy, 78 | animate, 79 | }); 80 | } 81 | } 82 | 83 | export async function resizeShape( 84 | tx: WriteTransaction, 85 | {id, ds, animate = true}: {id: string; ds: number; animate?: boolean}, 86 | ): Promise { 87 | const shape = await getShape(tx, id); 88 | if (shape) { 89 | const minSize = 10; 90 | const dw = Math.max(minSize - shape.width, ds); 91 | const dh = Math.max(minSize - shape.height, ds); 92 | await putShape(tx, { 93 | ...shape, 94 | width: shape.width + dw, 95 | height: shape.height + dh, 96 | x: shape.x - dw / 2, 97 | y: shape.y - dh / 2, 98 | animate, 99 | }); 100 | } 101 | } 102 | 103 | export async function rotateShape( 104 | tx: WriteTransaction, 105 | {id, ddeg, animate = true}: {id: string; ddeg: number; animate?: boolean}, 106 | ): Promise { 107 | const shape = await getShape(tx, id); 108 | if (shape) { 109 | await putShape(tx, {...shape, rotate: shape.rotate + ddeg, animate}); 110 | } 111 | } 112 | 113 | export async function initShapes(tx: WriteTransaction, shapes: Shape[]) { 114 | if (await tx.has('initialized')) { 115 | return; 116 | } 117 | await Promise.all([ 118 | tx.put('initialized', true), 119 | ...shapes.map(s => putShape(tx, s)), 120 | ]); 121 | } 122 | 123 | const colors = ['red', 'blue', 'white', 'green', 'yellow']; 124 | let nextColor = 0; 125 | 126 | export function randomShape() { 127 | const s = randInt(100, 400); 128 | const fill = colors[nextColor++]; 129 | if (nextColor === colors.length) { 130 | nextColor = 0; 131 | } 132 | return { 133 | id: nanoid(), 134 | type: 'rect', 135 | x: randInt(0, 400), 136 | y: randInt(0, 400), 137 | width: s, 138 | height: s, 139 | rotate: randInt(0, 359), 140 | fill, 141 | animate: false, 142 | } as Shape; 143 | } 144 | -------------------------------------------------------------------------------- /frontend/smoothie.ts: -------------------------------------------------------------------------------- 1 | import hermite from 'cubic-hermite'; 2 | import {useEffect, useState} from 'react'; 3 | import type {Replicache, ReadTransaction} from 'replicache'; 4 | import {getClientState} from './client-state'; 5 | import {getShape} from './shape'; 6 | 7 | /** 8 | * Gets the current position of the cursor for `clientID`, but smoothing out 9 | * the motion by interpolating extra frames. 10 | */ 11 | export function useCursor( 12 | rep: Replicache, 13 | clientID: string, 14 | ): {x: number; y: number} | null { 15 | const [values, setValues] = useState | null>(null); 16 | const smoothie = Smoothie.get( 17 | rep, 18 | `cursor/${clientID}`, 19 | async (tx: ReadTransaction) => { 20 | const clientState = await getClientState(tx, clientID); 21 | return { 22 | animate: true, 23 | values: [clientState.cursor.x, clientState.cursor.y], 24 | }; 25 | }, 26 | ); 27 | useListener(smoothie, setValues, clientID); 28 | if (!values) { 29 | return null; 30 | } 31 | const [x, y] = values; 32 | return {x, y}; 33 | } 34 | 35 | /** 36 | * Gets the current position of the shape for `shapeID`, but smoothing out 37 | * the motion by interpolating extra frames. 38 | */ 39 | export function useShape(rep: Replicache, shapeID: string) { 40 | const [values, setValues] = useState | null>(null); 41 | const smoother = Smoothie.get( 42 | rep, 43 | `shape/${shapeID}`, 44 | async (tx: ReadTransaction) => { 45 | const shape = await getShape(tx, shapeID); 46 | return shape 47 | ? { 48 | animate: shape.animate, 49 | values: [shape.x, shape.y, shape.width, shape.height, shape.rotate], 50 | } 51 | : null; 52 | }, 53 | ); 54 | useListener(smoother, setValues, shapeID); 55 | if (!values) { 56 | return null; 57 | } 58 | const [x, y, w, h, r] = values; 59 | return {x, y, w, h, r}; 60 | } 61 | 62 | /** 63 | * Tracks progress of an animation smoothing jumps between one or more 64 | * numeric properties. 65 | */ 66 | type Animation = { 67 | startValues: Array; 68 | targetValues: Array; 69 | startVelocities: Array; 70 | targetVelocities: Array; 71 | startTime: number; 72 | duration: number; 73 | currentValues: Array; 74 | timerID: number; 75 | }; 76 | 77 | type Listener = (current: Array | null) => void; 78 | type SubscriptionFunction = ( 79 | tx: ReadTransaction, 80 | ) => Promise<{animate: boolean; values: Array} | null>; 81 | 82 | const minAnimationDuration = 50; 83 | const maxAnimationDuration = 5000; 84 | 85 | /** 86 | * Smoothie interpolates frames between Replicache subscription notifications. 87 | * 88 | * We cannot simply animate at the UI layer, because we need multiple UI 89 | * elements that appear to be together (e.g., the selection highlight for 90 | * a shape and the shape itself) to animate in lockstep. The UI lacks 91 | * sufficient information to synchronize this way. 92 | * 93 | * We use hermite splines to smooth out the frames that we get because they 94 | * are easy to use and create a more appealing curve than simply chaining 95 | * tweens between frames. 96 | */ 97 | class Smoothie { 98 | private static _instances = new Map(); 99 | 100 | /** 101 | * Gets the specified named instance 102 | * @param rep Replicache instance to query 103 | * @param key Unique name for the data to extract / smooth 104 | * @param sub A subscription function, of the type that would be passed to 105 | * Replicache.subscribe(). The return value must be an Array of numbers. 106 | * These are the values we will smooth over time. 107 | * @returns 108 | */ 109 | static get( 110 | rep: Replicache, 111 | key: string, 112 | sub: SubscriptionFunction, 113 | ): Smoothie { 114 | let s = this._instances.get(key); 115 | if (!s) { 116 | s = new Smoothie(rep, sub); 117 | this._instances.set(key, s); 118 | } 119 | return s; 120 | } 121 | 122 | private _rep: Replicache; 123 | 124 | // The target values we're currently animating to. 125 | private _latestTargetsValues: Array | null = null; 126 | 127 | // The latest time the latestTargets changed. 128 | private _latestTimestamp = 0; 129 | 130 | // The current animation we're running. Only non-null when one is 131 | // actually running. 132 | private _currentAnimation: Animation | null = null; 133 | 134 | // Current listeners. 135 | private _listeners = new Set(); 136 | 137 | private constructor(rep: Replicache, sub: SubscriptionFunction) { 138 | this._rep = rep; 139 | this._rep.subscribe(sub, { 140 | onData: targets => { 141 | const now = performance.now(); 142 | 143 | // We can flip back to null, for example if the object we are watching 144 | // gets deleted. So we must handle that and count it as achange. 145 | if (targets === null) { 146 | this.jumpTo(null, now); 147 | return; 148 | } 149 | 150 | if (this._latestTargetsValues === null) { 151 | this.jumpTo(targets.values, now); 152 | return; 153 | } 154 | 155 | if (!shallowEqual(targets.values, this._latestTargetsValues)) { 156 | if (targets.values.length !== this._latestTargetsValues.length) { 157 | console.info('Number of targets changed - ignoring'); 158 | return; 159 | } 160 | 161 | const duration = now - this._latestTimestamp; 162 | if (duration < minAnimationDuration || targets.animate === false) { 163 | // If the time since last frame is very short, it looks better to 164 | // skip the animation. This mainly happens with frames generated 165 | // locally. 166 | this.jumpTo(targets.values, now); 167 | } else if (!this._currentAnimation) { 168 | // Otherwise if there's no current animation running, start one. 169 | this._currentAnimation = { 170 | startValues: this._latestTargetsValues, 171 | targetValues: targets.values, 172 | startVelocities: targets.values.map(_ => 0), 173 | targetVelocities: targets.values.map(_ => 0), 174 | startTime: now, 175 | duration: this.frameDuration(now), 176 | currentValues: this._latestTargetsValues, 177 | timerID: this.scheduleAnimate(), 178 | }; 179 | } else { 180 | // Otherwise, cancel the existing animation and start a new one. 181 | cancelAnimationFrame(this._currentAnimation.timerID); 182 | 183 | const t = 184 | (now - this._currentAnimation.startTime) / 185 | this._currentAnimation.duration; 186 | 187 | // Get the current velocities. These will be the initial 188 | // velocities for the new animation. 189 | const startVelocities = hermite.derivative( 190 | this._currentAnimation.startValues, 191 | this._currentAnimation.startVelocities, 192 | this._currentAnimation.targetValues, 193 | this._currentAnimation.targetVelocities, 194 | Math.max(0, Math.min(t, 1)), 195 | ); 196 | this._currentAnimation = { 197 | startValues: this._currentAnimation.currentValues, 198 | targetValues: targets.values, 199 | startVelocities, 200 | targetVelocities: targets.values.map(_ => 0), 201 | startTime: now, 202 | duration: this.frameDuration(now), 203 | currentValues: this._currentAnimation.currentValues, 204 | timerID: this.scheduleAnimate(), 205 | }; 206 | } 207 | this._latestTargetsValues = targets.values; 208 | this._latestTimestamp = now; 209 | } 210 | }, 211 | }); 212 | } 213 | 214 | jumpTo(targets: Array | null, now: number) { 215 | this._currentAnimation && 216 | cancelAnimationFrame(this._currentAnimation.timerID); 217 | this._currentAnimation = null; 218 | this._latestTargetsValues = targets; 219 | this._latestTimestamp = now; 220 | this.fire(); 221 | } 222 | 223 | scheduleAnimate() { 224 | return requestAnimationFrame(() => { 225 | if (!this._currentAnimation) { 226 | return; 227 | } 228 | 229 | // Update the current animated values. 230 | const t = 231 | (performance.now() - this._currentAnimation.startTime) / 232 | this._currentAnimation.duration; 233 | this._currentAnimation = { 234 | ...this._currentAnimation, 235 | currentValues: hermite( 236 | this._currentAnimation.startValues, 237 | this._currentAnimation.startVelocities, 238 | this._currentAnimation.targetValues, 239 | this._currentAnimation.targetVelocities, 240 | Math.min(1, Math.max(0, t)), 241 | ), 242 | }; 243 | this.fire(); 244 | if (t >= 1) { 245 | // If we're done, clear the animation. 246 | this._currentAnimation = null; 247 | return; 248 | } 249 | // Otherwise, schedule the next frame. 250 | this._currentAnimation.timerID === this.scheduleAnimate(); 251 | }); 252 | } 253 | 254 | private _getCurrentValues(): Array | null { 255 | if (this._currentAnimation) { 256 | return this._currentAnimation.currentValues; 257 | } else if (this._latestTargetsValues) { 258 | return this._latestTargetsValues; 259 | } 260 | return null; 261 | } 262 | 263 | addListener(l: Listener) { 264 | this._listeners.add(l); 265 | const c = this._getCurrentValues(); 266 | if (c) { 267 | l(c); 268 | } 269 | } 270 | 271 | removeListener(l: Listener) { 272 | this._listeners.delete(l); 273 | } 274 | 275 | fire() { 276 | const c = this._getCurrentValues(); 277 | this._listeners.forEach(l => { 278 | try { 279 | l(c); 280 | } catch (e) { 281 | console.error(e); 282 | } 283 | }); 284 | } 285 | 286 | frameDuration(now: number) { 287 | return Math.min( 288 | maxAnimationDuration, 289 | // We can't simply use the delay since the last frame as the 290 | // duration for the animation because we want the animation to smoothly 291 | // slow down and stop once we stop receiving events. But if we're receiving 292 | // frames approximately every Fms, and we set the duration of each frame's 293 | // animation to be Fms, then we will see a choppy movement when these 294 | // animations are connected one to the next. 295 | // 296 | // Also we sometimes get frames in which no movement occurs. 297 | // This is because push can take longer than pull, so we 298 | // might have pushes happening at a rate of 150ms/frame, and pulls 299 | // happening at a rate of 100ms/frame. So every third frame or so 300 | // we'd get no new position information. In that case, if the frame duration 301 | // is close to the rate pulls are happening, we'll see the 302 | // animation slow down then speed up again. 303 | // 304 | // Instead, we arbitrarily assume that there are n additional frames 305 | // coming after this one. This has the effect of smoothing out the 306 | // animation at the cost of extending the animation n frames longer than it 307 | // actually took on the source machine. 308 | (now - this._latestTimestamp) * 3, 309 | ); 310 | } 311 | } 312 | 313 | function useListener( 314 | smoother: Smoothie, 315 | listener: (values: Array | null) => void, 316 | dep: string, 317 | ) { 318 | useEffect(() => { 319 | smoother.addListener(listener); 320 | return () => smoother.removeListener(listener); 321 | }, [dep]); 322 | } 323 | 324 | function shallowEqual(a1: unknown[], a2: unknown[]) { 325 | if (a1.length !== a2.length) { 326 | return false; 327 | } 328 | if (a1.some((v1, idx) => v1 !== a2[idx])) { 329 | return false; 330 | } 331 | return true; 332 | } 333 | -------------------------------------------------------------------------------- /frontend/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import {useSubscribe} from 'replicache-react'; 2 | import {getClientState, clientStatePrefix} from './client-state'; 3 | import {getShape, shapePrefix} from './shape'; 4 | import type {Replicache} from 'replicache'; 5 | import type {M} from './mutators.js'; 6 | 7 | export function useShapeIDs(rep: Replicache) { 8 | return useSubscribe( 9 | rep, 10 | async tx => { 11 | const shapes = await tx.scan({prefix: shapePrefix}).keys().toArray(); 12 | return shapes.map(k => k.split('-', 2)[1]); 13 | }, 14 | [], 15 | ); 16 | } 17 | 18 | export function useShapeByID(rep: Replicache, id: string) { 19 | return useSubscribe( 20 | rep, 21 | async tx => { 22 | return (await getShape(tx, id)) ?? null; 23 | }, 24 | null, 25 | ); 26 | } 27 | 28 | export function useUserInfo(rep: Replicache) { 29 | return useSubscribe( 30 | rep, 31 | async tx => { 32 | return (await getClientState(tx, await rep.clientID)).userInfo; 33 | }, 34 | null, 35 | ); 36 | } 37 | 38 | export function useOverShapeID(rep: Replicache) { 39 | return useSubscribe( 40 | rep, 41 | async tx => { 42 | return (await getClientState(tx, await rep.clientID)).overID; 43 | }, 44 | '', 45 | ); 46 | } 47 | 48 | export function useSelectedShapeID(rep: Replicache) { 49 | return useSubscribe( 50 | rep, 51 | async tx => { 52 | return (await getClientState(tx, await rep.clientID)).selectedID; 53 | }, 54 | '', 55 | ); 56 | } 57 | 58 | export function useCollaboratorIDs(rep: Replicache) { 59 | return useSubscribe( 60 | rep, 61 | async tx => { 62 | const clientIDs = await tx 63 | .scan({prefix: clientStatePrefix}) 64 | .keys() 65 | .toArray(); 66 | const myClientID = await rep.clientID; 67 | return clientIDs 68 | .filter(k => !k.endsWith(myClientID)) 69 | .map(k => k.substr(clientStatePrefix.length)); 70 | }, 71 | [], 72 | ); 73 | } 74 | 75 | export function useClientInfo(rep: Replicache, clientID: string) { 76 | return useSubscribe( 77 | rep, 78 | async tx => { 79 | return await getClientState(tx, clientID); 80 | }, 81 | null, 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /frontend/undo-redo.tsx: -------------------------------------------------------------------------------- 1 | import styles from './nav.module.css'; 2 | type UndoRedoProps = { 3 | onClick: () => void; 4 | title: string; 5 | isRedo?: boolean; 6 | canUndoRedo: {canUndo: boolean; canRedo: boolean}; 7 | }; 8 | 9 | export function UndoRedo({ 10 | onClick, 11 | title, 12 | isRedo = false, 13 | canUndoRedo, 14 | }: UndoRedoProps) { 15 | return ( 16 |
22 | 25 |
26 | ); 27 | } 28 | 29 | type UndoRedoSvgProps = { 30 | isDisabled: boolean; 31 | }; 32 | 33 | function UndoRedoSvg({isDisabled}: UndoRedoSvgProps): JSX.Element { 34 | return ( 35 | 42 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | eslint: { 3 | // Warning: This allows production builds to successfully complete even if 4 | // your project has ESLint errors. 5 | ignoreDuringBuilds: true, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "replidraw", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start -p $PORT", 9 | "format": "prettier --write './**/*.{js,jsx,json,ts,tsx,html,css,md}'", 10 | "check-format": "prettier --check './**/*.{js,jsx,json,ts,tsx,html,css,md}'", 11 | "check-types": "tsc --noEmit", 12 | "lint": "eslint --ext .ts,.tsx,.js,.jsx pages backend frontend util", 13 | "test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register 'backend/**/*.test.ts'" 14 | }, 15 | "dependencies": { 16 | "@rocicorp/undo": "^0.1.0", 17 | "bootstrap": "^4.6.1", 18 | "cubic-hermite": "^1.0.0", 19 | "nanoid": "^3.3.1", 20 | "next": "^12.1.6", 21 | "pg": "^8.7.3", 22 | "pusher": "^4.0.2", 23 | "pusher-js": "^7.0.3", 24 | "react": "17.0.2", 25 | "react-bootstrap": "^2.3.1", 26 | "react-dom": "17.0.2", 27 | "react-draggable": "^4.4.5", 28 | "react-hotkeys": "^1.1.4", 29 | "replicache": "^12.2.0", 30 | "replicache-react": "^2.10.0", 31 | "replicache-transaction": "^0.2.1", 32 | "zod": "^3.13.4" 33 | }, 34 | "devDependencies": { 35 | "@rocicorp/eslint-config": "^0.2.0", 36 | "@rocicorp/prettier-config": "^0.1.1", 37 | "@types/chai": "^4.3.0", 38 | "@types/mocha": "^9.1.0", 39 | "@types/node": "^14.14.37", 40 | "@types/pg": "^8.6.4", 41 | "@types/react": "^17.0.11", 42 | "chai": "^4.3.6", 43 | "mocha": "^9.2.1", 44 | "ts-node": "^10.7.0", 45 | "typescript": "4.8" 46 | }, 47 | "eslintConfig": { 48 | "extends": "@rocicorp/eslint-config" 49 | }, 50 | "prettier": "@rocicorp/prettier-config" 51 | } 52 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import 'bootstrap/dist/css/bootstrap.min.css'; 3 | 4 | function MyApp({Component, pageProps}) { 5 | return ; 6 | } 7 | 8 | export default MyApp; 9 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, {Html, Head, Main, NextScript} from 'next/document'; 2 | 3 | class MyDocument extends Document { 4 | static async getInitialProps(ctx) { 5 | const initialProps = await Document.getInitialProps(ctx); 6 | return {...initialProps}; 7 | } 8 | 9 | render() { 10 | return ( 11 | 12 | 13 | 14 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | ); 26 | } 27 | } 28 | 29 | export default MyDocument; 30 | -------------------------------------------------------------------------------- /pages/api/echo.ts: -------------------------------------------------------------------------------- 1 | import type {NextApiRequest, NextApiResponse} from 'next'; 2 | 3 | // Just here to test RTT to Next.js. 4 | // eslint-disable-next-line require-await 5 | export default async (req: NextApiRequest, res: NextApiResponse) => { 6 | res.send('hello, world'); 7 | res.end(); 8 | }; 9 | -------------------------------------------------------------------------------- /pages/api/replicache-pull.ts: -------------------------------------------------------------------------------- 1 | import type {NextApiRequest, NextApiResponse} from 'next'; 2 | import {transact} from '../../backend/pg'; 3 | import { 4 | createDatabase, 5 | getChangedEntries, 6 | getCookie, 7 | getLastMutationID, 8 | } from '../../backend/data'; 9 | import {z} from 'zod'; 10 | import type {PullResponse} from 'replicache'; 11 | 12 | const pullRequest = z.object({ 13 | clientID: z.string(), 14 | cookie: z.union([z.number(), z.null()]), 15 | }); 16 | 17 | export default async (req: NextApiRequest, res: NextApiResponse) => { 18 | console.log(`Processing pull`, JSON.stringify(req.body, null, '')); 19 | if (!req.query['spaceID']) { 20 | res.status(400).send('Missing spaceID'); 21 | res.end(); 22 | return; 23 | } 24 | const t0 = Date.now(); 25 | const spaceID = req.query['spaceID'].toString(); 26 | const pull = pullRequest.parse(req.body); 27 | const requestCookie = pull.cookie; 28 | 29 | console.log('spaceID', spaceID); 30 | console.log('clientID', pull.clientID); 31 | 32 | const [entries, lastMutationID, responseCookie] = await transact( 33 | async executor => { 34 | await createDatabase(executor); 35 | 36 | return Promise.all([ 37 | getChangedEntries(executor, spaceID, requestCookie ?? 0), 38 | getLastMutationID(executor, pull.clientID), 39 | getCookie(executor, spaceID), 40 | ]); 41 | }, 42 | ); 43 | 44 | console.log('lastMutationID: ', lastMutationID); 45 | console.log('responseCookie: ', responseCookie); 46 | console.log('Read all objects in', Date.now() - t0); 47 | 48 | const resp: PullResponse = { 49 | lastMutationID: lastMutationID ?? 0, 50 | cookie: responseCookie ?? 0, 51 | patch: [], 52 | }; 53 | 54 | for (const [key, value, deleted] of entries) { 55 | if (deleted) { 56 | resp.patch.push({ 57 | op: 'del', 58 | key, 59 | }); 60 | } else { 61 | resp.patch.push({ 62 | op: 'put', 63 | key, 64 | value, 65 | }); 66 | } 67 | } 68 | 69 | res.json(resp); 70 | res.end(); 71 | console.log('Processing pull took', Date.now() - t0); 72 | }; 73 | -------------------------------------------------------------------------------- /pages/api/replicache-push.ts: -------------------------------------------------------------------------------- 1 | import {transact} from '../../backend/pg'; 2 | import { 3 | createDatabase, 4 | getCookie, 5 | getLastMutationID, 6 | setCookie, 7 | setLastMutationID, 8 | } from '../../backend/data'; 9 | import type {NextApiRequest, NextApiResponse} from 'next'; 10 | import {ReplicacheTransaction} from 'replicache-transaction'; 11 | import {mutators} from '../../frontend/mutators'; 12 | import {z} from 'zod'; 13 | import {jsonSchema} from '../../util/json'; 14 | import Pusher from 'pusher'; 15 | import type {MutatorDefs} from 'replicache'; 16 | import {PostgresStorage} from '../../backend/postgres-storage'; 17 | 18 | // TODO: Either generate schema from mutator types, or vice versa, to tighten this. 19 | // See notes in bug: https://github.com/rocicorp/replidraw/issues/47 20 | const mutationSchema = z.object({ 21 | id: z.number(), 22 | name: z.string(), 23 | args: jsonSchema, 24 | }); 25 | 26 | const pushRequestSchema = z.object({ 27 | clientID: z.string(), 28 | mutations: z.array(mutationSchema), 29 | }); 30 | 31 | export default async (req: NextApiRequest, res: NextApiResponse) => { 32 | console.log('Processing push', JSON.stringify(req.body, null, '')); 33 | if (!req.query['spaceID']) { 34 | res.status(400).send('Missing spaceID'); 35 | res.end(); 36 | return; 37 | } 38 | const t0 = Date.now(); 39 | 40 | const spaceID = req.query['spaceID'].toString(); 41 | const push = pushRequestSchema.parse(req.body); 42 | 43 | await transact(async executor => { 44 | await createDatabase(executor); 45 | 46 | const prevVersion = (await getCookie(executor, spaceID)) ?? 0; 47 | const nextVersion = prevVersion + 1; 48 | let lastMutationID = 49 | (await getLastMutationID(executor, push.clientID)) ?? 0; 50 | 51 | console.log('prevVersion: ', prevVersion); 52 | console.log('lastMutationID:', lastMutationID); 53 | 54 | const storage = new PostgresStorage(spaceID, nextVersion, executor); 55 | const tx = new ReplicacheTransaction(storage, push.clientID); 56 | 57 | for (let i = 0; i < push.mutations.length; i++) { 58 | const mutation = push.mutations[i]; 59 | const expectedMutationID = lastMutationID + 1; 60 | 61 | if (mutation.id < expectedMutationID) { 62 | console.log( 63 | `Mutation ${mutation.id} has already been processed - skipping`, 64 | ); 65 | continue; 66 | } 67 | if (mutation.id > expectedMutationID) { 68 | console.warn(`Mutation ${mutation.id} is from the future - aborting`); 69 | break; 70 | } 71 | 72 | console.log('Processing mutation:', JSON.stringify(mutation, null, '')); 73 | 74 | const t1 = Date.now(); 75 | const mutator = (mutators as MutatorDefs)[mutation.name]; 76 | if (!mutator) { 77 | console.error(`Unknown mutator: ${mutation.name} - skipping`); 78 | } 79 | 80 | try { 81 | await mutator(tx, mutation.args); 82 | } catch (e) { 83 | console.error( 84 | `Error executing mutator: ${JSON.stringify(mutator)}: ${e}`, 85 | ); 86 | } 87 | 88 | lastMutationID = expectedMutationID; 89 | console.log('Processed mutation in', Date.now() - t1); 90 | } 91 | 92 | await Promise.all([ 93 | setLastMutationID(executor, push.clientID, lastMutationID), 94 | setCookie(executor, spaceID, nextVersion), 95 | tx.flush(), 96 | ]); 97 | }); 98 | 99 | console.log('Processed all mutations in', Date.now() - t0); 100 | 101 | if ( 102 | process.env.NEXT_PUBLIC_PUSHER_APP_ID && 103 | process.env.NEXT_PUBLIC_PUSHER_KEY && 104 | process.env.NEXT_PUBLIC_PUSHER_SECRET && 105 | process.env.NEXT_PUBLIC_PUSHER_CLUSTER 106 | ) { 107 | const startPoke = Date.now(); 108 | 109 | const pusher = new Pusher({ 110 | appId: process.env.NEXT_PUBLIC_PUSHER_APP_ID, 111 | key: process.env.NEXT_PUBLIC_PUSHER_KEY, 112 | secret: process.env.NEXT_PUBLIC_PUSHER_SECRET, 113 | cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER, 114 | useTLS: true, 115 | }); 116 | 117 | await pusher.trigger('default', 'poke', {}); 118 | console.log('Poke took', Date.now() - startPoke); 119 | } else { 120 | console.log('Not poking because Pusher is not configured'); 121 | } 122 | 123 | res.status(200).json({}); 124 | console.log('Processing push took', Date.now() - t0); 125 | }; 126 | -------------------------------------------------------------------------------- /pages/d/[id].tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from 'react'; 2 | import {Replicache} from 'replicache'; 3 | import {Designer} from '../../frontend/designer'; 4 | import {Nav} from '../../frontend/nav'; 5 | import Pusher from 'pusher-js'; 6 | import {M, mutators} from '../../frontend/mutators'; 7 | import {randUserInfo} from '../../frontend/client-state'; 8 | import {randomShape} from '../../frontend/shape'; 9 | import {UndoManager} from '@rocicorp/undo'; 10 | import {useRouter} from 'next/router'; 11 | 12 | export default function Home() { 13 | const [rep, setRep] = useState | null>(null); 14 | const [undoManager, setUndoManager] = useState(null); 15 | const [canUndoRedo, setCanUndoRedo] = useState({ 16 | canUndo: false, 17 | canRedo: false, 18 | }); 19 | const router = useRouter(); 20 | const {hideNav} = router.query; 21 | 22 | // TODO: Think through Replicache + SSR. 23 | useEffect(() => { 24 | (async () => { 25 | if (rep) { 26 | return; 27 | } 28 | 29 | const [, , docID] = location.pathname.split('/'); 30 | const r = new Replicache({ 31 | // To get your own license key run `npx replicache get-license`. (It's free.) 32 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 33 | licenseKey: process.env.NEXT_PUBLIC_REPLICACHE_LICENSE_KEY!, 34 | pushURL: `/api/replicache-push?spaceID=${docID}`, 35 | pullURL: `/api/replicache-pull?spaceID=${docID}`, 36 | name: docID, 37 | mutators, 38 | }); 39 | 40 | const defaultUserInfo = randUserInfo(); 41 | await r.mutate.initClientState({ 42 | id: await r.clientID, 43 | defaultUserInfo, 44 | }); 45 | r.onSync = (syncing: boolean) => { 46 | if (!syncing) { 47 | r.onSync = null; 48 | void r.mutate.initShapes( 49 | Array.from({length: 5}, () => randomShape()), 50 | ); 51 | } 52 | }; 53 | 54 | if ( 55 | process.env.NEXT_PUBLIC_PUSHER_KEY && 56 | process.env.NEXT_PUBLIC_PUSHER_CLUSTER 57 | ) { 58 | Pusher.logToConsole = true; 59 | const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY, { 60 | cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER, 61 | }); 62 | 63 | const channel = pusher.subscribe('default'); 64 | channel.bind('poke', () => { 65 | r.pull(); 66 | }); 67 | } 68 | setUndoManager( 69 | new UndoManager({ 70 | onChange: setCanUndoRedo, 71 | }), 72 | ); 73 | setRep(r); 74 | })().catch(e => { 75 | console.error(e); 76 | }); 77 | }, []); 78 | 79 | if (!rep || !undoManager) { 80 | return null; 81 | } 82 | 83 | return ( 84 |
96 | {!hideNav && ( 97 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import {nanoid} from 'nanoid'; 2 | 3 | function Page() { 4 | return ''; 5 | } 6 | 7 | export function getServerSideProps() { 8 | return { 9 | redirect: { 10 | destination: `/d/${nanoid(6)}`, 11 | permanent: false, 12 | }, 13 | }; 14 | } 15 | 16 | export default Page; 17 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | user-select: none; 8 | overflow: hidden; 9 | width: 100%; 10 | height: 100%; 11 | } 12 | 13 | a { 14 | color: inherit; 15 | text-decoration: none; 16 | } 17 | 18 | * { 19 | box-sizing: border-box; 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "exactOptionalPropertyTypes": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "importsNotUsedAsValues": "error" 19 | }, 20 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /util/json.ts: -------------------------------------------------------------------------------- 1 | import {z} from 'zod'; 2 | 3 | // From https://github.com/colinhacks/zod#json-type 4 | type Literal = boolean | null | number | string; 5 | type Json = Literal | {[key: string]: Json} | Json[]; 6 | const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); 7 | export const jsonSchema: z.ZodSchema = z.lazy(() => 8 | z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]), 9 | ); 10 | export type JSONType = z.infer; 11 | --------------------------------------------------------------------------------