This commit is contained in:
Orion Reed 2024-07-17 15:59:09 +02:00
commit a91bfdb51b
20 changed files with 5275 additions and 0 deletions

27
.eslintrc.cjs Normal file
View File

@ -0,0 +1,27 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
}

28
.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.yarn/install-state.gz
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vercel
.env

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

17
README.md Normal file
View File

@ -0,0 +1,17 @@
This repository shows how you might use [tldraw](https://github.com/tldraw/tldraw) together with the [yjs](https://yjs.dev) library. It also makes a good example for how to use tldraw with other backend services!
This branch shows a partykit integration.
For production:
- First create a `.env` file with:
```
VITE_PRODUCTION_URL=https://tldraw-partykit-yjs-example.YOUR_USERNAME.partykit.dev
```
...replacing `YOUR_USERNAME` with your partykit username.
- Then run `yarn deploy`
If it's your first time using partykit, you may need to try deploying first in order to get your username. While the instructions above should work, the real way to do this is to point the `VITE_PRODUCTION_URL` at whatever URL you're deploying the website to.

23
biome.json Normal file
View File

@ -0,0 +1,23 @@
{
"$schema": "https://biomejs.dev/schemas/1.4.1/schema.json",
"organizeImports": {
"enabled": true
},
"files": {
"ignore": [
"src/hull"
]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noForEach": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
}
}
}
}

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Canvas</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

35
package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "tldraw-yjs-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"HOST=localhost PORT=4321 npx y-websocket\" --kill-others",
"build": "tsc && vite build",
"preview": "vite preview",
"deploy": "yarn build && npx partykit deploy",
"lint": "yarn dlx @biomejs/biome check --apply src"
},
"dependencies": {
"partykit": "0.0.27",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tldraw": "2.3.0",
"y-partykit": "0.0.7",
"y-utility": "0.1.3",
"y-websocket": "1.5.0",
"yjs": "^13.6.8"
},
"devDependencies": {
"@biomejs/biome": "1.4.1",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"concurrently": "^8.2.0",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"vite-plugin-top-level-await": "^1.3.1",
"vite-plugin-wasm": "^3.2.2"
},
"packageManager": "yarn@4.0.2"
}

8
partykit.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "ggraph",
"main": "src/server.ts",
"serve": {
"path": "dist"
},
"compatibilityDate": "2023-10-04"
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

92
src/App.tsx Normal file
View File

@ -0,0 +1,92 @@
import { Tldraw, track, useEditor } from "tldraw";
import "tldraw/tldraw.css";
// import { DevShapeTool } from "./DevShape/DevShapeTool";
// import { DevShapeUtil } from "./DevShape/DevShapeUtil";
// import { DevUi } from "./DevUI";
// import { uiOverrides } from "./ui-overrides";
import { useYjsStore } from "./useYjsStore";
// const customShapeUtils = [DevShapeUtil];
// const customTools = [DevShapeTool];
const HOST_URL = import.meta.env.DEV
? "ws://localhost:1234"
: import.meta.env.VITE_PRODUCTION_URL.replace("https://", "ws://"); // remove protocol just in case
export default function Canvas() {
const roomId =
new URLSearchParams(window.location.search).get("room") || "43";
const store = useYjsStore({
roomId: roomId,
hostUrl: HOST_URL,
// shapeUtils: customShapeUtils,
});
return (
<div className="tldraw__editor">
<Tldraw
autoFocus
store={store}
components={{
SharePanel: NameEditor,
}}
// shapeUtils={customShapeUtils}
// tools={customTools}
// overrides={uiOverrides}
>
{/* <DevUi /> */}
</Tldraw>
</div>
);
}
const NameEditor = track(() => {
const editor = useEditor();
const { color, name } = editor.user.getUserPreferences();
return (
<div
style={{
// TODO: style this properly and consistently with tldraw
pointerEvents: "all",
display: "flex",
width: "148px",
margin: "4px 8px",
border: "none",
}}
>
<input
style={{
borderRadius: "9px 0px 0px 9px",
border: "none",
backgroundColor: "white",
boxShadow: "0px 0px 4px rgba(0, 0, 0, 0.25)",
}}
type="color"
value={color}
onChange={(e) => {
editor.user.updateUserPreferences({
color: e.currentTarget.value,
});
}}
/>
<input
style={{
width: "100%",
borderRadius: "0px 9px 9px 0px",
border: "none",
backgroundColor: "white",
boxShadow: "0px 0px 4px rgba(0, 0, 0, 0.25)",
}}
value={name}
onChange={(e) => {
editor.user.updateUserPreferences({
name: e.currentTarget.value,
});
}}
/>
</div>
);
});

61
src/css/dev-ui.css Normal file
View File

@ -0,0 +1,61 @@
.custom-layout {
position: absolute;
inset: 0px;
z-index: 300;
pointer-events: none;
}
.custom-toolbar {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
gap: 8px;
}
.custom-button {
pointer-events: all;
padding: 4px 12px;
background: white;
border: 1px solid black;
border-radius: 64px;
&:hover {
background-color: rgb(240, 240, 240);
}
}
.custom-button[data-isactive="true"] {
background-color: black;
color: white;
}
/* Style the button that is used to open and close the collapsible content */
.collapsible {
background-color: #eee;
color: #444;
cursor: pointer;
padding: 18px;
width: 100%;
border: none;
text-align: left;
outline: none;
font-size: 15px;
}
/* Add a background color to the button if it is clicked on (add the .active class with JS), and when you move the mouse over it (hover) */
.active,
.collapsible:hover {
background-color: #ccc;
}
/* Style the collapsible content. Note: hidden by default */
.content {
padding: 0 18px;
display: none;
overflow: hidden;
background-color: #f1f1f1;
}

64
src/css/index.css Normal file
View File

@ -0,0 +1,64 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap');
html,
body {
padding: 0;
margin: 0;
font-family: 'Inter', sans-serif;
overscroll-behavior: none;
touch-action: none;
min-height: 100vh;
font-size: 16px;
/* mobile viewport bug fix */
min-height: -webkit-fill-available;
height: 100%;
}
html,
* {
box-sizing: border-box;
}
.tldraw__editor {
position: fixed;
inset: 0px;
overflow: hidden;
}
.examples {
padding: 16px;
}
.examples__header {
width: fit-content;
padding-bottom: 32px;
}
.examples__lockup {
height: 56px;
width: auto;
}
.examples__list {
display: flex;
flex-direction: column;
padding: 0;
margin: 0;
list-style: none;
}
.examples__list__item {
padding: 8px 12px;
margin: 0px -12px;
}
.examples__list__item a {
padding: 8px 12px;
margin: 0px -12px;
text-decoration: none;
color: inherit;
}
.examples__list__item a:hover {
text-decoration: underline;
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./css/index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

13
src/server.ts Normal file
View File

@ -0,0 +1,13 @@
import * as Party from "partykit/server";
import { onConnect } from "y-partykit";
export default {
async onConnect(conn: Party.Connection, room: Party.Party) {
console.log("onConnect");
return await onConnect(conn, room, {
// experimental: persist the document to partykit's room storage
persist: true,
});
},
};

276
src/useYjsStore.ts Normal file
View File

@ -0,0 +1,276 @@
import {
InstancePresenceRecordType,
TLAnyShapeUtilConstructor,
TLInstancePresence,
TLRecord,
TLStoreWithStatus,
computed,
createPresenceStateDerivation,
createTLStore,
defaultShapeUtils,
defaultUserPreferences,
getUserPreferences,
react,
transact,
} from "tldraw";
import { useEffect, useMemo, useState } from "react";
import YPartyKitProvider from "y-partykit/provider";
import { YKeyValue } from "y-utility/y-keyvalue";
import * as Y from "yjs";
// import { DEFAULT_STORE } from "./default_store";
export function useYjsStore({
hostUrl,
version = 1,
roomId = "example",
shapeUtils = [],
}: {
hostUrl: string;
version?: number;
roomId?: string;
shapeUtils?: TLAnyShapeUtilConstructor[];
}) {
const [store] = useState(() => {
const store = createTLStore({
shapeUtils: [...defaultShapeUtils, ...shapeUtils],
});
// store.loadSnapshot(DEFAULT_STORE);
return store;
});
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({
status: "loading",
});
const { yDoc, yStore, room } = useMemo(() => {
const yDoc = new Y.Doc({ gc: true });
const yArr = yDoc.getArray<{ key: string; val: TLRecord }>(`tl_${roomId}`);
const yStore = new YKeyValue(yArr);
return {
yDoc,
yStore,
room: new YPartyKitProvider(hostUrl, `${roomId}_${version}`, yDoc, {
connect: true,
}),
};
}, [hostUrl, roomId, version]);
useEffect(() => {
setStoreWithStatus({ status: "loading" });
const unsubs: (() => void)[] = [];
function handleSync() {
// 1.
// Connect store to yjs store and vis versa, for both the document and awareness
/* -------------------- Document -------------------- */
// Sync store changes to the yjs doc
unsubs.push(
store.listen(
function syncStoreChangesToYjsDoc({ changes }) {
yDoc.transact(() => {
Object.values(changes.added).forEach((record) => {
yStore.set(record.id, record);
});
Object.values(changes.updated).forEach(([_, record]) => {
yStore.set(record.id, record);
});
Object.values(changes.removed).forEach((record) => {
yStore.delete(record.id);
});
});
},
{ source: "user", scope: "document" }, // only sync user's document changes
),
);
// Sync the yjs doc changes to the store
const handleChange = (
changes: Map<
string,
| { action: "delete"; oldValue: TLRecord }
| { action: "update"; oldValue: TLRecord; newValue: TLRecord }
| { action: "add"; newValue: TLRecord }
>,
transaction: Y.Transaction,
) => {
if (transaction.local) return;
const toRemove: TLRecord["id"][] = [];
const toPut: TLRecord[] = [];
changes.forEach((change, id) => {
switch (change.action) {
case "add":
case "update": {
const record = yStore.get(id)!;
toPut.push(record);
break;
}
case "delete": {
toRemove.push(id as TLRecord["id"]);
break;
}
}
});
// put / remove the records in the store
store.mergeRemoteChanges(() => {
if (toRemove.length) store.remove(toRemove);
if (toPut.length) store.put(toPut);
});
};
yStore.on("change", handleChange);
unsubs.push(() => yStore.off("change", handleChange));
/* -------------------- Awareness ------------------- */
const userPreferences = computed<{
id: string;
color: string;
name: string;
}>("userPreferences", () => {
const user = getUserPreferences();
return {
id: user.id,
color: user.color ?? defaultUserPreferences.color,
name: user.name ?? defaultUserPreferences.name,
};
});
// Create the instance presence derivation
const yClientId = room.awareness.clientID.toString();
const presenceId = InstancePresenceRecordType.createId(yClientId);
const presenceDerivation =
createPresenceStateDerivation(userPreferences)(store);
// Set our initial presence from the derivation's current value
room.awareness.setLocalStateField("presence", presenceDerivation.get());
// When the derivation change, sync presence to to yjs awareness
unsubs.push(
react("when presence changes", () => {
const presence = presenceDerivation.get();
requestAnimationFrame(() => {
room.awareness.setLocalStateField("presence", presence);
});
}),
);
// Sync yjs awareness changes to the store
const handleUpdate = (update: {
added: number[];
updated: number[];
removed: number[];
}) => {
const states = room.awareness.getStates() as Map<
number,
{ presence: TLInstancePresence }
>;
const toRemove: TLInstancePresence["id"][] = [];
const toPut: TLInstancePresence[] = [];
// Connect records to put / remove
for (const clientId of update.added) {
const state = states.get(clientId);
if (state?.presence && state.presence.id !== presenceId) {
toPut.push(state.presence);
}
}
for (const clientId of update.updated) {
const state = states.get(clientId);
if (state?.presence && state.presence.id !== presenceId) {
toPut.push(state.presence);
}
}
for (const clientId of update.removed) {
toRemove.push(
InstancePresenceRecordType.createId(clientId.toString()),
);
}
// put / remove the records in the store
store.mergeRemoteChanges(() => {
if (toRemove.length) store.remove(toRemove);
if (toPut.length) store.put(toPut);
});
};
room.awareness.on("update", handleUpdate);
unsubs.push(() => room.awareness.off("update", handleUpdate));
// 2.
// Initialize the store with the yjs doc records—or, if the yjs doc
// is empty, initialize the yjs doc with the default store records.
if (yStore.yarray.length) {
// Replace the store records with the yjs doc records
transact(() => {
// The records here should be compatible with what's in the store
store.clear();
const records = yStore.yarray.toJSON().map(({ val }) => val);
store.put(records);
});
} else {
// Create the initial store records
// Sync the store records to the yjs doc
yDoc.transact(() => {
for (const record of store.allRecords()) {
yStore.set(record.id, record);
}
});
}
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
});
}
let hasConnectedBefore = false;
function handleStatusChange({
status,
}: {
status: "disconnected" | "connected";
}) {
// If we're disconnected, set the store status to 'synced-remote' and the connection status to 'offline'
if (status === "disconnected") {
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "offline",
});
return;
}
room.off("synced", handleSync);
if (status === "connected") {
if (hasConnectedBefore) return;
hasConnectedBefore = true;
room.on("synced", handleSync);
unsubs.push(() => room.off("synced", handleSync));
}
}
room.on("status", handleStatusChange);
unsubs.push(() => room.off("status", handleStatusChange));
return () => {
unsubs.forEach((fn) => fn());
unsubs.length = 0;
};
}, [room, yDoc, store, yStore]);
return storeWithStatus;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

12
vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
export default defineConfig({
plugins: [
react(),
wasm(),
topLevelAwait()
],
})

4559
yarn.lock Normal file

File diff suppressed because it is too large Load Diff