├── .yarnrc.yml
├── src
├── vite-env.d.ts
├── main.tsx
├── server.ts
├── css
│ ├── physics-ui.css
│ └── index.css
├── physics
│ ├── ui
│ │ ├── overrides.ts
│ │ └── PhysicsUi.tsx
│ ├── utils.ts
│ └── PhysicsCollection.tsx
├── App.tsx
├── default_store.ts
└── useYjsStore.ts
├── tldraw-collections
├── .yarn
│ └── install-state.gz
├── src
│ ├── index.ts
│ ├── useCollection.ts
│ ├── CollectionProvider.tsx
│ └── BaseCollection.ts
├── tsconfig.json
├── package.json
└── yarn.lock
├── partykit.json
├── tsconfig.node.json
├── vite.config.ts
├── index.html
├── biome.json
├── .gitignore
├── .github
└── workflows
│ └── deploy.yml
├── tsconfig.json
├── package.json
└── README.md
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tldraw-collections/.yarn/install-state.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OrionReed/tldraw-physics/HEAD/tldraw-collections/.yarn/install-state.gz
--------------------------------------------------------------------------------
/tldraw-collections/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './BaseCollection';
2 | export * from './CollectionProvider';
3 | export * from './useCollection';
--------------------------------------------------------------------------------
/partykit.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "canvas",
3 | "main": "src/server.ts",
4 | "serve": {
5 | "path": "dist"
6 | },
7 | "compatibilityDate": "2024-01-01"
8 | }
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App.tsx";
4 | import "./css/index.css";
5 |
6 | ReactDOM.createRoot(document.getElementById("root")!).render(
7 |
8 |
9 | ,
10 | );
11 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 | import wasm from "vite-plugin-wasm";
4 | import topLevelAwait from "vite-plugin-top-level-await";
5 |
6 | export default defineConfig({
7 | plugins: [
8 | react(),
9 | wasm(),
10 | topLevelAwait()
11 | ],
12 | })
13 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | physics in tldraw
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | import * as Party from "partykit/server";
2 | import { onConnect } from "y-partykit";
3 |
4 | export default {
5 | async onConnect(conn: Party.Connection, room: Party.Party) {
6 | return await onConnect(conn, room, {
7 | // experimental: persist the document to partykit's room storage
8 | persist: true,
9 | });
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/tldraw-collections/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "commonjs",
5 | "declaration": true,
6 | "outDir": "./dist",
7 | "strict": true,
8 | "jsx": "react-jsx",
9 | "esModuleInterop": true,
10 | "skipLibCheck": true
11 | },
12 | "include": [
13 | "src"
14 | ],
15 | "exclude": [
16 | "node_modules",
17 | "dist"
18 | ]
19 | }
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.4.1/schema.json",
3 | "organizeImports": {
4 | "enabled": true
5 | },
6 | "files": {
7 | "ignore": [
8 | "src/hull"
9 | ]
10 | },
11 | "linter": {
12 | "enabled": true,
13 | "rules": {
14 | "recommended": true,
15 | "complexity": {
16 | "noForEach": "off"
17 | },
18 | "correctness": {
19 | "useExhaustiveDependencies": "off"
20 | }
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | .vscode
15 | .pnp.*
16 | .yarn/*
17 | !.yarn/patches
18 | !.yarn/plugins
19 | !.yarn/releases
20 | !.yarn/sdks
21 | !.yarn/versions
22 |
23 | # Editor directories and files
24 | .idea
25 | .DS_Store
26 | *.suo
27 | *.ntvs*
28 | *.njsproj
29 | *.sln
30 | *.sw?
31 | .vercel
32 | .env
33 |
--------------------------------------------------------------------------------
/tldraw-collections/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@orion/tldraw-collections",
3 | "version": "0.1.1",
4 | "main": "dist/index.js",
5 | "types": "dist/index.d.ts",
6 | "files": [
7 | "dist"
8 | ],
9 | "scripts": {
10 | "build": "tsc",
11 | "watch": "tsc --watch",
12 | "prepublish": "yarn build"
13 | },
14 | "peerDependencies": {
15 | "@tldraw/tldraw": "2.0.0-beta.2",
16 | "react": "^18.2.0"
17 | },
18 | "devDependencies": {
19 | "@types/react": "^18.2.15",
20 | "typescript": "^5.0.2"
21 | }
22 | }
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | - pages-demo
8 | permissions:
9 | contents: write
10 |
11 | jobs:
12 | build-and-deploy:
13 | concurrency: ci-${{ github.ref }}
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout 🛎️
17 | uses: actions/checkout@v3
18 |
19 | - name: Enable Corepack 📦
20 | run: |
21 | corepack enable
22 | corepack prepare yarn@4.0.2 --activate
23 |
24 |
25 | - name: Build 🔧
26 | run: |
27 | yarn install
28 | npm run build
29 |
30 | - name: Deploy 🚀
31 | uses: JamesIves/github-pages-deploy-action@v4
32 | with:
33 | folder: dist
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": false,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src", "tldraw-collections/src/BaseCollection.ts", "tldraw-collections/src/CollectionProvider.tsx", "tldraw-collections/src/useCollection.ts"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/src/css/physics-ui.css:
--------------------------------------------------------------------------------
1 | .custom-layout {
2 | position: absolute;
3 | inset: 0px;
4 | z-index: 300;
5 | pointer-events: none;
6 | }
7 |
8 | .custom-toolbar {
9 | position: absolute;
10 | top: 0px;
11 | left: 0px;
12 | width: 100%;
13 | padding: 8px;
14 | & > * {
15 | padding: 2px;
16 | font-family: monospace;
17 | color: #0000008a;
18 | gap: 8px;
19 | display: flex;
20 | align-items: center;
21 | justify-content: center;
22 | pointer-events: all;
23 | }
24 | }
25 |
26 | .custom-button {
27 | pointer-events: all;
28 | padding: 4px 12px;
29 | background: white;
30 | border: 1px solid rgba(0, 0, 0, 0.3);
31 | border-radius: 64px;
32 | &:hover {
33 | background-color: rgb(240, 240, 240);
34 | }
35 | }
36 |
37 | .custom-button[data-isactive="true"] {
38 | background-color: black;
39 | color: white;
40 | }
41 |
--------------------------------------------------------------------------------
/src/physics/ui/overrides.ts:
--------------------------------------------------------------------------------
1 | import {
2 | TLUiEventSource,
3 | TLUiMenuGroup,
4 | TLUiOverrides,
5 | TLUiTranslationKey,
6 | menuItem,
7 | } from "@tldraw/tldraw";
8 |
9 | // In order to see select our custom shape tool, we need to add it to the ui.
10 | export const uiOverrides: TLUiOverrides = {
11 | actions(_editor, actions) {
12 | actions['toggle-physics'] = {
13 | id: 'toggle-physics',
14 | label: 'Toggle Physics' as TLUiTranslationKey,
15 | readonlyOk: true,
16 | kbd: 'p',
17 | onSelect(_source: TLUiEventSource) {
18 | const event = new CustomEvent('togglePhysicsEvent');
19 | window.dispatchEvent(event);
20 | },
21 | }
22 | return actions
23 | },
24 | keyboardShortcutsMenu(_editor, shortcutsMenu, { actions }) {
25 | const editGroup = shortcutsMenu.find(
26 | (group) => group.id === 'shortcuts-dialog.tools'
27 | ) as TLUiMenuGroup
28 |
29 | editGroup.children.push(menuItem(actions['toggle-physics']))
30 | return shortcutsMenu
31 | },
32 | }
--------------------------------------------------------------------------------
/tldraw-collections/src/useCollection.ts:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect, useState } from "react";
2 | import { CollectionContext } from "./CollectionProvider";
3 | import { BaseCollection } from "./BaseCollection";
4 |
5 | export const useCollection = (collectionId: string): { collection: T; size: number } => {
6 | const context = useContext(CollectionContext);
7 | if (!context) {
8 | throw new Error("CollectionContext not found.");
9 | }
10 |
11 | const collection = context.get(collectionId);
12 | if (!collection) {
13 | throw new Error(`Collection with id '${collectionId}' not found`);
14 | }
15 |
16 | const [size, setSize] = useState(collection.size);
17 |
18 | useEffect(() => {
19 | // Subscribe to collection changes
20 | const unsubscribe = collection.subscribe(() => {
21 | setSize(collection.size);
22 | });
23 |
24 | // Set initial size
25 | setSize(collection.size);
26 |
27 | return unsubscribe; // Cleanup on unmount
28 | }, [collection]);
29 |
30 | return { collection: collection as T, size };
31 | };
32 |
33 |
--------------------------------------------------------------------------------
/src/css/index.css:
--------------------------------------------------------------------------------
1 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap");
2 |
3 | html,
4 | body {
5 | padding: 0;
6 | margin: 0;
7 | font-family: "Inter", sans-serif;
8 | overscroll-behavior: none;
9 | touch-action: none;
10 | min-height: 100vh;
11 | font-size: 16px;
12 | /* mobile viewport bug fix */
13 | min-height: -webkit-fill-available;
14 | height: 100%;
15 | }
16 |
17 | html,
18 | * {
19 | box-sizing: border-box;
20 | }
21 |
22 | .tldraw__editor {
23 | position: fixed;
24 | inset: 0px;
25 | overflow: hidden;
26 | }
27 |
28 | .examples {
29 | padding: 16px;
30 | }
31 |
32 | .examples__header {
33 | width: fit-content;
34 | padding-bottom: 32px;
35 | }
36 |
37 | .examples__lockup {
38 | height: 56px;
39 | width: auto;
40 | }
41 |
42 | .examples__list {
43 | display: flex;
44 | flex-direction: column;
45 | padding: 0;
46 | margin: 0;
47 | list-style: none;
48 | }
49 |
50 | .examples__list__item {
51 | padding: 8px 12px;
52 | margin: 0px -12px;
53 | }
54 |
55 | .examples__list__item a {
56 | padding: 8px 12px;
57 | margin: 0px -12px;
58 | text-decoration: none;
59 | color: inherit;
60 | }
61 |
62 | .examples__list__item a:hover {
63 | text-decoration: underline;
64 | }
65 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tldraw-physics",
3 | "private": true,
4 | "version": "0.0.1",
5 | "homepage": "https://OrionReed.github.io/tldraw-physics",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "concurrently \"vite\" \"HOST=localhost PORT=1234 npx y-websocket\" --kill-others",
9 | "dev:win": "concurrently \"vite\" \"set HOST=localhost&& set PORT=1234 && npx y-websocket\" --kill-others",
10 | "build": "tsc && vite build --base=./",
11 | "preview": "vite preview",
12 | "lint": "yarn dlx @biomejs/biome check --apply src",
13 | "deploy": "yarn build && npx partykit deploy"
14 | },
15 | "dependencies": {
16 | "@dimforge/rapier2d": "^0.11.2",
17 | "@tldraw/tldraw": "2.0.0-beta.2",
18 | "partykit": "^0.0.27",
19 | "react": "^18.2.0",
20 | "react-dom": "^18.2.0",
21 | "y-partykit": "^0.0.7",
22 | "y-utility": "^0.1.3",
23 | "y-websocket": "^1.5.0",
24 | "yjs": "^13.6.8"
25 | },
26 | "devDependencies": {
27 | "@biomejs/biome": "1.4.1",
28 | "@types/gh-pages": "^6",
29 | "@types/react": "^18.2.15",
30 | "@types/react-dom": "^18.2.7",
31 | "@vitejs/plugin-react": "^4.0.3",
32 | "concurrently": "^8.2.0",
33 | "gh-pages": "^6.1.1",
34 | "typescript": "^5.0.2",
35 | "vite": "^4.4.5",
36 | "vite-plugin-top-level-await": "^1.3.1",
37 | "vite-plugin-wasm": "^3.2.2"
38 | },
39 | "packageManager": "yarn@4.0.2"
40 | }
41 |
--------------------------------------------------------------------------------
/tldraw-collections/yarn.lock:
--------------------------------------------------------------------------------
1 | # This file is generated by running "yarn install" inside your project.
2 | # Manual changes might be lost - proceed with caution!
3 |
4 | __metadata:
5 | version: 8
6 | cacheKey: 10c0
7 |
8 | "@orion/tldraw-collections@workspace:.":
9 | version: 0.0.0-use.local
10 | resolution: "@orion/tldraw-collections@workspace:."
11 | dependencies:
12 | "@types/react": "npm:^18.2.15"
13 | typescript: "npm:^5.0.2"
14 | peerDependencies:
15 | "@tldraw/tldraw": 2.0.0-beta.2
16 | react: ^18.2.0
17 | languageName: unknown
18 | linkType: soft
19 |
20 | "@types/prop-types@npm:*":
21 | version: 15.7.12
22 | resolution: "@types/prop-types@npm:15.7.12"
23 | checksum: 1babcc7db6a1177779f8fde0ccc78d64d459906e6ef69a4ed4dd6339c920c2e05b074ee5a92120fe4e9d9f1a01c952f843ebd550bee2332fc2ef81d1706878f8
24 | languageName: node
25 | linkType: hard
26 |
27 | "@types/react@npm:^18.2.15":
28 | version: 18.2.77
29 | resolution: "@types/react@npm:18.2.77"
30 | dependencies:
31 | "@types/prop-types": "npm:*"
32 | csstype: "npm:^3.0.2"
33 | checksum: 9114149933dbee3fdf5900786e660afe3146e8e277424aaf94dca50e29f1de56802b38a83bb65166ff53da086062bb8d001af563a6c906fe3540bdc46554d05a
34 | languageName: node
35 | linkType: hard
36 |
37 | "csstype@npm:^3.0.2":
38 | version: 3.1.3
39 | resolution: "csstype@npm:3.1.3"
40 | checksum: 80c089d6f7e0c5b2bd83cf0539ab41474198579584fa10d86d0cafe0642202343cbc119e076a0b1aece191989477081415d66c9fefbf3c957fc2fc4b7009f248
41 | languageName: node
42 | linkType: hard
43 |
44 | "typescript@npm:^5.0.2":
45 | version: 5.4.5
46 | resolution: "typescript@npm:5.4.5"
47 | bin:
48 | tsc: bin/tsc
49 | tsserver: bin/tsserver
50 | checksum: 2954022ada340fd3d6a9e2b8e534f65d57c92d5f3989a263754a78aba549f7e6529acc1921913560a4b816c46dce7df4a4d29f9f11a3dc0d4213bb76d043251e
51 | languageName: node
52 | linkType: hard
53 |
54 | "typescript@patch:typescript@npm%3A^5.0.2#optional!builtin":
55 | version: 5.4.5
56 | resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=e012d7"
57 | bin:
58 | tsc: bin/tsc
59 | tsserver: bin/tsserver
60 | checksum: 9cf4c053893bcf327d101b6c024a55baf05430dc30263f9adb1bf354aeffc11306fe1f23ba2f9a0209674359f16219b5b7d229e923477b94831d07d5a33a4217
61 | languageName: node
62 | linkType: hard
63 |
--------------------------------------------------------------------------------
/src/physics/ui/PhysicsUi.tsx:
--------------------------------------------------------------------------------
1 | import { track, useEditor } from "@tldraw/tldraw";
2 | import { useEffect } from "react";
3 | import "../../css/physics-ui.css";
4 | import { useCollection } from "../../../tldraw-collections/src";
5 |
6 | export const PhysicsUi = track(() => {
7 | const editor = useEditor();
8 | const { collection, size } = useCollection('physics')
9 |
10 | const handleAdd = () => {
11 | if (collection) {
12 | collection.add(editor.getSelectedShapes())
13 | editor.selectNone()
14 | }
15 | }
16 |
17 | const handleRemove = () => {
18 | if (collection) {
19 | collection.remove(editor.getSelectedShapes())
20 | editor.selectNone()
21 | }
22 | }
23 |
24 | const handleShortcut = () => {
25 | if (!collection) return
26 | if (size === 0)
27 | collection.add(editor.getCurrentPageShapes())
28 | else
29 | collection.clear()
30 | };
31 |
32 | const handleHelp = () => {
33 | alert("Use the 'Add' and 'Remove' buttons to add/remove selected shapes, or hit 'P' to add/remove all shapes. \n\nUse the highlight button (🔦) to visualize shapes in the simulation. \n\nShapes' physical properties vary by color (Orange is bouncy, Blue is slippery, Violet is a keyboard-controlled character, etc). \n\nYou can group shapes for compound rigidbodies. \n\nFor more details, check the project's README.");
34 | }
35 |
36 | const handleHighlight = () => {
37 | if (collection) {
38 | editor.setHintingShapes([...collection.getShapes().values()])
39 | }
40 | }
41 |
42 | useEffect(() => {
43 | window.addEventListener('togglePhysicsEvent', handleShortcut);
44 | return () => {
45 | window.removeEventListener('togglePhysicsEvent', handleShortcut);
46 | };
47 | }, [handleShortcut]);
48 |
49 | return (
50 |
51 |
52 |
53 |
54 |
62 |
70 |
78 |
86 |
87 |
{size} shapes
88 |
89 |
90 | );
91 | });
92 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Editor, Tldraw, track, useEditor } from "@tldraw/tldraw";
2 | import "@tldraw/tldraw/tldraw.css";
3 | import { PhysicsUi } from "./physics/ui/PhysicsUi";
4 | import { useYjsStore } from "./useYjsStore";
5 | import { uiOverrides } from "./physics/ui/overrides";
6 | import { CollectionProvider } from "../tldraw-collections/src";
7 | import { PhysicsCollection } from "./physics/PhysicsCollection";
8 | import { useState } from "react";
9 |
10 | const collections = [PhysicsCollection]
11 |
12 | const store = () => {
13 | const hostUrl = import.meta.env.DEV
14 | ? "ws://localhost:1234"
15 | : import.meta.env.VITE_PRODUCTION_URL.replace("https://", "ws://"); // remove protocol just in case
16 | const roomId =
17 | new URLSearchParams(window.location.search).get("room") || "42";
18 | return useYjsStore({
19 | roomId: roomId,
20 | hostUrl: hostUrl,
21 | });
22 | }
23 |
24 | export default function Canvas() {
25 | const [editor, setEditor] = useState(null)
26 |
27 | return (
28 |
29 |
}
33 | overrides={uiOverrides}
34 | onMount={setEditor}
35 | persistenceKey="tldraw-physics"
36 | >
37 | {editor && (
38 |
39 |
40 |
41 | )}
42 |
43 |
44 | );
45 | }
46 |
47 | const NameEditor = track(() => {
48 | const editor = useEditor();
49 |
50 | const { color, name } = editor.user.getUserPreferences();
51 |
52 | return (
53 |
63 | {
73 | editor.user.updateUserPreferences({
74 | color: e.currentTarget.value,
75 | });
76 | }}
77 | />
78 | {
88 | editor.user.updateUserPreferences({
89 | name: e.currentTarget.value,
90 | });
91 | }}
92 | />
93 |
94 | );
95 | });
96 |
--------------------------------------------------------------------------------
/tldraw-collections/src/CollectionProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useEffect, useMemo, useState } from 'react';
2 | import { TLShape, TLRecord, Editor, useEditor } from '@tldraw/tldraw';
3 | import { BaseCollection } from './BaseCollection';
4 |
5 | interface CollectionContextValue {
6 | get: (id: string) => BaseCollection | undefined;
7 | }
8 |
9 | type Collection = (new (editor: Editor) => BaseCollection)
10 |
11 | interface CollectionProviderProps {
12 | editor: Editor;
13 | collections: Collection[];
14 | children: React.ReactNode;
15 | }
16 |
17 | const CollectionContext = createContext(undefined);
18 |
19 | const CollectionProvider: React.FC = ({ editor, collections: collectionClasses, children }) => {
20 | const [collections, setCollections] = useState