Skip to content

Commit

Permalink
Implement ClientState entity for highlight. This is how all the
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
aboodman committed Mar 2, 2021
1 parent 83a1634 commit b9db7c5
Show file tree
Hide file tree
Showing 9 changed files with 222 additions and 40 deletions.
32 changes: 32 additions & 0 deletions backend/data.ts
Expand Up @@ -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
Expand Down Expand Up @@ -74,3 +76,33 @@ export async function putShape(
content: { stringValue: JSON.stringify(shape) },
});
}

export async function getClientState(
executor: ExecuteStatementFn,
id: string
): Promise<ClientState> {
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<void> {
await executor(`CALL PutClientState(:id, :content)`, {
id: { stringValue: id },
content: { stringValue: JSON.stringify(clientState) },
});
}
19 changes: 18 additions & 1 deletion backend/rds.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
60 changes: 58 additions & 2 deletions frontend/data.ts
Expand Up @@ -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) => {
Expand All @@ -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<void>;
readonly moveShape: (args: MoveShapeArgs) => Promise<void>;
readonly overShape: (args: OverShapeArgs) => Promise<void>;

useShapeIDs(): Array<string> {
return useSubscribe(
Expand All @@ -62,21 +83,56 @@ export class Data {
);
}

private async getShape(tx: ReadTransaction, id: string): Promise<Shape> {
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<Shape | null> {
// 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<ClientState> {
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),
};
}
}
32 changes: 30 additions & 2 deletions frontend/designer.tsx
Expand Up @@ -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("");
Expand Down Expand Up @@ -66,10 +69,35 @@ export function Designer({ data }: { data: Data }) {
<svg width="100%" height="100%">
{ids.map((id) => (
<Rect
key={id}
{...{ data, id, onMouseDown: (e) => 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 && (
<Rect
{...{
key: `highlight-${overID}`,
data,
id: overID,
highlight: true,
}}
/>
)}
</svg>
</div>
</HotKeys>
Expand Down
22 changes: 12 additions & 10 deletions frontend/rect.tsx
Expand Up @@ -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 (
<rect
{...{
style: getStyle(shape.blendMode, over),
style: getStyle(shape.blendMode, Boolean(highlight)),
transform: getTransformMatrix(shape),
x: shape.x,
y: shape.y,
width: shape.width,
height: shape.width,
fill: shape.fill,
fill: highlight ? "none" : shape.fill,
onMouseDown,
onMouseEnter,
onMouseLeave,
Expand All @@ -40,11 +42,11 @@ export function Rect({
);
}

function getStyle(blendMode: string, over: boolean): any {
function getStyle(blendMode: string, highlight: boolean): any {
return {
mixBlendMode: blendMode,
outlineColor: "rgb(74,158,255)",
outlineStyle: over ? "solid" : "none",
outlineStyle: highlight ? "solid" : "none",
outlineWidth: "2px",
};
}
Expand Down
57 changes: 32 additions & 25 deletions pages/api/replicache-pull.ts
Expand Up @@ -12,14 +12,17 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
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),
]);
Expand All @@ -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,
Expand All @@ -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,
});
}
}
}
}
Expand Down

0 comments on commit b9db7c5

Please sign in to comment.