├── .eslintrc.cjs
├── .github
└── workflows
│ └── deploy.yml
├── .gitignore
├── .yarnrc.yml
├── README.md
├── biome.json
├── index.html
├── package.json
├── partykit.json
├── public
└── vite.svg
├── src
├── App.tsx
├── css
│ ├── dev-ui.css
│ └── index.css
├── default_store.ts
├── graph
│ ├── GraphLayoutCollection.tsx
│ ├── GraphUi.tsx
│ └── uiOverrides.ts
├── main.tsx
├── server.ts
├── useYjsStore.ts
└── vite-env.d.ts
├── tldraw-collections
├── .yarn
│ └── install-state.gz
├── package.json
├── src
│ ├── BaseCollection.ts
│ ├── CollectionProvider.tsx
│ ├── index.ts
│ └── useCollection.ts
├── tsconfig.json
└── yarn.lock
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── yarn.lock
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | '@typescript-eslint/no-explicit-any': 'off',
18 | '@typescript-eslint/no-unused-vars': [
19 | 'error',
20 | {
21 | argsIgnorePattern: '^_',
22 | varsIgnorePattern: '^_',
23 | caughtErrorsIgnorePattern: '^_',
24 | },
25 | ],
26 | },
27 | }
28 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy to GitHub Pages
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | permissions:
8 | contents: write
9 |
10 | jobs:
11 | build-and-deploy:
12 | concurrency: ci-${{ github.ref }}
13 | runs-on: ubuntu-latest
14 | steps:
15 | - name: Checkout 🛎️
16 | uses: actions/checkout@v3
17 |
18 | - name: Enable Corepack 📦
19 | run: |
20 | corepack enable
21 | corepack prepare yarn@4.0.2 --activate
22 |
23 | - name: Build 🔧
24 | run: |
25 | yarn install
26 | yarn run build
27 |
28 | - name: Deploy 🚀
29 | uses: JamesIves/github-pages-deploy-action@v4
30 | with:
31 | folder: dist
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # tldraw graph layout
2 | This repo demonstrates an interactive force-directed graph layout integration with [tldraw](https://github.com/tldraw/tldraw). It uses [WebCola](https://ialab.it.monash.edu/webcola/), a JS port of [libcola](http://www.adaptagrams.org). This repo is aimed to be a starting point for further exploration and there's lots to be explored!
3 |
4 | You can mess around with it online [here](https://orionreed.github.io/tldraw-graph-layout/)
5 |
6 | https://github.com/OrionReed/tldraw-graph-layout/assets/16704290/0245917d-3a4b-45ad-a3a5-4c6fc4e46a04
7 |
8 | ## Usage
9 | 1. Hit "G" to add/remove all shapes from the graph collection or select shapes and use the "Add" and "Remove" buttons to add just those
10 | 2. Move shapes around and watch it go brrrrrrrrrr
11 | 3. You can hit the "🔦" button to highlight shapes in the graph
12 |
13 | ### Behaviour
14 | - *Any* shapes connected with arrows are included in the graph layout (this includes videos, frames, etc)
15 | - While a shape is selected it will not be moved by the graph layout. Deselect to let those shapes move.
16 |
17 | ### Constraints
18 | - Making a shape red will constrain it vertically
19 | - Making a shape blue will constrain it horizontally
20 | - Much more interesting constraints are possible, PRs welcome!
21 |
22 | ## Setup
23 | ```bash
24 | yarn install
25 | yarn dev
26 | ```
27 | Then go to `http://localhost:5173` in your browser.
28 |
29 | Multiplayer is supported* using yjs and partykit. To deploy:
30 | ```bash
31 | yarn deploy
32 | ```
33 | *Note that multiplayer is essentially the same as a single client manually moving many shapes each frame, but it sure is fun! Due to a connection bug I've disabled multiplayer (err, commented out line 25 of App.tsx). PRs for multiplayer fixes/improvements are **very** welcome!
34 |
35 | # Contributing
36 | Please open an issue or PR if you have any suggestions or improvements! Especially looking for:
37 | - More interesting constraint demonstrations
38 | - Improvements to the [collections system](https://github.com/OrionReed/tldraw-graph-layout/tree/main/tldraw-collections)
39 | - Bug fixes / performance improvements
40 |
41 | ## Current Limitations & Issues
42 | - There is a bug I cannot identify where the non-overlap constraint does not apply to disconnected nodes. They *should* collide with each other but don't.
43 | - Due to the current edge length calculation, it's possible for the graph to never reach a stable / zero energy state under some circumstances. This is just me doing bad math. A better ideal edge length function would be nice.
44 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Graph Layouts
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tldraw-graph",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "concurrently \"vite\" \"HOST=localhost PORT=1234 npx y-websocket\" --kill-others",
8 | "dev:win": "concurrently \"vite\" \"set HOST=localhost&& set PORT=1234 && npx y-websocket\" --kill-others",
9 | "build": "tsc && vite build --base=./",
10 | "preview": "vite preview",
11 | "lint": "yarn dlx @biomejs/biome check --apply src",
12 | "deploy": "yarn build && npx partykit deploy"
13 | },
14 | "dependencies": {
15 | "@tldraw/tldraw": "2.0.0-beta.2",
16 | "partykit": "^0.0.27",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0",
19 | "webcola": "latest",
20 | "y-partykit": "^0.0.7",
21 | "y-utility": "^0.1.3",
22 | "y-websocket": "^1.5.0",
23 | "yjs": "^13.6.8"
24 | },
25 | "devDependencies": {
26 | "@biomejs/biome": "1.4.1",
27 | "@types/react": "^18.2.15",
28 | "@types/react-dom": "^18.2.7",
29 | "@vitejs/plugin-react": "^4.0.3",
30 | "concurrently": "^8.2.0",
31 | "path": "^0.12.7",
32 | "typescript": "^5.0.2",
33 | "vite": "^4.4.5",
34 | "vite-plugin-top-level-await": "^1.3.1",
35 | "vite-plugin-wasm": "^3.2.2"
36 | },
37 | "packageManager": "yarn@4.0.2"
38 | }
39 |
--------------------------------------------------------------------------------
/partykit.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "canvas",
3 | "main": "src/server.ts",
4 | "serve": {
5 | "path": "dist"
6 | },
7 | "compatibilityDate": "2023-10-04"
8 | }
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Editor, Tldraw, track, useEditor } from "@tldraw/tldraw";
2 | import "@tldraw/tldraw/tldraw.css";
3 | import { GraphUi } from "./graph/GraphUi";
4 | import { useYjsStore } from "./useYjsStore";
5 | import { uiOverrides } from "./graph/uiOverrides";
6 | import { Collection, CollectionProvider } from "@collections";
7 | import { useState } from "react";
8 | import { GraphLayoutCollection } from "./graph/GraphLayoutCollection";
9 |
10 | const collections: Collection[] = [GraphLayoutCollection]
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 | persistenceKey="tldraw-graph"
35 | onMount={setEditor}
36 | >
37 | {editor && (
38 |
39 |
40 |
41 | )}
42 |
43 |
44 | );
45 | }
46 |
47 | const NameEditor = track(() => {
48 | const editor = useEditor();
49 | const { color, name } = editor.user.getUserPreferences();
50 |
51 | return (
52 |
62 | {
72 | editor.user.updateUserPreferences({
73 | color: e.currentTarget.value,
74 | });
75 | }}
76 | />
77 | {
87 | editor.user.updateUserPreferences({
88 | name: e.currentTarget.value,
89 | });
90 | }}
91 | />
92 |
93 | );
94 | });
95 |
--------------------------------------------------------------------------------
/src/css/dev-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 | display: flex;
14 | align-items: center;
15 | justify-content: center;
16 | padding: 8px;
17 | gap: 8px;
18 | }
19 |
20 | .custom-button {
21 | pointer-events: all;
22 | padding: 4px 12px;
23 | background-color: white;
24 | border: 1px solid rgba(0, 0, 0, 0.2);
25 | border-radius: 64px;
26 | &:hover {
27 | background-color: rgb(240, 240, 240);
28 | }
29 | }
30 |
31 | .custom-button[data-isactive="true"] {
32 | background-color: black;
33 | color: white;
34 | }
35 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/default_store.ts:
--------------------------------------------------------------------------------
1 | export const DEFAULT_STORE = {
2 | store: {
3 | "document:document": {
4 | gridSize: 10,
5 | name: "",
6 | meta: {},
7 | id: "document:document",
8 | typeName: "document",
9 | },
10 | "pointer:pointer": {
11 | id: "pointer:pointer",
12 | typeName: "pointer",
13 | x: 0,
14 | y: 0,
15 | lastActivityTimestamp: 0,
16 | meta: {},
17 | },
18 | "page:page": {
19 | meta: {},
20 | id: "page:page",
21 | name: "Page 1",
22 | index: "a1",
23 | typeName: "page",
24 | },
25 | "camera:page:page": {
26 | x: 0,
27 | y: 0,
28 | z: 1,
29 | meta: {},
30 | id: "camera:page:page",
31 | typeName: "camera",
32 | },
33 | "instance_page_state:page:page": {
34 | editingShapeId: null,
35 | croppingShapeId: null,
36 | selectedShapeIds: [],
37 | hoveredShapeId: null,
38 | erasingShapeIds: [],
39 | hintingShapeIds: [],
40 | focusedGroupId: null,
41 | meta: {},
42 | id: "instance_page_state:page:page",
43 | pageId: "page:page",
44 | typeName: "instance_page_state",
45 | },
46 | "instance:instance": {
47 | followingUserId: null,
48 | opacityForNextShape: 1,
49 | stylesForNextShape: {},
50 | brush: null,
51 | scribble: null,
52 | cursor: {
53 | type: "default",
54 | rotation: 0,
55 | },
56 | isFocusMode: false,
57 | exportBackground: true,
58 | isDebugMode: false,
59 | isToolLocked: false,
60 | screenBounds: {
61 | x: 0,
62 | y: 0,
63 | w: 720,
64 | h: 400,
65 | },
66 | zoomBrush: null,
67 | isGridMode: false,
68 | isPenMode: false,
69 | chatMessage: "",
70 | isChatting: false,
71 | highlightedUserIds: [],
72 | canMoveCamera: true,
73 | isFocused: true,
74 | devicePixelRatio: 2,
75 | isCoarsePointer: false,
76 | isHoveringCanvas: false,
77 | openMenus: [],
78 | isChangingStyle: false,
79 | isReadonly: false,
80 | meta: {},
81 | id: "instance:instance",
82 | currentPageId: "page:page",
83 | typeName: "instance",
84 | },
85 | },
86 | schema: {
87 | schemaVersion: 1,
88 | storeVersion: 4,
89 | recordVersions: {
90 | asset: {
91 | version: 1,
92 | subTypeKey: "type",
93 | subTypeVersions: {
94 | image: 2,
95 | video: 2,
96 | bookmark: 0,
97 | },
98 | },
99 | camera: {
100 | version: 1,
101 | },
102 | document: {
103 | version: 2,
104 | },
105 | instance: {
106 | version: 21,
107 | },
108 | instance_page_state: {
109 | version: 5,
110 | },
111 | page: {
112 | version: 1,
113 | },
114 | shape: {
115 | version: 3,
116 | subTypeKey: "type",
117 | subTypeVersions: {
118 | group: 0,
119 | text: 1,
120 | bookmark: 1,
121 | draw: 1,
122 | geo: 7,
123 | note: 4,
124 | line: 1,
125 | frame: 0,
126 | arrow: 1,
127 | highlight: 0,
128 | embed: 4,
129 | image: 2,
130 | video: 1,
131 | },
132 | },
133 | instance_presence: {
134 | version: 5,
135 | },
136 | pointer: {
137 | version: 1,
138 | },
139 | },
140 | },
141 | };
142 |
--------------------------------------------------------------------------------
/src/graph/GraphLayoutCollection.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from 'webcola';
2 | import { BaseCollection } from '@collections';
3 | import { Editor, TLArrowShape, TLGeoShape, TLShape, TLShapeId } from '@tldraw/tldraw';
4 |
5 | type ColaNode = {
6 | id: TLShapeId;
7 | x: number;
8 | y: number;
9 | width: number;
10 | height: number;
11 | rotation: number;
12 | color?: string;
13 | };
14 | type ColaIdLink = {
15 | source: TLShapeId
16 | target: TLShapeId
17 | };
18 | type ColaNodeLink = {
19 | source: ColaNode
20 | target: ColaNode
21 | };
22 |
23 | type AlignmentConstraint = {
24 | type: 'alignment',
25 | axis: 'x' | 'y',
26 | offsets: { node: TLShapeId, offset: number }[]
27 | }
28 |
29 | type ColaConstraint = AlignmentConstraint
30 |
31 | export class GraphLayoutCollection extends BaseCollection {
32 | override id = 'graph';
33 | graphSim: Layout;
34 | animFrame = -1;
35 | colaNodes: Map = new Map();
36 | colaLinks: Map = new Map();
37 | colaConstraints: ColaConstraint[] = [];
38 |
39 | constructor(editor: Editor) {
40 | super(editor)
41 | this.graphSim = new Layout();
42 | const simLoop = () => {
43 | this.step();
44 | this.animFrame = requestAnimationFrame(simLoop);
45 | };
46 | simLoop();
47 | }
48 |
49 | override onAdd(shapes: TLShape[]) {
50 | for (const shape of shapes) {
51 | if (shape.type !== "arrow") {
52 | this.addGeo(shape);
53 | }
54 | else {
55 | this.addArrow(shape as TLArrowShape);
56 | }
57 | }
58 | this.refreshGraph();
59 | }
60 |
61 | override onRemove(shapes: TLShape[]) {
62 | const removedShapeIds = new Set(shapes.map(shape => shape.id));
63 |
64 | for (const shape of shapes) {
65 | this.colaNodes.delete(shape.id);
66 | this.colaLinks.delete(shape.id);
67 | }
68 |
69 | // Filter out links where either source or target has been removed
70 | for (const [key, link] of this.colaLinks) {
71 | if (removedShapeIds.has(link.source) || removedShapeIds.has(link.target)) {
72 | this.colaLinks.delete(key);
73 | }
74 | }
75 |
76 | this.refreshGraph();
77 | }
78 |
79 | override onShapeChange(prev: TLShape, next: TLShape) {
80 | if (prev.type === 'geo' && next.type === 'geo') {
81 | const prevShape = prev as TLGeoShape
82 | const nextShape = next as TLGeoShape
83 | // update color if its changed and refresh constraints which use this
84 | if (prevShape.props.color !== nextShape.props.color) {
85 | const existingNode = this.colaNodes.get(next.id);
86 | if (existingNode) {
87 | this.colaNodes.set(next.id, {
88 | ...existingNode,
89 | color: nextShape.props.color,
90 | });
91 | }
92 | this.refreshGraph();
93 | }
94 | }
95 | }
96 |
97 | step = () => {
98 | this.graphSim.start(1, 0, 0, 0, true, false);
99 | for (const node of this.graphSim.nodes() as ColaNode[]) {
100 |
101 | const shape = this.editor.getShape(node.id);
102 | const { w, h } = this.editor.getShapeGeometry(node.id).bounds
103 | if (!shape) continue;
104 |
105 | const { x, y } = getCornerToCenterOffset(w, h, shape.rotation);
106 |
107 | // Fix positions if we're dragging them
108 | if (this.editor.getSelectedShapeIds().includes(node.id)) {
109 | node.x = shape.x + x;
110 | node.y = shape.y + y;
111 | }
112 |
113 | // Update shape props
114 | node.width = w;
115 | node.height = h;
116 | node.rotation = shape.rotation;
117 |
118 | this.editor.updateShape({
119 | id: node.id,
120 | type: "geo",
121 | x: node.x - x,
122 | y: node.y - y,
123 | });
124 | }
125 | };
126 |
127 | addArrow = (arrow: TLArrowShape) => {
128 | const source = arrow.props.start.type === 'binding' ? this.editor.getShape(arrow.props.start.boundShapeId) : undefined;
129 | const target = arrow.props.end.type === 'binding' ? this.editor.getShape(arrow.props.end.boundShapeId) : undefined;
130 | if (source && target) {
131 | const link: ColaIdLink = {
132 | source: source.id,
133 | target: target.id
134 | };
135 | this.colaLinks.set(arrow.id, link);
136 | }
137 | }
138 |
139 | addGeo = (shape: TLShape) => {
140 | const { w, h } = this.editor.getShapeGeometry(shape).bounds
141 | const { x, y } = getCornerToCenterOffset(w, h, shape.rotation)
142 | const node: ColaNode = {
143 | id: shape.id,
144 | x: shape.x + x,
145 | y: shape.y + y,
146 | width: w,
147 | height: h,
148 | rotation: shape.rotation,
149 | color: (shape.props as any).color
150 | };
151 | this.colaNodes.set(shape.id, node);
152 | }
153 |
154 | refreshGraph() {
155 | // TODO: remove this hardcoded behaviour
156 | this.editor.selectNone()
157 | this.refreshConstraints();
158 | const nodes = [...this.colaNodes.values()];
159 | const nodeIdToIndex = new Map(nodes.map((n, i) => [n.id, i]));
160 | // Convert the Map values to an array for processing
161 | const links = Array.from(this.colaLinks.values()).map(l => ({
162 | source: nodeIdToIndex.get(l.source),
163 | target: nodeIdToIndex.get(l.target)
164 | }));
165 |
166 | const constraints = this.colaConstraints.map(constraint => {
167 | if (constraint.type === 'alignment') {
168 | return {
169 | ...constraint,
170 | offsets: constraint.offsets.map(offset => ({
171 | node: nodeIdToIndex.get(offset.node),
172 | offset: offset.offset
173 | }))
174 | };
175 | }
176 | return constraint;
177 | });
178 |
179 | this.graphSim
180 | .nodes(nodes)
181 | // @ts-ignore
182 | .links(links)
183 | .constraints(constraints)
184 | // you could use .linkDistance(250) too, which is stable but does not handle size/rotation
185 | .linkDistance((edge) => calcEdgeDistance(edge as ColaNodeLink))
186 | .avoidOverlaps(true)
187 | .handleDisconnected(true)
188 | }
189 |
190 | refreshConstraints() {
191 | const alignmentConstraintX: AlignmentConstraint = {
192 | type: 'alignment',
193 | axis: 'x',
194 | offsets: [],
195 | };
196 | const alignmentConstraintY: AlignmentConstraint = {
197 | type: 'alignment',
198 | axis: 'y',
199 | offsets: [],
200 | };
201 |
202 | // Iterate over shapes and generate constraints based on conditions
203 | for (const node of this.colaNodes.values()) {
204 | if (node.color === "red") {
205 | // Add alignment offset for red shapes
206 | alignmentConstraintX.offsets.push({ node: node.id, offset: 0 });
207 | }
208 | if (node.color === "blue") {
209 | // Add alignment offset for red shapes
210 | alignmentConstraintY.offsets.push({ node: node.id, offset: 0 });
211 | }
212 | }
213 |
214 | const constraints = [];
215 | if (alignmentConstraintX.offsets.length > 0) {
216 | constraints.push(alignmentConstraintX);
217 | }
218 | if (alignmentConstraintY.offsets.length > 0) {
219 | constraints.push(alignmentConstraintY);
220 | }
221 | this.colaConstraints = constraints;
222 | }
223 | }
224 |
225 | function getCornerToCenterOffset(w: number, h: number, rotation: number) {
226 |
227 | // Calculate the center coordinates relative to the top-left corner
228 | const centerX = w / 2;
229 | const centerY = h / 2;
230 |
231 | // Apply rotation to the center coordinates
232 | const rotatedCenterX = centerX * Math.cos(rotation) - centerY * Math.sin(rotation);
233 | const rotatedCenterY = centerX * Math.sin(rotation) + centerY * Math.cos(rotation);
234 |
235 | return { x: rotatedCenterX, y: rotatedCenterY };
236 | }
237 |
238 | function calcEdgeDistance(edge: ColaNodeLink) {
239 | const LINK_DISTANCE = 100;
240 |
241 | // horizontal and vertical distances between centers
242 | const dx = edge.target.x - edge.source.x;
243 | const dy = edge.target.y - edge.source.y;
244 |
245 | // the angles of the nodes in radians
246 | const sourceAngle = edge.source.rotation;
247 | const targetAngle = edge.target.rotation;
248 |
249 | // Calculate the rotated dimensions of the nodes
250 | const sourceWidth = Math.abs(edge.source.width * Math.cos(sourceAngle)) + Math.abs(edge.source.height * Math.sin(sourceAngle));
251 | const sourceHeight = Math.abs(edge.source.width * Math.sin(sourceAngle)) + Math.abs(edge.source.height * Math.cos(sourceAngle));
252 | const targetWidth = Math.abs(edge.target.width * Math.cos(targetAngle)) + Math.abs(edge.target.height * Math.sin(targetAngle));
253 | const targetHeight = Math.abs(edge.target.width * Math.sin(targetAngle)) + Math.abs(edge.target.height * Math.cos(targetAngle));
254 |
255 | // Calculate edge-to-edge distances
256 | const horizontalGap = Math.max(0, Math.abs(dx) - (sourceWidth + targetWidth) / 2);
257 | const verticalGap = Math.max(0, Math.abs(dy) - (sourceHeight + targetHeight) / 2);
258 |
259 | // Calculate straight-line distance between the centers of the nodes
260 | const centerToCenterDistance = Math.sqrt(dx * dx + dy * dy);
261 |
262 | // Adjust the distance by subtracting the edge-to-edge distance and adding the desired travel distance
263 | const adjustedDistance = centerToCenterDistance -
264 | Math.sqrt(horizontalGap * horizontalGap + verticalGap * verticalGap) +
265 | LINK_DISTANCE;
266 |
267 | return adjustedDistance;
268 | };
269 |
--------------------------------------------------------------------------------
/src/graph/GraphUi.tsx:
--------------------------------------------------------------------------------
1 | import { useEditor } from "@tldraw/tldraw";
2 | import { useEffect } from "react";
3 | import "../css/dev-ui.css";
4 | import { useCollection } from "@collections";
5 |
6 | export const GraphUi = () => {
7 | const editor = useEditor();
8 | const { collection, size } = useCollection('graph')
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 | const empty = collection.getShapes().size === 0
27 | if (empty)
28 | collection.add(editor.getCurrentPageShapes())
29 | else
30 | collection.clear()
31 | };
32 |
33 | const handleHighlight = () => {
34 | if (collection) {
35 | editor.setHintingShapes([...collection.getShapes().values()])
36 | }
37 | }
38 |
39 | const handleHelp = () => {
40 | alert("Use the 'Add' and 'Remove' buttons to add/remove selected shapes, or hit 'G' to add/remove all shapes. \n\nUse the highlight button (🔦) to visualize shapes in the simulation. \n\nBLUE shapes are constrained horizontally, RED shapes are constrained vertically. This is just to demo basic constraints, I plan to demo more interesting constraints in the future. \n\nFor more details, check the project's README.");
41 | }
42 |
43 | useEffect(() => {
44 | window.addEventListener('toggleGraphLayoutEvent', handleShortcut);
45 |
46 | return () => {
47 | window.removeEventListener('toggleGraphLayoutEvent', handleShortcut);
48 | };
49 | }, [handleShortcut]);
50 |
51 | return (
52 |
53 |
54 |
{size} shapes
55 |
63 |
71 |
79 |
87 |
88 |
89 | );
90 | };
91 |
--------------------------------------------------------------------------------
/src/graph/uiOverrides.ts:
--------------------------------------------------------------------------------
1 | import {
2 | TLUiEventSource,
3 | TLUiOverrides,
4 | TLUiTranslationKey,
5 | } from "@tldraw/tldraw";
6 |
7 | export const uiOverrides: TLUiOverrides = {
8 | actions(_editor, actions) {
9 | actions['toggle-graph-layout'] = {
10 | id: 'toggle-graph-layout',
11 | label: 'Toggle Graph Layout' as TLUiTranslationKey,
12 | readonlyOk: true,
13 | kbd: 'g',
14 | onSelect(_source: TLUiEventSource) {
15 | const event = new CustomEvent('toggleGraphLayoutEvent');
16 | window.dispatchEvent(event);
17 | },
18 | }
19 | return actions
20 | }
21 | }
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import ReactDOM from "react-dom/client";
2 | import App from "./App.tsx";
3 | import "./css/index.css";
4 |
5 | const rootElement = document.getElementById("root");
6 | if (rootElement) {
7 | ReactDOM.createRoot(rootElement).render(
8 | //
9 |
10 | //
11 | );
12 | }
--------------------------------------------------------------------------------
/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 | console.log("onConnect");
7 |
8 | return await onConnect(conn, room, {
9 | // experimental: persist the document to partykit's room storage
10 | persist: true,
11 | });
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/src/useYjsStore.ts:
--------------------------------------------------------------------------------
1 | import {
2 | InstancePresenceRecordType,
3 | TLAnyShapeUtilConstructor,
4 | TLInstancePresence,
5 | TLRecord,
6 | TLStoreWithStatus,
7 | computed,
8 | createPresenceStateDerivation,
9 | createTLStore,
10 | defaultShapeUtils,
11 | defaultUserPreferences,
12 | getUserPreferences,
13 | react,
14 | transact,
15 | } from "@tldraw/tldraw";
16 | import { useEffect, useMemo, useState } from "react";
17 | import YPartyKitProvider from "y-partykit/provider";
18 | import { YKeyValue } from "y-utility/y-keyvalue";
19 | import * as Y from "yjs";
20 | import { DEFAULT_STORE } from "./default_store";
21 |
22 | export function useYjsStore({
23 | hostUrl,
24 | version = 1,
25 | roomId = "example",
26 | shapeUtils = [],
27 | }: {
28 | hostUrl: string;
29 | version?: number;
30 | roomId?: string;
31 | shapeUtils?: TLAnyShapeUtilConstructor[];
32 | }) {
33 | const [store] = useState(() => {
34 | const store = createTLStore({
35 | shapeUtils: [...defaultShapeUtils, ...shapeUtils],
36 | });
37 | store.loadSnapshot(DEFAULT_STORE);
38 | return store;
39 | });
40 |
41 | const [storeWithStatus, setStoreWithStatus] = useState({
42 | status: "loading",
43 | });
44 |
45 | const { yDoc, yStore, room } = useMemo(() => {
46 | const yDoc = new Y.Doc({ gc: true });
47 | const yArr = yDoc.getArray<{ key: string; val: TLRecord }>(`tl_${roomId}`);
48 | const yStore = new YKeyValue(yArr);
49 |
50 | return {
51 | yDoc,
52 | yStore,
53 | room: new YPartyKitProvider(hostUrl, `${roomId}_${version}`, yDoc, {
54 | connect: true,
55 | }),
56 | };
57 | }, [hostUrl, roomId, version]);
58 |
59 | useEffect(() => {
60 | setStoreWithStatus({ status: "loading" });
61 |
62 | const unsubs: (() => void)[] = [];
63 |
64 | function handleSync() {
65 | // 1.
66 | // Connect store to yjs store and vis versa, for both the document and awareness
67 |
68 | /* -------------------- Document -------------------- */
69 |
70 | // Sync store changes to the yjs doc
71 | unsubs.push(
72 | store.listen(
73 | function syncStoreChangesToYjsDoc({ changes }) {
74 | yDoc.transact(() => {
75 | Object.values(changes.added).forEach((record) => {
76 | yStore.set(record.id, record);
77 | });
78 |
79 | Object.values(changes.updated).forEach(([_, record]) => {
80 | yStore.set(record.id, record);
81 | });
82 |
83 | Object.values(changes.removed).forEach((record) => {
84 | yStore.delete(record.id);
85 | });
86 | });
87 | },
88 | { source: "user", scope: "document" }, // only sync user's document changes
89 | ),
90 | );
91 |
92 | // Sync the yjs doc changes to the store
93 | const handleChange = (
94 | changes: Map<
95 | string,
96 | | { action: "delete"; oldValue: TLRecord }
97 | | { action: "update"; oldValue: TLRecord; newValue: TLRecord }
98 | | { action: "add"; newValue: TLRecord }
99 | >,
100 | transaction: Y.Transaction,
101 | ) => {
102 | if (transaction.local) return;
103 |
104 | const toRemove: TLRecord["id"][] = [];
105 | const toPut: TLRecord[] = [];
106 |
107 | changes.forEach((change, id) => {
108 | switch (change.action) {
109 | case "add":
110 | case "update": {
111 | const record = yStore.get(id)!;
112 | toPut.push(record);
113 | break;
114 | }
115 | case "delete": {
116 | toRemove.push(id as TLRecord["id"]);
117 | break;
118 | }
119 | }
120 | });
121 |
122 | // put / remove the records in the store
123 | store.mergeRemoteChanges(() => {
124 | if (toRemove.length) store.remove(toRemove);
125 | if (toPut.length) store.put(toPut);
126 | });
127 | };
128 |
129 | yStore.on("change", handleChange);
130 | unsubs.push(() => yStore.off("change", handleChange));
131 |
132 | /* -------------------- Awareness ------------------- */
133 |
134 | const userPreferences = computed<{
135 | id: string;
136 | color: string;
137 | name: string;
138 | }>("userPreferences", () => {
139 | const user = getUserPreferences();
140 | return {
141 | id: user.id,
142 | color: user.color ?? defaultUserPreferences.color,
143 | name: user.name ?? defaultUserPreferences.name,
144 | };
145 | });
146 |
147 | // Create the instance presence derivation
148 | const yClientId = room.awareness.clientID.toString();
149 | const presenceId = InstancePresenceRecordType.createId(yClientId);
150 | const presenceDerivation =
151 | createPresenceStateDerivation(userPreferences)(store);
152 |
153 | // Set our initial presence from the derivation's current value
154 | room.awareness.setLocalStateField("presence", presenceDerivation.get());
155 |
156 | // When the derivation change, sync presence to to yjs awareness
157 | unsubs.push(
158 | react("when presence changes", () => {
159 | const presence = presenceDerivation.get();
160 | requestAnimationFrame(() => {
161 | room.awareness.setLocalStateField("presence", presence);
162 | });
163 | }),
164 | );
165 |
166 | // Sync yjs awareness changes to the store
167 | const handleUpdate = (update: {
168 | added: number[];
169 | updated: number[];
170 | removed: number[];
171 | }) => {
172 | const states = room.awareness.getStates() as Map<
173 | number,
174 | { presence: TLInstancePresence }
175 | >;
176 |
177 | const toRemove: TLInstancePresence["id"][] = [];
178 | const toPut: TLInstancePresence[] = [];
179 |
180 | // Connect records to put / remove
181 | for (const clientId of update.added) {
182 | const state = states.get(clientId);
183 | if (state?.presence && state.presence.id !== presenceId) {
184 | toPut.push(state.presence);
185 | }
186 | }
187 |
188 | for (const clientId of update.updated) {
189 | const state = states.get(clientId);
190 | if (state?.presence && state.presence.id !== presenceId) {
191 | toPut.push(state.presence);
192 | }
193 | }
194 |
195 | for (const clientId of update.removed) {
196 | toRemove.push(
197 | InstancePresenceRecordType.createId(clientId.toString()),
198 | );
199 | }
200 |
201 | // put / remove the records in the store
202 | store.mergeRemoteChanges(() => {
203 | if (toRemove.length) store.remove(toRemove);
204 | if (toPut.length) store.put(toPut);
205 | });
206 | };
207 |
208 | room.awareness.on("update", handleUpdate);
209 | unsubs.push(() => room.awareness.off("update", handleUpdate));
210 |
211 | // 2.
212 | // Initialize the store with the yjs doc records—or, if the yjs doc
213 | // is empty, initialize the yjs doc with the default store records.
214 | if (yStore.yarray.length) {
215 | // Replace the store records with the yjs doc records
216 | transact(() => {
217 | // The records here should be compatible with what's in the store
218 | store.clear();
219 | const records = yStore.yarray.toJSON().map(({ val }) => val);
220 | store.put(records);
221 | });
222 | } else {
223 | // Create the initial store records
224 | // Sync the store records to the yjs doc
225 | yDoc.transact(() => {
226 | for (const record of store.allRecords()) {
227 | yStore.set(record.id, record);
228 | }
229 | });
230 | }
231 |
232 | setStoreWithStatus({
233 | store,
234 | status: "synced-remote",
235 | connectionStatus: "online",
236 | });
237 | }
238 |
239 | let hasConnectedBefore = false;
240 |
241 | function handleStatusChange({
242 | status,
243 | }: {
244 | status: "disconnected" | "connected";
245 | }) {
246 | // If we're disconnected, set the store status to 'synced-remote' and the connection status to 'offline'
247 | if (status === "disconnected") {
248 | setStoreWithStatus({
249 | store,
250 | status: "synced-remote",
251 | connectionStatus: "offline",
252 | });
253 | return;
254 | }
255 |
256 | room.off("synced", handleSync);
257 |
258 | if (status === "connected") {
259 | if (hasConnectedBefore) return;
260 | hasConnectedBefore = true;
261 | room.on("synced", handleSync);
262 | unsubs.push(() => room.off("synced", handleSync));
263 | }
264 | }
265 |
266 | room.on("status", handleStatusChange);
267 | unsubs.push(() => room.off("status", handleStatusChange));
268 |
269 | return () => {
270 | unsubs.forEach((fn) => fn());
271 | unsubs.length = 0;
272 | };
273 | }, [room, yDoc, store, yStore]);
274 |
275 | return storeWithStatus;
276 | }
277 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tldraw-collections/.yarn/install-state.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/OrionReed/tldraw-graph-layout/b595bc9924c4332d102f0e619ce972f47b8bdeaa/tldraw-collections/.yarn/install-state.gz
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/tldraw-collections/src/BaseCollection.ts:
--------------------------------------------------------------------------------
1 | import { Editor, TLShape, TLShapeId } from '@tldraw/tldraw';
2 |
3 | /**
4 | * A PoC abstract collections class for @tldraw.
5 | */
6 | export abstract class BaseCollection {
7 | /** A unique identifier for the collection. */
8 | abstract id: string;
9 | /** A map containing the shapes that belong to this collection, keyed by their IDs. */
10 | protected shapes: Map = new Map();
11 | /** A reference to the \@tldraw Editor instance. */
12 | protected editor: Editor;
13 | /** A set of listeners to be notified when the collection changes. */
14 | private listeners = new Set<() => void>();
15 |
16 | // TODO: Maybe pass callback to replace updateShape so only CollectionProvider can call it
17 | public constructor(editor: Editor) {
18 | this.editor = editor;
19 | }
20 |
21 | /**
22 | * Called when shapes are added to the collection.
23 | * @param shapes The shapes being added to the collection.
24 | */
25 | protected onAdd(_shapes: TLShape[]): void { }
26 |
27 | /**
28 | * Called when shapes are removed from the collection.
29 | * @param shapes The shapes being removed from the collection.
30 | */
31 | protected onRemove(_shapes: TLShape[]) { }
32 |
33 | /**
34 | * Called when the membership of the collection changes (i.e., when shapes are added or removed).
35 | */
36 | protected onMembershipChange() { }
37 |
38 |
39 | /**
40 | * Called when the properties of a shape belonging to the collection change.
41 | * @param prev The previous version of the shape before the change.
42 | * @param next The updated version of the shape after the change.
43 | */
44 | protected onShapeChange(_prev: TLShape, _next: TLShape) { }
45 |
46 | /**
47 | * Adds the specified shapes to the collection.
48 | * @param shapes The shapes to add to the collection.
49 | */
50 | public add(shapes: TLShape[]) {
51 | shapes.forEach(shape => {
52 | this.shapes.set(shape.id, shape)
53 | });
54 | this.onAdd(shapes);
55 | this.onMembershipChange();
56 | this.notifyListeners();
57 | }
58 |
59 | /**
60 | * Removes the specified shapes from the collection.
61 | * @param shapes The shapes to remove from the collection.
62 | */
63 | public remove(shapes: TLShape[]) {
64 | shapes.forEach(shape => {
65 | this.shapes.delete(shape.id);
66 | });
67 | this.onRemove(shapes);
68 | this.onMembershipChange();
69 | this.notifyListeners();
70 | }
71 |
72 | /**
73 | * Clears all shapes from the collection.
74 | */
75 | public clear() {
76 | this.remove([...this.shapes.values()])
77 | }
78 |
79 | /**
80 | * Returns the map of shapes in the collection.
81 | * @returns The map of shapes in the collection, keyed by their IDs.
82 | */
83 | public getShapes(): Map {
84 | return this.shapes;
85 | }
86 |
87 | public get size(): number {
88 | return this.shapes.size;
89 | }
90 |
91 | public _onShapeChange(prev: TLShape, next: TLShape) {
92 | this.shapes.set(next.id, next)
93 | this.onShapeChange(prev, next)
94 | this.notifyListeners();
95 | }
96 |
97 | private notifyListeners() {
98 | for (const listener of this.listeners) {
99 | listener();
100 | }
101 | }
102 |
103 | public subscribe(listener: () => void): () => void {
104 | this.listeners.add(listener);
105 | return () => this.listeners.delete(listener);
106 | }
107 | }
--------------------------------------------------------------------------------
/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