├── .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 |
140 | Demo Source
141 |
142 |
147 | Replicache Homepage
148 |
149 |
150 |
151 | showShare(false)} centered>
152 |
153 | Share Drawing
154 |
155 |
156 | Copy this URL and send to anyone:
158 |
164 |
165 |
166 |
167 | showShare(false)}>
168 | OK
169 |
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 |
98 | )}
99 |
100 |
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 |
--------------------------------------------------------------------------------