init
This commit is contained in:
commit
a91bfdb51b
|
|
@ -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: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"name": "ggraph",
|
||||||
|
"main": "src/server.ts",
|
||||||
|
"serve": {
|
||||||
|
"path": "dist"
|
||||||
|
},
|
||||||
|
"compatibilityDate": "2023-10-04"
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>,
|
||||||
|
);
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -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" }]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue