init demo repo

This commit is contained in:
Orion Reed 2024-06-25 11:51:41 +01:00
commit ee413478d4
28 changed files with 5057 additions and 0 deletions

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
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
dbDir
*.tsbuildinfo

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"useTabs": true,
"trailingComma": "all"
}

24
README.md Normal file
View File

@ -0,0 +1,24 @@
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!
## Bootsrapping Locally
To run the local development server, first clone this repo.
Install dependencies:
```bash
npm i
```
Start the local development server:
For macOS/Linux:
```bash
npm run dev
```
For Windows:
```bash
npm run dev:win
```
Open the example project at `localhost:5173`.

0
biome.json Normal file
View File

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./tldraw.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>multi</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "tldraw-governance",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "concurrently \"vite --host\" \"HOST=localhost PORT=1234 npx y-websocket\" --kill-others",
"dev:win": "concurrently \"vite\" \"set HOST=localhost&& set PORT=1234 && npx y-websocket\" --kill-others",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"deploy": "yarn build && npx partykit deploy",
"tsc": "tsc"
},
"dependencies": {
"@dimforge/rapier2d": "^0.11.2",
"partykit": "0.0.27",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tldraw": "2.2.0-canary.749b7d4d6dcc",
"y-partykit": "0.0.7",
"y-utility": "^0.1.3",
"y-websocket": "^2.0.2",
"yjs": "^13.6.14"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.3",
"concurrently": "^8.2.0",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"vite-plugin-top-level-await": "^1.3.1",
"vite-plugin-wasm": "^3.2.2"
}
}

8
partykit.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "tlkart",
"main": "src/server.ts",
"serve": {
"path": "dist"
},
"compatibilityDate": "2024-02-04"
}

11
public/tldraw.svg Normal file
View File

@ -0,0 +1,11 @@
<svg width="513" height="512" viewBox="0 0 513 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1053_2035)">
<path d="M0.501953 56.2637C0.501953 25.1901 24.6314 0 54.3967 0H458.607C488.372 0 512.502 25.1901 512.502 56.2637V455.736C512.502 486.81 488.372 512 458.607 512H54.3967C24.6315 512 0.501953 486.81 0.501953 455.736V56.2637Z" fill="white"/>
<path d="M298.763 146.432C298.763 158.806 294.534 169.305 286.076 177.929C277.617 186.553 267.32 190.865 255.184 190.865C242.68 190.865 232.199 186.553 223.741 177.929C215.283 169.305 211.054 158.806 211.054 146.432C211.054 134.059 215.283 123.56 223.741 114.936C232.199 106.312 242.68 102 255.184 102C267.32 102 277.617 106.312 286.076 114.936C294.534 123.56 298.763 134.059 298.763 146.432ZM210.502 302.149C210.502 289.775 214.731 279.276 223.189 270.652C232.016 261.653 242.68 257.154 255.184 257.154C266.952 257.154 277.249 261.653 286.076 270.652C294.902 279.276 300.05 289.025 301.521 299.899C304.463 320.147 300.786 340.207 290.489 360.08C280.559 379.952 266.217 395.138 247.461 405.637C237.164 411.636 228.706 411.449 222.086 405.074C215.834 399.075 217.673 391.951 227.603 383.702C233.119 379.577 237.716 374.328 241.393 367.954C245.071 361.579 247.461 355.018 248.565 348.268C248.932 345.269 247.645 343.769 244.703 343.769C237.348 343.394 229.809 339.269 222.086 331.395C214.363 323.521 210.502 313.772 210.502 302.149Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_1053_2035">
<rect width="512" height="512" fill="white" transform="translate(0.501953)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

138
src/App.tsx Normal file
View File

@ -0,0 +1,138 @@
import { Editor, TLArrowShape, Tldraw, track, useEditor } from 'tldraw'
import 'tldraw/tldraw.css'
import { useYjsStore } from './useYjsStore'
import { useEffect, useState } from 'react'
import { User } from './users'
// import { CustomToolbar, GMachineOverlay, overrides } from './ui'
import { CollectionProvider } from '@/tldraw-collections/CollectionProvider'
import { PhysicsCollection } from '@/physics/PhysicsCollection'
import { PhysicsUi } from '@/physics/ui/PhysicsUi'
const collections = [
PhysicsCollection
]
const HOST_URL =
//@ts-ignore
import.meta.env.MODE === 'development'
? "ws://localhost:1234"
//@ts-ignore
: location.origin.replace("https://", "ws://"); // remove protocol just in case
export default function YjsExample() {
const store = useYjsStore({
roomId: 'example17',
hostUrl: HOST_URL,
})
const [editor, setEditor] = useState<Editor | null>(null)
return (
<div className="tldraw__editor">
<Tldraw
store={store}
components={{
SharePanel: NameEditor,
// Toolbar: CustomToolbar,
// OnTheCanvas: GMachineOverlay,
}}
// overrides={overrides}
onMount={(e) => {
// mount(e)
setEditor(e)
}}
>
<CollectionProvider
editor={editor}
collections={collections}
addOnMount
>
<PhysicsUi />
</CollectionProvider>
</Tldraw>
</div>
)
}
// function mount(editor: Editor) {
// //@ts-expect-error ehh
// editor.getStateDescendant('select.idle').handleDoubleClickOnCanvas = () => void null
// // editor.sideEffects.registerAfterChangeHandler<'instan('instance_page_state', (prev, next, source) => {})
// editor.sideEffects.registerAfterChangeHandler("pointer", (prev, next, source) => {
// // console.log('TOOL', editor.getPath())
// if (!editor.isIn('select.pointing_shape')) return
// const _a = editor.getHoveredShape()
// const _m = editor.getShapeAtPoint(editor.inputs.currentPagePoint, { filter: (shape) => shape.type === 'gmachine' })
// if (_m && _m.type === 'gmachine' && _a && _a.type === 'arrow') {
// const machine = _m as IGMachineShape
// const arrow = _a as TLArrowShape
// const machineUtil = editor.getShapeUtil(machine) as GMachineShapeUtil
// machineUtil.machine.transition(machine, arrow.id)
// }
// })
// }
function useSessionStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [value, setValue] = useState<T>(() => {
const storedValue = sessionStorage.getItem(key);
return storedValue ? JSON.parse(storedValue) : initialValue;
});
useEffect(() => {
sessionStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
const setStoredValue = (newValue: T) => {
setValue(newValue);
};
return [value, setStoredValue];
}
const NameEditor = track(() => {
const editor = useEditor()
const [userPrefs, setUserPrefs] = useSessionStorage<User>('userPrefs', editor.user.getUserPreferences())
const editorPrefs = editor.user.getUserPreferences()
useEffect(() => {
if (userPrefs.id !== editorPrefs.id) {
editor.user.updateUserPreferences(userPrefs);
}
}, [userPrefs, editorPrefs, editor.user]);
const { color, name } = userPrefs
return (
<div style={{ pointerEvents: 'all', display: 'flex' }}>
<input
type="color"
value={color}
onChange={(e) => {
editor.user.updateUserPreferences({
color: e.currentTarget.value,
})
setUserPrefs({
...userPrefs,
color: e.currentTarget.value,
})
}}
/>
<input
value={name}
onChange={(e) => {
editor.user.updateUserPreferences({
name: e.currentTarget.value,
})
setUserPrefs({
...userPrefs,
name: e.currentTarget.value,
})
}}
/>
</div>
)
})

27
src/index.css Normal file
View File

@ -0,0 +1,27 @@
@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;
z-index: 0;
}

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 './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,645 @@
import { BaseCollection } from "@/tldraw-collections"
import {
Editor,
TLDrawShape,
TLGeoShape,
TLGroupShape,
TLParentId,
TLScribble,
TLShape,
TLShapeId,
Vec,
VecLike,
} from "tldraw"
import RAPIER from "@dimforge/rapier2d"
import {
MATERIAL,
centerToCorner,
convertVerticesToFloat32Array,
shouldConvexify,
cornerToCenter,
getFrictionFromColor,
getRestitutionFromColor,
isRigidbody,
} from "./utils"
type RigidbodyUserData = RAPIER.RigidBody & {
id: TLShapeId
type: TLShape["type"]
w: number
h: number
rbType: RAPIER.RigidBodyType
}
export class PhysicsCollection extends BaseCollection {
override id = "physics"
private world: RAPIER.World
private rigidbodyLookup: Map<TLShapeId, RAPIER.RigidBody>
private colliderLookup: Map<TLShapeId, RAPIER.Collider>
private carSet: Set<TLShapeId> = new Set()
private animFrame = -1 // Store the animation frame id
private shiftDriftFactor = 0.8
private tireMarks: Map<
TLShapeId,
{
drifting: boolean
backLeft: string
backRight: string
frontLeft: string
frontRight: string
}
> = new Map()
constructor(editor: Editor) {
super(editor)
this.world = new RAPIER.World({ x: 0, y: 0 })
this.rigidbodyLookup = new Map()
this.colliderLookup = new Map()
// oof
window.addEventListener("keydown", (e) => {
if (e.shiftKey) {
this.shiftDriftFactor = 1
}
})
window.addEventListener("keyup", (e) => {
if (!e.shiftKey && this.shiftDriftFactor === 1) {
const driftInterval = setInterval(() => {
this.shiftDriftFactor = Math.max(this.shiftDriftFactor * 0.99, 0.8)
if (this.shiftDriftFactor <= 0.8) {
clearInterval(driftInterval)
}
}, 40)
}
})
this.simStart()
}
override onAdd(shapes: TLShape[]) {
const parentShapes = new Set<TLParentId>()
for (const shape of shapes) {
if (shape.parentId !== "page:page") {
parentShapes.add(shape.parentId)
continue
}
if (shape.type === "group") {
parentShapes.add(shape.id)
continue
}
if (
this.colliderLookup.has(shape.id) ||
this.rigidbodyLookup.has(shape.id)
)
continue
if ("text" in shape.props && shape.props.text.toLowerCase() !== "") {
if (shape.props.text.toLowerCase() === this.editor.user.getName().toLowerCase()) {
this.createCar(shape as TLGeoShape)
this.carSet.add(shape.id)
}
continue
}
switch (shape.type) {
case "draw":
this.createCompoundLineObject(shape as TLDrawShape)
break
case "group":
this.createGroupObject(shape as TLGroupShape)
break
default:
this.createShape(shape)
break
}
}
for (const parent of parentShapes) {
const parentShape = this.editor.getShape(parent)
if (!parentShape || parentShape.type !== "group") continue
this.createGroupObject(parentShape as TLGroupShape)
}
}
override onRemove(shapes: TLShape[]) {
for (const shape of shapes) {
if (this.rigidbodyLookup.has(shape.id)) {
const rb = this.rigidbodyLookup.get(shape.id)
if (!rb) continue
this.world.removeRigidBody(rb)
this.rigidbodyLookup.delete(shape.id)
}
if (this.colliderLookup.has(shape.id)) {
const col = this.colliderLookup.get(shape.id)
if (!col) continue
this.world.removeCollider(col, true)
this.colliderLookup.delete(shape.id)
}
}
}
public simStart() {
const simLoop = () => {
this.world.step()
this.updateKinematic()
this.updateRigidbodies()
this.animFrame = requestAnimationFrame(simLoop)
}
simLoop()
return () => cancelAnimationFrame(this.animFrame)
}
public simStop() {
if (this.animFrame !== -1) {
cancelAnimationFrame(this.animFrame)
this.animFrame = -1
}
}
addCollider(
id: TLShapeId,
desc: RAPIER.ColliderDesc,
parentRigidBody?: RAPIER.RigidBody,
): RAPIER.Collider {
const col = this.world.createCollider(desc, parentRigidBody)
col && this.colliderLookup.set(id, col)
return col
}
addRigidbody(id: TLShapeId, desc: RAPIER.RigidBodyDesc) {
const rb = this.world.createRigidBody(desc)
rb && this.rigidbodyLookup.set(id, rb)
return rb
}
createShape(shape: TLShape) {
if ("dash" in shape.props && shape.props.dash === "dashed") return // Skip dashed shapes
if ("color" in shape.props && isRigidbody(shape.props.color)) {
const gravity = 0 //getGravityFromColor(shape.props.color)
const rb = this.createRigidbodyObject(shape, gravity)
this.createColliderObject(shape, rb)
} else {
this.createColliderObject(shape)
}
}
createCar(shape: TLShape) {
const gravity = 0
const rb = this.createRigidbodyObject(shape, gravity)
rb.enableCcd(true)
rb.setLinearDamping(0)
rb.setAngularDamping(1)
this.createColliderObject(shape, rb)
}
createGroupObject(group: TLGroupShape) {
// create rigidbody for group
const rigidbody = this.createRigidbodyObject(group)
this.editor.getSortedChildIdsForParent(group.id).forEach((childId) => {
// create collider for each
const child = this.editor.getShape(childId)
if (!child) return
const isRb = "color" in child.props && isRigidbody(child.props.color)
if (isRb) {
this.createColliderObject(child, rigidbody, group)
} else {
this.createColliderObject(child)
}
})
}
createCompoundLineObject(drawShape: TLDrawShape) {
const rigidbody = this.createRigidbodyObject(drawShape)
const drawnGeo = this.editor.getShapeGeometry(drawShape)
const verts = drawnGeo.vertices
const isRb =
"color" in drawShape.props && isRigidbody(drawShape.props.color)
verts.forEach((point) => {
if (isRb)
this.createColliderRelativeToParentObject(point, drawShape, rigidbody)
else this.createColliderRelativeToParentObject(point, drawShape)
})
}
private createRigidbodyObject(shape: TLShape, gravity = 1): RAPIER.RigidBody {
const { w, h } = this.getShapeSize(shape)
const centerPosition = cornerToCenter({
x: shape.x,
y: shape.y,
width: w,
height: h,
rotation: shape.rotation,
})
const rigidBodyDesc = RAPIER.RigidBodyDesc.dynamic()
.setTranslation(centerPosition.x, centerPosition.y)
.setRotation(shape.rotation)
.setGravityScale(gravity)
.setLinearDamping(3)
.setAngularDamping(6)
rigidBodyDesc.userData = {
id: shape.id,
type: shape.type,
w: w,
h: h,
rbType: RAPIER.RigidBodyType.Dynamic,
}
const rigidbody = this.addRigidbody(shape.id, rigidBodyDesc)
return rigidbody
}
private createColliderRelativeToParentObject(
point: VecLike,
relativeToParent: TLDrawShape,
parentRigidBody: RAPIER.RigidBody | null = null,
) {
const radius = 3
const center = cornerToCenter({
x: point.x,
y: point.y,
width: radius,
height: radius,
rotation: 0,
parentGroupShape: relativeToParent,
})
let colliderDesc: RAPIER.ColliderDesc | null = null
colliderDesc = RAPIER.ColliderDesc.ball(radius)
if (!colliderDesc) {
console.error("Failed to create collider description.")
return
}
if (parentRigidBody) {
colliderDesc.setTranslation(center.x, center.y)
this.addCollider(relativeToParent.id, colliderDesc, parentRigidBody)
} else {
colliderDesc.setTranslation(
relativeToParent.x + center.x,
relativeToParent.y + center.y,
)
this.addCollider(relativeToParent.id, colliderDesc)
}
}
private createColliderObject(
shape: TLShape,
parentRigidBody: RAPIER.RigidBody | null = null,
parentGroup: TLGroupShape | undefined = undefined,
) {
const { w, h } = this.getShapeSize(shape)
const parentGroupShape = parentGroup
? (this.editor.getShape(parentGroup.id) as TLGroupShape)
: undefined
const centerPosition = cornerToCenter({
x: shape.x,
y: shape.y,
width: w,
height: h,
rotation: shape.rotation,
parentGroupShape: parentGroupShape,
})
const restitution =
"color" in shape.props
? getRestitutionFromColor(shape.props.color)
: MATERIAL.defaultRestitution
const friction =
"color" in shape.props
? getFrictionFromColor(shape.props.color)
: MATERIAL.defaultFriction
let colliderDesc: RAPIER.ColliderDesc | null = null
if (shouldConvexify(shape)) {
// Convert vertices for convex shapes
const vertices = this.editor.getShapeGeometry(shape).vertices
const vec2Array = convertVerticesToFloat32Array(vertices, w, h)
colliderDesc = RAPIER.ColliderDesc.convexHull(vec2Array)
} else {
// Cuboid for rectangle shapes
colliderDesc = RAPIER.ColliderDesc.cuboid(w / 2, h / 2)
}
if (!colliderDesc) {
console.error("Failed to create collider description.")
return
}
colliderDesc
.setRestitution(restitution)
.setRestitutionCombineRule(RAPIER.CoefficientCombineRule.Max)
.setFriction(friction)
.setFrictionCombineRule(RAPIER.CoefficientCombineRule.Min)
if (parentRigidBody) {
if (parentGroup) {
colliderDesc.setTranslation(centerPosition.x, centerPosition.y)
colliderDesc.setRotation(shape.rotation)
}
this.addCollider(shape.id, colliderDesc, parentRigidBody)
} else {
colliderDesc
.setTranslation(centerPosition.x, centerPosition.y)
.setRotation(shape.rotation)
this.addCollider(shape.id, colliderDesc)
}
}
updateCar(rb: RAPIER.RigidBody) {
// Config
const accelerationFactor = 15 ** 6
const turnSpeedFactor = 1000
const turnFactor = 2.5 * (Math.PI / 180)
const driftFactor = 0.98
const maxSpeed = 1000
const tireMarkThreshold = 200
const tireScribble: Partial<TLScribble> = {
color: "muted-1",
size: 4,
taper: false,
// state: "paused",
}
rb.collider(0).setFriction(0.1)
// Utils
const clamp01 = (value: number) => {
return Math.max(Math.min(value, 1), 0)
}
const magnitude = (v: VecLike) => {
return Math.sqrt(v.x ** 2 + v.y ** 2)
}
const dotProduct = (v1: VecLike, v2: VecLike) => {
return v1.x * v2.x + v1.y * v2.y
}
const getInput = () => {
const forward = this.editor.inputs.keys.has("ArrowUp") ? 1 : 0
const backward = this.editor.inputs.keys.has("ArrowDown") ? -1 : 0
const right = this.editor.inputs.keys.has("ArrowRight") ? 1 : 0
const left = this.editor.inputs.keys.has("ArrowLeft") ? -1 : 0
return new Vec(right + left, forward + backward)
}
// Inputs
const input = getInput()
// const shiftDriftFactor = () => (this.editor.inputs.altKey ? 1 : 0)
const getForwardVector = (r: RAPIER.RigidBody) =>
new Vec(Math.sin(r.rotation()), -Math.cos(r.rotation()))
const getRightVector = (r: RAPIER.RigidBody) =>
new Vec(Math.cos(r.rotation()), Math.sin(r.rotation()))
const engineForceVector: Vec = getForwardVector(rb)
.mul(input.y)
.mul(accelerationFactor)
const lockCameraToCar = () => {
const pos = rb.translation()
const camBounds = this.editor.getViewportPageBounds()
const camPos = this.editor.getCamera()
this.editor.setCamera({
x: -pos.x + camBounds.width / 2,
y: -pos.y + camBounds.height / 2,
z: camPos.z,
})
}
const applyEngineForce = () => {
rb.resetForces(true)
if (magnitude(rb.linvel()) < maxSpeed) {
rb.addForce(engineForceVector, true)
}
if (input.y === 0) {
const drag = 0.01 + rb.linearDamping()
rb.setLinearDamping(drag)
} else {
rb.setLinearDamping(0)
}
}
const applyTurn = () => {
const minSpeedTurnFactor = clamp01(
magnitude(rb.linvel()) / turnSpeedFactor,
)
const direction =
dotProduct(getForwardVector(rb), rb.linvel()) < 0 ? -1 : 1 // Reverse direction when going backward
const rotation =
rb.rotation() + input.x * turnFactor * minSpeedTurnFactor * direction
rb.setRotation(rotation, true)
}
const applyDrift = () => {
const forwardVector = getForwardVector(rb)
const rightVector = getRightVector(rb)
const forwardDot = dotProduct(forwardVector, rb.linvel())
const rightDot = dotProduct(rightVector, rb.linvel())
const forwardVelocity = forwardVector.mul(forwardDot)
const rightVelocity = rightVector.mul(rightDot)
// const shiftDriftFactor = this.shiftKey ? 1 : 0.4
console.log(this.shiftDriftFactor)
rb.setLinvel(
forwardVelocity.add(
rightVelocity.mul(driftFactor * this.shiftDriftFactor),
),
true,
)
}
const addTireMarks = () => {
const rightVector = getRightVector(rb)
const rightDot = dotProduct(rightVector, rb.linvel())
const rightVelocity = rightVector.mul(rightDot)
const driftMagnitude = magnitude(rightVelocity)
const carShape = this.editor.getShape((rb.userData as RigidbodyUserData).id) as TLShape
const carTransform = this.editor.getShapePageTransform(carShape.id)
const carGeo = this.editor.getShapeGeometry(carShape.id)
const tireCornerOffset = 20
const backLeftTirePos = carTransform.applyToPoint({
x: carGeo.bounds.minX + tireCornerOffset,
y: carGeo.bounds.maxY - tireCornerOffset,
})
const backRightTirePos = carTransform.applyToPoint({
x: carGeo.bounds.maxX - tireCornerOffset,
y: carGeo.bounds.maxY - tireCornerOffset,
})
const frontLeftTirePos = carTransform.applyToPoint({
x: carGeo.bounds.minX + tireCornerOffset,
y: carGeo.bounds.minY + tireCornerOffset,
})
const frontRightTirePos = carTransform.applyToPoint({
x: carGeo.bounds.maxX - tireCornerOffset,
y: carGeo.bounds.minY + tireCornerOffset,
})
// if drifting
if (driftMagnitude > tireMarkThreshold) {
const tireMarks = this.tireMarks.get(carShape.id)
if (tireMarks?.drifting) {
// const tireMarks = this.tireMarks.get(carShape.id)
if (tireMarks) {
this.tireMarks.set(carShape.id, {
drifting: true,
backLeft: tireMarks.backLeft,
backRight: tireMarks.backRight,
frontLeft: tireMarks.frontLeft,
frontRight: tireMarks.frontRight,
})
}
this.editor.scribbles.addPoint(
tireMarks.backLeft,
backLeftTirePos.x,
backLeftTirePos.y,
)
this.editor.scribbles.addPoint(
tireMarks.backRight,
backRightTirePos.x,
backRightTirePos.y,
)
this.editor.scribbles.addPoint(
tireMarks.frontLeft,
frontLeftTirePos.x,
frontLeftTirePos.y,
)
this.editor.scribbles.addPoint(
tireMarks.frontRight,
frontRightTirePos.x,
frontRightTirePos.y,
)
}
if (!tireMarks?.drifting) {
const scribbleBackLeft =
this.editor.scribbles.addScribble(tireScribble)
const scribbleBackRight =
this.editor.scribbles.addScribble(tireScribble)
const scribbleFrontLeft =
this.editor.scribbles.addScribble(tireScribble)
const scribbleFrontRight =
this.editor.scribbles.addScribble(tireScribble)
this.tireMarks.set(carShape.id, {
backLeft: scribbleBackLeft.id,
backRight: scribbleBackRight.id,
frontLeft: scribbleFrontLeft.id,
frontRight: scribbleFrontRight.id,
drifting: true,
})
}
} else {
const tireMarks = this.tireMarks.get(carShape.id)
if (tireMarks) {
this.tireMarks.set(carShape.id, {
drifting: false,
backLeft: tireMarks.backLeft,
backRight: tireMarks.backRight,
frontLeft: tireMarks.frontLeft,
frontRight: tireMarks.frontRight,
})
}
}
}
applyEngineForce()
applyTurn()
applyDrift()
addTireMarks()
lockCameraToCar() // make this a toggle
}
updateRigidbodies() {
this.world.bodies.forEach((rb) => {
if (!rb.userData) return
const userData = rb.userData as RigidbodyUserData
if (
this.editor.getSelectedShapeIds().includes(userData.id) &&
!this.editor.isIn("select.idle")
) {
console.log("selected")
return
}
// CAR CONTROL
if (this.carSet.has(userData.id)) {
this.updateCar(rb)
}
rb.setBodyType(userData.rbType, true)
const position = rb.translation()
const rotation = rb.rotation()
const cornerPos = centerToCorner({
x: position.x,
y: position.y,
width: userData.w,
height: userData.h,
rotation: rotation,
})
this.editor.updateShape({
id: userData.id,
type: userData.type,
rotation: rotation,
x: cornerPos.x,
y: cornerPos.y,
})
})
}
// kinematicShapes(): TLShape[] {
// const selected = this.editor.getSelectedShapeIds()
// const
// }
updateKinematic() {
const multiplayerSelection = this.editor.getCollaboratorsOnCurrentPage().flatMap((c) => {
return c.selectedShapeIds
})
const s = new Set([...multiplayerSelection, ...this.editor.getSelectedShapeIds()])
for (const id of s) {
// if (this.editor.isIn("select.idle")) continue
const shape = this.editor.getShape(id)
if (!shape) continue
// if ("text" in shape.props && shape.props.text) {
// continue
// }
const col = this.colliderLookup.get(id)
const rb = this.rigidbodyLookup.get(id)
const { w, h } = this.getShapeSize(shape)
const centerPos = cornerToCenter({
x: shape.x,
y: shape.y,
width: w,
height: h,
rotation: shape.rotation,
})
if (col && rb) {
const userData = rb.userData as RigidbodyUserData
if (!rb.isKinematic())
rb.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true)
rb.setNextKinematicTranslation({ x: centerPos.x, y: centerPos.y })
rb.setNextKinematicRotation(shape.rotation)
col.setHalfExtents({ x: w / 2, y: h / 2 })
// userData.w = w
// userData.h = h
continue
}
if (rb) {
if (!rb.isKinematic())
rb.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true)
rb.setNextKinematicTranslation({ x: centerPos.x, y: centerPos.y })
rb.setNextKinematicRotation(shape.rotation)
continue
}
if (col) {
col.setTranslation({ x: centerPos.x, y: centerPos.y })
col.setRotation(shape.rotation)
col.setHalfExtents({ x: w / 2, y: h / 2 })
// TODO: update dimensions for all shapes
}
}
}
private getShapeSize = (shape: TLShape): { w: number; h: number } => {
const { w, h } = this.editor.getShapeGeometry(shape).bounds
return { w, h }
}
}

View File

@ -0,0 +1,46 @@
import { track, useEditor } from "tldraw"
import { useEffect, useState } from "react"
import "./physics-ui.css"
import { useCollection } from "@/tldraw-collections"
export const PhysicsUi = track(() => {
const editor = useEditor()
// const [init, setInit] = useState(false)
const { collection, size } = useCollection("physics")
// if (collection && size === 0 && !init) {
// setInit(true)
// // collection.add(editor.getCurrentPageShapes())
// }
const handleShortcut = () => {
if (!collection) return
if (size === 0) collection.add(editor.getCurrentPageShapes())
else collection.clear()
}
useEffect(() => {
window.addEventListener("togglePhysicsEvent", handleShortcut)
return () => {
window.removeEventListener("togglePhysicsEvent", handleShortcut)
}
}, [handleShortcut])
return (
<div className="custom-layout">
<div className="custom-toolbar">
<div>
<button
type="button"
className="custom-button"
style={{ backgroundColor: size === 0 ? "white" : "#bdffc8" }}
onClick={handleShortcut}
>
{size === 0 ? "Editing" : "Playing"}
</button>
</div>
<span>{size} shapes</span>
</div>
</div>
)
})

View File

@ -0,0 +1,22 @@
import {
TLUiEventSource,
TLUiOverrides,
TLUiTranslationKey,
} from "tldraw";
// In order to see select our custom shape tool, we need to add it to the ui.
export const uiOverrides: TLUiOverrides = {
actions(_editor, actions) {
actions['toggle-physics'] = {
id: 'toggle-physics',
label: 'Toggle Physics' as TLUiTranslationKey,
readonlyOk: true,
kbd: 'p',
onSelect(_source: TLUiEventSource) {
const event = new CustomEvent('togglePhysicsEvent');
window.dispatchEvent(event);
},
}
return actions
},
}

View File

@ -0,0 +1,40 @@
.custom-layout {
position: absolute;
inset: 0px;
z-index: 300;
pointer-events: none;
}
.custom-toolbar {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
padding: 8px;
& > * {
padding: 2px;
font-family: monospace;
color: #0000008a;
gap: 8px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: all;
}
}
.custom-button {
pointer-events: all;
padding: 4px 12px;
background: white;
border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 64px;
&:hover {
background-color: rgb(240, 240, 240);
}
}
.custom-button[data-isactive="true"] {
background-color: black;
color: white;
}

136
src/physics/utils.ts Normal file
View File

@ -0,0 +1,136 @@
import { TLGeoShape, TLShape, Vec, VecLike } from "tldraw";
export const GRAVITY = { x: 0.0, y: 98 };
export const DEFAULT_RESTITUTION = 0;
export const DEFAULT_FRICTION = 0.5;
export function isRigidbody(color: string) {
return !color || color === "black" ? false : true;
}
export function getGravityFromColor(color: string) {
return color === 'grey' ? 0 : 1
}
export function getRestitutionFromColor(color: string) {
return color === "orange" ? 0.9 : 0;
}
export function getFrictionFromColor(color: string) {
return color === "blue" ? 0.1 : 0.8;
}
export const MATERIAL = {
defaultRestitution: 0,
defaultFriction: 0.1,
};
export const CHARACTER = {
up: { x: 0.0, y: -1.0 },
additionalMass: 20,
maxSlopeClimbAngle: 1,
slideEnabled: true,
minSlopeSlideAngle: 0.9,
applyImpulsesToDynamicBodies: true,
autostepHeight: 5,
autostepMaxClimbAngle: 1,
snapToGroundDistance: 3,
maxMoveSpeedX: 100,
moveAcceleration: 600,
moveDeceleration: 500,
jumpVelocity: 300,
gravityMultiplier: 10,
};
type ShapeTransform = {
x: number;
y: number;
width: number;
height: number;
rotation: number;
parentGroupShape?: TLShape | undefined
}
// Define rotatePoint as a standalone function
const rotatePoint = (cx: number, cy: number, x: number, y: number, angle: number) => {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return {
x: cos * (x - cx) - sin * (y - cy) + cx,
y: sin * (x - cx) + cos * (y - cy) + cy,
};
}
export const cornerToCenter = ({
x,
y,
width,
height,
rotation,
parentGroupShape
}: ShapeTransform): { x: number; y: number } => {
const centerX = x + width / 2;
const centerY = y + height / 2;
if (parentGroupShape) {
return rotatePoint(parentGroupShape.x, parentGroupShape.y, centerX, centerY, rotation);
}
return rotatePoint(x, y, centerX, centerY, rotation);
}
export const centerToCorner = ({
x,
y,
width,
height,
rotation,
}: ShapeTransform): { x: number; y: number } => {
const cornerX = x - width / 2;
const cornerY = y - height / 2;
return rotatePoint(x, y, cornerX, cornerY, rotation);
}
export const getDisplacement = (
velocity: VecLike,
acceleration: VecLike,
timeStep: number,
speedLimitX: number,
decelerationX: number,
): VecLike => {
let newVelocityX =
acceleration.x === 0 && velocity.x !== 0
? Math.max(Math.abs(velocity.x) - decelerationX * timeStep, 0) *
Math.sign(velocity.x)
: velocity.x + acceleration.x * timeStep;
newVelocityX =
Math.min(Math.abs(newVelocityX), speedLimitX) * Math.sign(newVelocityX);
const averageVelocityX = (velocity.x + newVelocityX) / 2;
const x = averageVelocityX * timeStep;
const y =
velocity.y * timeStep + 0.5 * acceleration.y * timeStep ** 2;
return { x, y }
}
export const convertVerticesToFloat32Array = (
vertices: Vec[],
width: number,
height: number,
) => {
const vec2Array = new Float32Array(vertices.length * 2);
const hX = width / 2;
const hY = height / 2;
for (let i = 0; i < vertices.length; i++) {
vec2Array[i * 2] = vertices[i].x - hX;
vec2Array[i * 2 + 1] = vertices[i].y - hY;
}
return vec2Array;
}
export const shouldConvexify = (shape: TLShape): boolean => {
return !(
shape.type === "geo" && (shape as TLGeoShape).props.geo === "rectangle"
);
}

11
src/server.ts Normal file
View File

@ -0,0 +1,11 @@
import type * as Party from "partykit/server";
import { onConnect } from "y-partykit";
export default class YjsServer implements Party.Server {
constructor(public party: Party.Party) { }
onConnect(conn: Party.Connection) {
return onConnect(conn, this.party, {
// ...options
});
}
}

View File

@ -0,0 +1,107 @@
import { Editor, TLShape, TLShapeId } from 'tldraw';
/**
* A PoC abstract collections class for @tldraw.
*/
export abstract class BaseCollection {
/** A unique identifier for the collection. */
abstract id: string;
/** A map containing the shapes that belong to this collection, keyed by their IDs. */
protected shapes: Map<TLShapeId, TLShape> = new Map();
/** A reference to the \@tldraw Editor instance. */
protected editor: Editor;
/** A set of listeners to be notified when the collection changes. */
private listeners = new Set<() => void>();
// TODO: Maybe pass callback to replace updateShape so only CollectionProvider can call it
public constructor(editor: Editor) {
this.editor = editor;
}
/**
* Called when shapes are added to the collection.
* @param shapes The shapes being added to the collection.
*/
protected onAdd(_shapes: TLShape[]): void { }
/**
* Called when shapes are removed from the collection.
* @param shapes The shapes being removed from the collection.
*/
protected onRemove(_shapes: TLShape[]) { }
/**
* Called when the membership of the collection changes (i.e., when shapes are added or removed).
*/
protected onMembershipChange() { }
/**
* Called when the properties of a shape belonging to the collection change.
* @param prev The previous version of the shape before the change.
* @param next The updated version of the shape after the change.
*/
protected onShapeChange(_prev: TLShape, _next: TLShape) { }
/**
* Adds the specified shapes to the collection.
* @param shapes The shapes to add to the collection.
*/
public add(shapes: TLShape[]) {
shapes.forEach(shape => {
this.shapes.set(shape.id, shape)
});
this.onAdd(shapes);
this.onMembershipChange();
this.notifyListeners();
}
/**
* Removes the specified shapes from the collection.
* @param shapes The shapes to remove from the collection.
*/
public remove(shapes: TLShape[]) {
shapes.forEach(shape => {
this.shapes.delete(shape.id);
});
this.onRemove(shapes);
this.onMembershipChange();
this.notifyListeners();
}
/**
* Clears all shapes from the collection.
*/
public clear() {
this.remove([...this.shapes.values()])
}
/**
* Returns the map of shapes in the collection.
* @returns The map of shapes in the collection, keyed by their IDs.
*/
public getShapes(): Map<TLShapeId, TLShape> {
return this.shapes;
}
public get size(): number {
return this.shapes.size;
}
public _onShapeChange(prev: TLShape, next: TLShape) {
this.shapes.set(next.id, next)
this.onShapeChange(prev, next)
this.notifyListeners();
}
private notifyListeners() {
for (const listener of this.listeners) {
listener();
}
}
public subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}

View File

@ -0,0 +1,108 @@
import React, { createContext, useEffect, useMemo, useState } from "react"
import { TLShape, TLRecord, Editor, useEditor } from "tldraw"
import { BaseCollection } from "./BaseCollection"
interface CollectionContextValue {
get: (id: string) => BaseCollection | undefined
}
type Collection = new (editor: Editor) => BaseCollection
interface CollectionProviderProps {
editor: Editor
collections: Collection[]
addOnMount?: boolean
children: React.ReactNode
}
const CollectionContext = createContext<CollectionContextValue | undefined>(
undefined,
)
const CollectionProvider: React.FC<CollectionProviderProps> = ({
editor,
collections: collectionClasses,
addOnMount = false,
children,
}) => {
const [collections, setCollections] = useState<Map<
string,
BaseCollection
> | null>(null)
// Handle shape property changes
const handleShapeChange = (prev: TLShape, next: TLShape) => {
if (!collections) return // Ensure collections is not null
for (const collection of collections.values()) {
if (collection.getShapes().has(next.id)) {
collection._onShapeChange(prev, next)
}
}
}
// Handle shape deletions
const handleShapeDelete = (shape: TLShape) => {
if (!collections) return // Ensure collections is not null
for (const collection of collections.values()) {
collection.remove([shape])
}
}
useEffect(() => {
if (editor) {
const initializedCollections = new Map<string, BaseCollection>()
for (const ColClass of collectionClasses) {
const instance = new ColClass(editor)
initializedCollections.set(instance.id, instance)
}
setCollections(initializedCollections)
}
}, [editor, collectionClasses])
// Subscribe to shape changes in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterChangeHandler("shape", (prev, next, source) => {
if (next.typeName !== "shape") return
const prevShape = prev as TLShape
const nextShape = next as TLShape
handleShapeChange(prevShape, nextShape)
})
// editor.store.onAfterChange = (prev: TLRecord, next: TLRecord) => {
// if (next.typeName !== "shape") return
// const prevShape = prev as TLShape
// const nextShape = next as TLShape
// handleShapeChange(prevShape, nextShape)
// }
}
}, [editor, collections])
// Subscribe to shape deletions in the editor
useEffect(() => {
if (editor && collections) {
// editor.store.onAfterDelete = (prev: TLRecord, _: string) => {
// if (prev.typeName === "shape") handleShapeDelete(prev)
// }
editor.sideEffects.registerAfterDeleteHandler("shape", (shape, source) => {
if (shape.typeName !== "shape") return
const nextShape = shape as TLShape
handleShapeDelete(nextShape)
})
}
}, [editor, collections])
const value = useMemo(
() => ({
get: (id: string) => collections?.get(id),
}),
[collections],
)
return (
<CollectionContext.Provider value={value}>
{collections ? children : null}
</CollectionContext.Provider>
)
}
export { CollectionContext, CollectionProvider, type Collection }

View File

@ -0,0 +1,3 @@
export * from './BaseCollection';
export * from './CollectionProvider';
export * from './useCollection';

View File

@ -0,0 +1,32 @@
import { useContext, useEffect, useState } from "react";
import { CollectionContext } from "./CollectionProvider";
import { BaseCollection } from "./BaseCollection";
export const useCollection = <T extends BaseCollection = BaseCollection>(collectionId: string): { collection: T; size: number } => {
const context = useContext(CollectionContext);
if (!context) {
throw new Error("CollectionContext not found.");
}
const collection = context.get(collectionId);
if (!collection) {
throw new Error(`Collection with id '${collectionId}' not found`);
}
const [size, setSize] = useState<number>(collection.size);
useEffect(() => {
// Subscribe to collection changes
const unsubscribe = collection.subscribe(() => {
setSize(collection.size);
});
// Set initial size
setSize(collection.size);
return unsubscribe; // Cleanup on unmount
}, [collection]);
return { collection: collection as T, size };
};

15
src/useRandomName.ts Normal file

File diff suppressed because one or more lines are too long

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;
}

35
src/users.ts Normal file
View File

@ -0,0 +1,35 @@
import { Editor } from "tldraw"
export type User = {
id: string
color: string
name: string
}
export const getRoomMembers = (editor: Editor): User[] => {
const collaborators = editor.getCollaboratorsOnCurrentPage()
const user = editor.user.getUserPreferences()
const roomMembers: User[] = collaborators.filter(user => user.userName !== "New User").map(u => {
return {
id: u.userId,
name: u.userName,
color: u.color
}
})
roomMembers.push({
id: user.id,
name: user.name,
color: user.color
})
return roomMembers
}
export const getCurrentUser = (editor: Editor): User => {
const user = editor.user.getUserPreferences()
return {
id: user.id,
name: user.name,
color: user.color
}
}

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"composite": true,
"allowSyntheticDefaultImports": true,
"useDefineForClassFields": true,
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src","vite.config.ts"],
"types": ["vite/client"]
}

11
tsconfig.node.json Normal file
View File

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

14
vite.config.ts Normal file
View File

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

3226
yarn.lock Normal file

File diff suppressed because it is too large Load Diff