From b9db7c51670c109ef0a6f515fc41659381960220 Mon Sep 17 00:00:00 2001 From: Aaron Boodman Date: Mon, 1 Mar 2021 13:07:04 -1000 Subject: [PATCH] Implement ClientState entity for highlight. This is how all the per-client state is going to work, like selected item, cursor position, etc. This feels like a lot of duplication so I'll be looking into refactors in the future, but this is a start. --- backend/data.ts | 32 +++++++++++++++++++ backend/rds.ts | 19 +++++++++++- frontend/data.ts | 60 ++++++++++++++++++++++++++++++++++-- frontend/designer.tsx | 32 +++++++++++++++++-- frontend/rect.tsx | 22 +++++++------ pages/api/replicache-pull.ts | 57 +++++++++++++++++++--------------- pages/api/replicache-push.ts | 15 +++++++++ shared/client-state.ts | 7 +++++ shared/mutators.ts | 18 +++++++++++ 9 files changed, 222 insertions(+), 40 deletions(-) create mode 100644 shared/client-state.ts diff --git a/backend/data.ts b/backend/data.ts index 5470c26..74bc786 100644 --- a/backend/data.ts +++ b/backend/data.ts @@ -3,10 +3,12 @@ */ import { shape } from "../shared/shape"; +import { clientState } from "../shared/client-state"; import { must } from "./decode"; import type { ExecuteStatementFn } from "./rds"; import type { Shape } from "../shared/shape"; +import type { ClientState } from "../shared/client-state"; export async function getCookieVersion( executor: ExecuteStatementFn @@ -74,3 +76,33 @@ export async function putShape( content: { stringValue: JSON.stringify(shape) }, }); } + +export async function getClientState( + executor: ExecuteStatementFn, + id: string +): Promise { + const { records } = await executor( + "SELECT Content FROM ClientState WHERE Id = :id", + { + id: { stringValue: id }, + } + ); + const content = records?.[0]?.[0]?.stringValue; + if (!content) { + return { + overID: "", + }; + } + return must(clientState.decode(JSON.parse(content))); +} + +export async function putClientState( + executor: ExecuteStatementFn, + id: string, + clientState: ClientState +): Promise { + await executor(`CALL PutClientState(:id, :content)`, { + id: { stringValue: id }, + content: { stringValue: JSON.stringify(clientState) }, + }); +} diff --git a/backend/rds.ts b/backend/rds.ts index b6f101a..ec2f6db 100644 --- a/backend/rds.ts +++ b/backend/rds.ts @@ -97,7 +97,16 @@ async function createDatabase() { Version BIGINT NOT NULL)`); await executeStatement(`CREATE TABLE Client ( Id VARCHAR(255) PRIMARY KEY NOT NULL, - LastMutationID BIGINT NOT NULL)`); + LastMutationID BIGINT NOT NULL, + LastModified TIMESTAMP NOT NULL + DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP)`); + await executeStatement(`CREATE TABLE ClientState ( + Id VARCHAR(255) PRIMARY KEY NOT NULL, + Content TEXT NOT NULL, + Version BIGINT NOT NULL)`); + // TODO: When https://github.com/rocicorp/replicache-sdk-js/issues/275 is + // fixed, can enable this. + //FOREIGN KEY (Id) REFERENCES Client (Id), await executeStatement(`CREATE TABLE Shape ( Id VARCHAR(255) PRIMARY KEY NOT NULL, Content TEXT NOT NULL, @@ -125,6 +134,14 @@ async function createDatabase() { INSERT INTO Shape (Id, Content, Version) VALUES (pId, pContent, @version) ON DUPLICATE KEY UPDATE Id = pId, Content = pContent, Version = @version; END`); + + await executeStatement(`CREATE PROCEDURE PutClientState (IN pId VARCHAR(255), IN pContent TEXT) + BEGIN + SET @version = 0; + CALL NextVersion(@version); + INSERT INTO ClientState (Id, Content, Version) VALUES (pId, pContent, @version) + ON DUPLICATE KEY UPDATE Id = pId, Content = pContent, Version = @version; + END`); } async function executeStatement( diff --git a/frontend/data.ts b/frontend/data.ts index 0e82e12..749b076 100644 --- a/frontend/data.ts +++ b/frontend/data.ts @@ -5,23 +5,36 @@ import Replicache, { } from "replicache"; import { useSubscribe } from "replicache-react-util"; import { Shape } from "../shared/shape"; +import { ClientState } from "../shared/client-state"; import { createShape, CreateShapeArgs, moveShape, MoveShapeArgs, + overShape, + OverShapeArgs, } from "../shared/mutators"; import type { MutatorStorage } from "../shared/mutators"; +import { newID } from "../shared/id"; +import { Type } from "io-ts"; /** * Abstracts Replicache storage (key/value pairs) to entities (Shape). */ export class Data { private rep: Replicache; + readonly clientID: string; constructor(rep: Replicache) { this.rep = rep; + // TODO: Use clientID from Replicache: + // https://github.com/rocicorp/replicache-sdk-js/issues/275 + this.clientID = localStorage.clientID; + if (!this.clientID) { + this.clientID = localStorage.clientID = newID(); + } + this.createShape = rep.register( "createShape", async (tx: WriteTransaction, args: CreateShapeArgs) => { @@ -35,11 +48,19 @@ export class Data { await moveShape(this.mutatorStorage(tx), args); } ); + + this.overShape = rep.register( + "overShape", + async (tx: WriteTransaction, args: OverShapeArgs) => { + await overShape(this.mutatorStorage(tx), args); + } + ); } // TODO: Is there a way for Typescript to infer this from the assignment in constructor? readonly createShape: (args: CreateShapeArgs) => Promise; readonly moveShape: (args: MoveShapeArgs) => Promise; + readonly overShape: (args: OverShapeArgs) => Promise; useShapeIDs(): Array { return useSubscribe( @@ -62,21 +83,56 @@ export class Data { ); } - private async getShape(tx: ReadTransaction, id: string): Promise { + useOverShapeID(): string | null { + return useSubscribe(this.rep, async (tx: ReadTransaction) => { + return (await this.getClientState(tx, this.clientID)).overID; + }); + } + + private async getShape( + tx: ReadTransaction, + id: string + ): Promise { // TODO: validate returned shape - can be wrong in case app reboots with // new code and old storage. We can decode, but then what? // See https://github.com/rocicorp/replicache-sdk-js/issues/285. - return ((await tx.get(`shape-${id}`)) as unknown) as Shape; + return ((await tx.get(`shape-${id}`)) as unknown) as Shape | null; } private async putShape(tx: WriteTransaction, id: string, shape: Shape) { return await tx.put(`shape-${id}`, (shape as unknown) as JSONObject); } + private async getClientState( + tx: ReadTransaction, + id: string + ): Promise { + return ( + (((await tx.get( + `client-state-${id}` + )) as unknown) as ClientState | null) || { + overID: "", + } + ); + } + + private async putClientState( + tx: WriteTransaction, + id: string, + client: ClientState + ) { + return await tx.put( + `client-state-${id}`, + (client as unknown) as JSONObject + ); + } + private mutatorStorage(tx: WriteTransaction): MutatorStorage { return { getShape: this.getShape.bind(null, tx), putShape: this.putShape.bind(null, tx), + getClientState: this.getClientState.bind(null, tx), + putClientState: this.putClientState.bind(null, tx), }; } } diff --git a/frontend/designer.tsx b/frontend/designer.tsx index 16aea17..2714247 100644 --- a/frontend/designer.tsx +++ b/frontend/designer.tsx @@ -9,6 +9,9 @@ export function Designer({ data }: { data: Data }) { const ids = data.useShapeIDs(); console.log({ ids }); + const overID = data.useOverShapeID(); + console.log({ overID }); + // TODO: This should be stored in Replicache too, since we will be rendering // other users' selections. const [selectedID, setSelectedID] = useState(""); @@ -66,10 +69,35 @@ export function Designer({ data }: { data: Data }) { {ids.map((id) => ( onMouseDown(e, id) }} + {...{ + key: id, + data, + id, + onMouseEnter: () => + data.overShape({ clientID: data.clientID, shapeID: id }), + onMouseLeave: () => + data.overShape({ clientID: data.clientID, shapeID: "" }), + onMouseDown: (e) => onMouseDown(e, id), + }} + highlight={false} /> ))} + + { + // This looks a little odd at first, but we want the selection + // rectangle to be stacked above all objects on the page, so we + // paint the highlighted object again in a special 'highlight' + // mode. + overID && ( + + )} diff --git a/frontend/rect.tsx b/frontend/rect.tsx index a74835c..94e8aaa 100644 --- a/frontend/rect.tsx +++ b/frontend/rect.tsx @@ -5,33 +5,35 @@ import { Data } from "./data"; export function Rect({ data, id, + highlight, + onMouseEnter, + onMouseLeave, onMouseDown, }: { data: Data; id: string; - onMouseDown: MouseEventHandler; + highlight?: boolean; + onMouseEnter?: MouseEventHandler; + onMouseLeave?: MouseEventHandler; + onMouseDown?: MouseEventHandler; }) { - const [over, setOver] = useState(false); const shape = data.useShapeByID(id); if (!shape) { return null; } - console.log("Rendering shape", shape); - - const onMouseEnter = () => setOver(true); - const onMouseLeave = () => setOver(false); + console.log("Rendering rect", shape, highlight); return ( { let cookie = pull.baseStateID == "" ? 0 : parseInt(pull.baseStateID); console.time(`Reading all Shapes...`); - let entries; + let shapes, clientStates; let lastMutationID = 0; await transact(async (executor) => { - [entries, lastMutationID, cookie] = await Promise.all([ + [shapes, clientStates, lastMutationID, cookie] = await Promise.all([ executor("SELECT * FROM Shape WHERE Version > :version", { version: { longValue: cookie }, }), + executor("SELECT * FROM ClientState WHERE Version > :version", { + version: { longValue: cookie }, + }), getLastMutationID(executor, pull.clientID), getCookieVersion(executor), ]); @@ -29,7 +32,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { // Grump. Typescript seems to not understand that the argument to transact() // is guaranteed to have been called before transact() exits. - entries = (entries as any) as ExecuteStatementCommandOutput; + shapes = (shapes as any) as ExecuteStatementCommandOutput; + clientStates = (clientStates as any) as ExecuteStatementCommandOutput; const resp: PullResponse = { lastMutationID, @@ -42,28 +46,31 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { }, }; - if (entries.records) { - for (let row of entries.records) { - const [ - { stringValue: id }, - { stringValue: content }, - { booleanValue: deleted }, - ] = row as [ - Field.StringValueMember, - Field.StringValueMember, - Field.BooleanValueMember - ]; - if (deleted) { - resp.patch.push({ - op: "remove", - path: `/shape-${id}`, - }); - } else { - resp.patch.push({ - op: "replace", - path: `/shape-${id}`, - valueString: content, - }); + for (let entries of [shapes, clientStates]) { + if (entries.records) { + for (let row of entries.records) { + const [ + { stringValue: id }, + { stringValue: content }, + { booleanValue: deleted }, + ] = row as [ + Field.StringValueMember, + Field.StringValueMember, + Field.BooleanValueMember + ]; + const prefix = entries == shapes ? "shape" : "client-state"; + if (deleted) { + resp.patch.push({ + op: "remove", + path: `/${prefix}-${id}`, + }); + } else { + resp.patch.push({ + op: "replace", + path: `/${prefix}-${id}`, + valueString: content, + }); + } } } } diff --git a/pages/api/replicache-push.ts b/pages/api/replicache-push.ts index 10bee20..3fa4303 100644 --- a/pages/api/replicache-push.ts +++ b/pages/api/replicache-push.ts @@ -7,10 +7,14 @@ import { moveShape, moveShapeArgs, MoveShapeArgs, + overShape, + overShapeArgs, } from "../../shared/mutators"; import { + getClientState, getLastMutationID, getShape, + putClientState, putShape, setLastMutationID, } from "../../backend/data"; @@ -29,6 +33,11 @@ const mutation = t.union([ name: t.literal("moveShape"), args: moveShapeArgs, }), + t.type({ + id: t.number, + name: t.literal("overShape"), + args: overShapeArgs, + }), ]); const pushRequest = t.type({ @@ -83,9 +92,13 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { case "createShape": await createShape(ms, must(createShapeArgs.decode(mutation.args))); break; + case "overShape": + await overShape(ms, must(overShapeArgs.decode(mutation.args))); + break; } await setLastMutationID(executor, push.clientID, expectedMutationID); + break; } }); @@ -119,5 +132,7 @@ function mutatorStorage(executor: ExecuteStatementFn): MutatorStorage { return { getShape: getShape.bind(null, executor), putShape: putShape.bind(null, executor), + getClientState: getClientState.bind(null, executor), + putClientState: putClientState.bind(null, executor), }; } diff --git a/shared/client-state.ts b/shared/client-state.ts new file mode 100644 index 0000000..eaa5ff0 --- /dev/null +++ b/shared/client-state.ts @@ -0,0 +1,7 @@ +import * as t from "io-ts"; + +export const clientState = t.type({ + overID: t.string, +}); + +export type ClientState = t.TypeOf; diff --git a/shared/mutators.ts b/shared/mutators.ts index f89acd4..4a16028 100644 --- a/shared/mutators.ts +++ b/shared/mutators.ts @@ -9,6 +9,8 @@ import * as t from "io-ts"; import { shape } from "./shape"; import type { Shape } from "./shape"; +import { clientState } from "./client-state"; +import type { ClientState } from "./client-state"; /** * Interface required of underlying storage. @@ -16,6 +18,8 @@ import type { Shape } from "./shape"; export interface MutatorStorage { getShape(id: string): Promise; putShape(id: string, shape: Shape): Promise; + getClientState(id: string): Promise; + putClientState(id: string, client: ClientState): Promise; } export async function createShape( @@ -50,3 +54,17 @@ export const moveShapeArgs = t.type({ dy: t.number, }); export type MoveShapeArgs = t.TypeOf; + +export async function overShape( + storage: MutatorStorage, + args: OverShapeArgs +): Promise { + const client = await storage.getClientState(args.clientID); + client.overID = args.shapeID; + await storage.putClientState(args.clientID, client); +} +export const overShapeArgs = t.type({ + clientID: t.string, + shapeID: t.string, +}); +export type OverShapeArgs = t.TypeOf;