Skip to content

Commit

Permalink
feat!: add WebComponent props
Browse files Browse the repository at this point in the history
  • Loading branch information
Quentin-Guillemin committed Mar 2, 2023
1 parent d115616 commit aa87131
Show file tree
Hide file tree
Showing 8 changed files with 98 additions and 46 deletions.
49 changes: 37 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
# Express-Yjs-tldraw

Multiplayer implementation with image upload on [tldraw](https://www.tldraw.com) using [yjs](https://github.com/yjs/yjs) and [express](https://github.com/expressjs/express).
Tldraw singleplayer and mutliplayer WebComponent.

Based on [nimeshnayaju yjs-tldraw](https://github.com/nimeshnayaju/yjs-tldraw) POC.
Librairies :

- [tldraw](https://www.tldraw.com)
- [yjs](https://github.com/yjs/yjs)
- [express](https://github.com/expressjs/express)

Based on [nimeshnayaju yjs-tldraw](https://github.com/nimeshnayaju/yjs-tldraw) POC for yjs multiplayer.

## Setup

```bash
yarn initialize
```

---

Remove `node_modules` and `dist` folders.

```bash
yarn clean
```

## How to use

### Development
Expand All @@ -26,21 +40,32 @@ Launch client to use tldraw.
yarn dev:client
```

### Multiplayer

Launch WebSocket server then add `/r/<anything>` to your url use multiplayer functionality.
Each unique url create is own room.
To use multiplayer functionality, launch WebSocket server then add `/r/<anything>` to your url.

```bash
yarn start:ws
```

Use to use multiplayer functionality.

Remove `node_modules` and `dist` folders.

```bash
yarn clean
### WebComponent

| Prop | Description | Type | Required | Default |
| :-------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----: | :------: | :-----: |
| idb-name | Name for indexeddb | string | true | - |
| api-url | API url for file managment | string | true | - |
| ws-url | WebSocket url | string | false | - |
| room-id | Identifier of multiplayer room | string | false | - |
| read-only | Disable edition on multiplayer | bool | false | false |
| language | Default interface language (check [tldraw translation](https://github.com/tldraw/tldraw/tree/main/packages/tldraw/src/translations) for availables translations) | string | false | en |

```html
<tldraw-editor
idb-name=""
api-url=""
ws-url=""
room-id=""
read-only
language=""
/>
```

## Build
Expand Down
1 change: 0 additions & 1 deletion client/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,3 @@ VITE_WS_URL="ws://localhost:1234/ws"
VITE_API_URL="http://localhost:8080"
VITE_LANGUAGE="en"
VITE_IDB_NAME="tldraw"
VITE_DEFAULT_ROOM="tldraw"
11 changes: 9 additions & 2 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import Editor from "./components/Editor";
import "./App.css";

const { VITE_LANGUAGE } = import.meta.env;
const { VITE_API_URL, VITE_IDB_NAME, VITE_LANGUAGE, VITE_WS_URL } = import.meta
.env;

export default function App() {
const urlParams = window.location.pathname.slice(1).split("/");
const roomId = urlParams[0] === "r" ? urlParams[1] : undefined;

return (
<div className="tldraw">
<Editor roomId={roomId} language={VITE_LANGUAGE} />
<Editor
idbName={VITE_IDB_NAME}
apiUrl={VITE_API_URL}
wsUrl={VITE_WS_URL}
roomId={roomId}
language={VITE_LANGUAGE}
/>
</div>
);
}
45 changes: 32 additions & 13 deletions client/src/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,60 @@ import { useMultiplayer } from "../hooks/useMultiplayer";
import { initProvider } from "../utils/y-websocket";
import { CustomCursor } from "./Cursor";
import PropTypes from "prop-types";
import { Default, Multiplayer, Settings } from "../types/types";
import {
Multiplayer,
MultiplayerReadOnly,
Settings,
Singleplayer,
} from "../types/types";
import { useSingleplayer } from "../hooks/useSingleplayer";
import { initPersistence } from "../utils/y-indexeddb";

Editor.propTypes = {
idbName: PropTypes.string.isRequired,
apiUrl: PropTypes.string.isRequired,
wsUrl: PropTypes.string,
roomId: PropTypes.string,
readOnly: PropTypes.string,
readOnly: PropTypes.bool,
language: PropTypes.string,
};

const components = {
Cursor: CustomCursor,
};

function Editor({ roomId, readOnly, language }: Settings) {
function Editor({
idbName,
apiUrl,
wsUrl,
roomId,
readOnly,
language,
}: Settings) {
language = language || "en";
let editor = <DefaultEditor language={language} />;
if (roomId) {
initProvider(roomId);
initPersistence(idbName);
let editor = <SingleplayerEditor apiUrl={apiUrl} language={language} />;
if (wsUrl && roomId) {
initProvider(wsUrl, roomId);
editor = readOnly ? (
<MultiplayerReadOnlyEditor roomId={roomId} language={language} />
) : (
<MultiplayerEditor roomId={roomId} language={language} />
<MultiplayerEditor apiUrl={apiUrl} roomId={roomId} language={language} />
);
}

return editor;
}

function DefaultEditor({ language }: Default) {
function SingleplayerEditor({ apiUrl, language }: Singleplayer) {
const fileSystemEvents = useFileSystem();
const { onAssetCreate, onAssetDelete, onAssetUpload } = useAssets();
const { onAssetCreate, onAssetDelete, onAssetUpload } = useAssets(apiUrl);
const { ...events } = useSingleplayer(language);

return (
<Tldraw
autofocus
components={components}
showMultiplayerMenu={false}
onAssetCreate={onAssetCreate}
onAssetDelete={onAssetDelete}
onAssetUpload={onAssetUpload}
Expand All @@ -50,16 +67,17 @@ function DefaultEditor({ language }: Default) {
);
}

function MultiplayerEditor({ roomId, language }: Multiplayer) {
function MultiplayerEditor({ apiUrl, roomId, language }: Multiplayer) {
const { onSaveProjectAs, onSaveProject } = useFileSystem();
const { onAssetCreate, onAssetDelete, onAssetUpload } = useAssets();
const { onAssetCreate, onAssetDelete, onAssetUpload } = useAssets(apiUrl);
const { ...events } = useMultiplayer(roomId, language);

return (
<Tldraw
autofocus
components={components}
showPages={false}
showMultiplayerMenu={false}
onAssetCreate={onAssetCreate}
onAssetDelete={onAssetDelete}
onAssetUpload={onAssetUpload}
Expand All @@ -70,7 +88,7 @@ function MultiplayerEditor({ roomId, language }: Multiplayer) {
);
}

function MultiplayerReadOnlyEditor({ roomId, language }: Multiplayer) {
function MultiplayerReadOnlyEditor({ roomId, language }: MultiplayerReadOnly) {
const { onSaveProjectAs, onSaveProject } = useFileSystem();
const { ...events } = useMultiplayer(roomId, language);

Expand All @@ -79,6 +97,7 @@ function MultiplayerReadOnlyEditor({ roomId, language }: Multiplayer) {
autofocus
components={components}
showPages={false}
showMultiplayerMenu={false}
onSaveProjectAs={onSaveProjectAs}
onSaveProject={onSaveProject}
readOnly
Expand Down
10 changes: 4 additions & 6 deletions client/src/hooks/useAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,18 @@ import { TldrawApp } from "@tldraw/tldraw";
import axios from "axios";
import { useCallback } from "react";

const { VITE_API_URL } = import.meta.env;

export function useAssets() {
export function useAssets(apiUrl: string) {
const onAssetCreate = useCallback(
async (app: TldrawApp, file: File, id: string): Promise<string | false> => {
let body = new FormData();
body.append("name", id);
body.append("file", file);

let response = await axios.post(`${VITE_API_URL}/files`, body, {
let response = await axios.post(`${apiUrl}/files`, body, {
headers: { "Content-Type": "multipart/form-data" },
});

return VITE_API_URL + response.data.uri;
return apiUrl + response.data.uri;
},
[]
);
Expand All @@ -31,7 +29,7 @@ export function useAssets() {

const onAssetDelete = useCallback(
async (app: TldrawApp, id: string): Promise<boolean> => {
await axios.delete(`${VITE_API_URL}/files/${id}`);
await axios.delete(`${apiUrl}/files/${id}`);

return true;
},
Expand Down
12 changes: 11 additions & 1 deletion client/src/types/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
export type Settings = {
idbName: string;
apiUrl: string;
wsUrl?: string;
roomId?: string;
readOnly?: boolean;
language?: string;
};

export type Default = {
export type Singleplayer = {
apiUrl: string;
language: string;
};

export type Multiplayer = {
apiUrl: string;
roomId: string;
language: string;
};

export type MultiplayerReadOnly = {
roomId: string;
language: string;
};
5 changes: 2 additions & 3 deletions client/src/utils/y-indexeddb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ import * as Y from "yjs";
import { IndexeddbPersistence } from "y-indexeddb";
import type { TDAsset, TDBinding, TDShape } from "@tldraw/tldraw";

const { VITE_IDB_NAME } = import.meta.env;

// Create the doc
export const doc = new Y.Doc();

const persistence = new IndexeddbPersistence(VITE_IDB_NAME, doc);
export const initPersistence = (idbName: string) =>
new IndexeddbPersistence(idbName, doc);

export const yShapes: Y.Map<TDShape> = doc.getMap("shapes");
export const yBindings: Y.Map<TDBinding> = doc.getMap("bindings");
Expand Down
11 changes: 3 additions & 8 deletions client/src/utils/y-websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,19 @@ import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import type { TDAsset, TDBinding, TDShape } from "@tldraw/tldraw";

const { VITE_DEFAULT_ROOM, VITE_WS_URL } = import.meta.env;

// Create the doc
export const doc = new Y.Doc();

let _provider: WebsocketProvider;

export const initProvider = (roomId: string) => {
_provider = new WebsocketProvider(VITE_WS_URL, roomId, doc, {
export const initProvider = (wsUrl: string, roomId: string) => {
_provider = new WebsocketProvider(wsUrl, roomId, doc, {
connect: true,
});
};

export const provider = (): WebsocketProvider => {
if (!_provider) {
console.warn("_provider has not been initialized");
initProvider(VITE_DEFAULT_ROOM);
}
if (!_provider) throw new Error("_provider has not been initialized");

return _provider;
};
Expand Down

0 comments on commit aa87131

Please sign in to comment.