clear
This commit is contained in:
parent
d83f9e624d
commit
f3eb55cb51
|
|
@ -1,28 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"useTabs": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
15
README.md
15
README.md
|
|
@ -1,15 +0,0 @@
|
|||
# scoped proagators
|
||||
"Scoped propagators" are formed of a `scope` and a `propagator` which often looks like this:
|
||||
`click { text: "foo" }`
|
||||
|
||||
The `scope` sets the events that cause propagation, such as clicks, ticks, or shape changes (not adding a scope will default to shape changes).
|
||||
|
||||
The `propagator` is a JS object (or function which returns one) that is applied to the shape.
|
||||
|
||||
Notes:
|
||||
- shapes are passed both `from` and `to` shapes.
|
||||
- Shapes are flattened before being passed to the propagator, and unpacked on the other side. So properties live alongside the `x`, `y`, and `rotation` values (e.g. `{ x: 100, y: 100, text: "foo" }`).
|
||||
|
||||
Current Issues (probably should be fixed before putting out a demo):
|
||||
- cycles of `change` propagators cause infinite recursion.
|
||||
- `geo` scopes are currently fired for any shape change, this should be localised to spatially local changes.
|
||||
13
index.html
13
index.html
|
|
@ -1,13 +0,0 @@
|
|||
<!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
42
package.json
|
|
@ -1,42 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"name": "tlkart",
|
||||
"main": "src/server.ts",
|
||||
"serve": {
|
||||
"path": "dist"
|
||||
},
|
||||
"compatibilityDate": "2024-02-04"
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
138
src/App.tsx
138
src/App.tsx
|
|
@ -1,138 +0,0 @@
|
|||
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>
|
||||
)
|
||||
})
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
@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
10
src/main.tsx
|
|
@ -1,10 +0,0 @@
|
|||
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>,
|
||||
)
|
||||
|
|
@ -1,645 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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>
|
||||
)
|
||||
})
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
},
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
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 }
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './BaseCollection';
|
||||
export * from './CollectionProvider';
|
||||
export * from './useCollection';
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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 };
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,276 +0,0 @@
|
|||
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
35
src/users.ts
|
|
@ -1,35 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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')
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue