implemented collections and graph layout tool

This commit is contained in:
Jeff Emmett 2025-07-29 14:52:57 -04:00
parent ea66699783
commit bc831c7516
15 changed files with 1180 additions and 15 deletions

58
package-lock.json generated
View File

@ -39,7 +39,8 @@
"react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"tldraw": "^3.6.0",
"vercel": "^39.1.1"
"vercel": "^39.1.1",
"webcola": "^3.4.0"
},
"devDependencies": {
"@cloudflare/types": "^6.0.0",
@ -11328,6 +11329,61 @@
"license": "Apache-2.0",
"optional": true
},
"node_modules/webcola": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/webcola/-/webcola-3.4.0.tgz",
"integrity": "sha512-4BiLXjXw3SJHo3Xd+rF+7fyClT6n7I+AR6TkBqyQ4kTsePSAMDLRCXY1f3B/kXJeP9tYn4G1TblxTO+jAt0gaw==",
"license": "MIT",
"dependencies": {
"d3-dispatch": "^1.0.3",
"d3-drag": "^1.0.4",
"d3-shape": "^1.3.5",
"d3-timer": "^1.0.5"
}
},
"node_modules/webcola/node_modules/d3-dispatch": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz",
"integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==",
"license": "BSD-3-Clause"
},
"node_modules/webcola/node_modules/d3-drag": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz",
"integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-dispatch": "1",
"d3-selection": "1"
}
},
"node_modules/webcola/node_modules/d3-path": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==",
"license": "BSD-3-Clause"
},
"node_modules/webcola/node_modules/d3-selection": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz",
"integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==",
"license": "BSD-3-Clause"
},
"node_modules/webcola/node_modules/d3-shape": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-path": "1"
}
},
"node_modules/webcola/node_modules/d3-timer": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz",
"integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==",
"license": "BSD-3-Clause"
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View File

@ -46,7 +46,8 @@
"react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"tldraw": "^3.6.0",
"vercel": "^39.1.1"
"vercel": "^39.1.1",
"webcola": "^3.4.0"
},
"devDependencies": {
"@cloudflare/types": "^6.0.0",

View File

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

View File

@ -0,0 +1,111 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { TLShape, Editor } from '@tldraw/tldraw';
import { BaseCollection } from './BaseCollection';
interface CollectionContextValue {
get: (id: string) => BaseCollection | undefined;
}
type Collection = (new (editor: Editor) => BaseCollection)
interface CollectionContextWrapperProps {
editor: Editor | null;
collections: Collection[];
children: React.ReactNode;
}
const CollectionContext = createContext<CollectionContextValue | undefined>(undefined);
export const CollectionContextWrapper: React.FC<CollectionContextWrapperProps> = ({
editor,
collections: collectionClasses,
children
}) => {
const [collections, setCollections] = useState<Map<string, BaseCollection> | null>(null);
// Handle shape property changes
const handleShapeChange = (prev: TLShape, next: TLShape) => {
if (!collections) return;
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;
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) => {
handleShapeChange(prev, next);
});
}
}, [editor, collections]);
// Subscribe to shape deletions in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
handleShapeDelete(prev);
});
}
}, [editor, collections]);
const value = useMemo(() => ({
get: (id: string) => collections?.get(id),
}), [collections]);
return (
<CollectionContext.Provider value={value}>
{children}
</CollectionContext.Provider>
);
};
// Hook to use collection context within the wrapper
export const useCollectionContext = <T extends BaseCollection = BaseCollection>(
collectionId: string
): { collection: T | null; size: number } => {
const context = useContext(CollectionContext);
if (!context) {
return { collection: null, size: 0 };
}
const collection = context.get(collectionId);
if (!collection) {
return { collection: null, size: 0 };
}
const [size, setSize] = useState<number>(collection.size);
useEffect(() => {
const unsubscribe = collection.subscribe(() => {
setSize(collection.size);
});
setSize(collection.size);
return unsubscribe;
}, [collection]);
return { collection: collection as T, size };
};

View File

@ -0,0 +1,82 @@
import React, { createContext, useEffect, useMemo, useState } from 'react';
import { TLShape, TLRecord, Editor, useEditor } from '@tldraw/tldraw';
import { BaseCollection } from './BaseCollection';
interface CollectionContextValue {
get: (id: string) => BaseCollection | undefined;
}
type Collection = (new (editor: Editor) => BaseCollection)
interface CollectionProviderProps {
editor: Editor | null;
collections: Collection[];
children: React.ReactNode;
}
const CollectionContext = createContext<CollectionContextValue | undefined>(undefined);
const CollectionProvider: React.FC<CollectionProviderProps> = ({ editor, collections: collectionClasses, 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) => {
handleShapeChange(prev, next);
});
}
}, [editor, collections]);
// Subscribe to shape deletions in the editor
useEffect(() => {
if (editor && collections) {
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
handleShapeDelete(prev);
});
}
}, [editor, collections]);
const value = useMemo(() => ({
get: (id: string) => collections?.get(id),
}), [collections]);
return (
<CollectionContext.Provider value={value}>
{collections ? children : null}
</CollectionContext.Provider>
);
};
export { CollectionContext, CollectionProvider, type Collection };

View File

@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Editor, TLShape } from '@tldraw/tldraw';
import { BaseCollection } from './BaseCollection';
type Collection = (new (editor: Editor) => BaseCollection)
class GlobalCollectionManager {
private static instance: GlobalCollectionManager;
private collections: Map<string, BaseCollection> = new Map();
private editor: Editor | null = null;
private listeners: Set<() => void> = new Set();
static getInstance(): GlobalCollectionManager {
if (!GlobalCollectionManager.instance) {
GlobalCollectionManager.instance = new GlobalCollectionManager();
}
return GlobalCollectionManager.instance;
}
initialize(editor: Editor, collectionClasses: Collection[]) {
this.editor = editor;
this.collections.clear();
for (const ColClass of collectionClasses) {
const instance = new ColClass(editor);
this.collections.set(instance.id, instance);
}
// Subscribe to shape changes
editor.sideEffects.registerAfterChangeHandler('shape', (prev, next) => {
this.handleShapeChange(prev, next);
});
// Subscribe to shape deletions
editor.sideEffects.registerAfterDeleteHandler('shape', (prev) => {
this.handleShapeDelete(prev);
});
this.notifyListeners();
}
private handleShapeChange(prev: TLShape, next: TLShape) {
for (const collection of this.collections.values()) {
if (collection.getShapes().has(next.id)) {
collection._onShapeChange(prev, next);
}
}
}
private handleShapeDelete(shape: TLShape) {
for (const collection of this.collections.values()) {
collection.remove([shape]);
}
}
getCollection(id: string): BaseCollection | undefined {
return this.collections.get(id);
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private notifyListeners() {
this.listeners.forEach(listener => listener());
}
}
// Hook to use the global collection manager
export const useGlobalCollection = (collectionId: string) => {
const [collection, setCollection] = useState<BaseCollection | null>(null);
const [size, setSize] = useState<number>(0);
useEffect(() => {
const manager = GlobalCollectionManager.getInstance();
const unsubscribe = manager.subscribe(() => {
const newCollection = manager.getCollection(collectionId);
setCollection(newCollection || null);
setSize(newCollection?.size || 0);
});
// Initial setup
const initialCollection = manager.getCollection(collectionId);
setCollection(initialCollection || null);
setSize(initialCollection?.size || 0);
return unsubscribe;
}, [collectionId]);
useEffect(() => {
if (collection) {
const unsubscribe = collection.subscribe(() => {
setSize(collection.size);
});
return unsubscribe;
}
}, [collection]);
return { collection, size };
};
// Function to initialize the global collection manager
export const initializeGlobalCollections = (editor: Editor, collectionClasses: Collection[]) => {
const manager = GlobalCollectionManager.getInstance();
manager.initialize(editor, collectionClasses);
};

152
src/collections/README.md Normal file
View File

@ -0,0 +1,152 @@
# Collections System
This directory contains a proof-of-concept collections system for @tldraw that allows you to group and track shapes with custom logic.
## Overview
The collections system provides a way to:
- Group shapes together with custom logic
- React to shape additions, removals, and changes
- Subscribe to collection changes in React components
- Maintain collections across shape modifications
## Files
- `BaseCollection.ts` - Abstract base class for all collections
- `CollectionProvider.tsx` - React context provider for collections
- `useCollection.ts` - React hook for accessing collections
- `ExampleCollection.ts` - Example collection implementation
- `ExampleCollectionComponent.tsx` - Example React component using collections
- `index.ts` - Exports all collection-related modules
## Usage
### 1. Create a Collection
Extend `BaseCollection` to create your own collection:
```typescript
import { BaseCollection } from '@/collections';
import { TLShape } from '@tldraw/tldraw';
export class MyCollection extends BaseCollection {
id = 'my-collection';
protected onAdd(shapes: TLShape[]): void {
console.log(`Added ${shapes.length} shapes to my collection`);
// Add your custom logic here
}
protected onRemove(shapes: TLShape[]): void {
console.log(`Removed ${shapes.length} shapes from my collection`);
// Add your custom logic here
}
protected onShapeChange(prev: TLShape, next: TLShape): void {
console.log('Shape changed in my collection:', { prev, next });
// Add your custom logic here
}
protected onMembershipChange(): void {
console.log(`My collection membership changed. Total shapes: ${this.size}`);
// Add your custom logic here
}
}
```
### 2. Set up the CollectionProvider
Wrap your Tldraw component with the CollectionProvider:
```typescript
import { CollectionProvider } from '@/collections';
function MyComponent() {
const [editor, setEditor] = useState<Editor | null>(null);
return (
<div>
{editor && (
<CollectionProvider editor={editor} collections={[MyCollection]}>
<Tldraw
onMount={(editor) => setEditor(editor)}
// ... other props
/>
</CollectionProvider>
)}
</div>
);
}
```
### 3. Use Collections in React Components
Use the `useCollection` hook to access collections:
```typescript
import { useCollection } from '@/collections';
function MyComponent() {
const { collection, size } = useCollection<MyCollection>('my-collection');
const handleAddShapes = () => {
const selectedShapes = collection.editor.getSelectedShapes();
if (selectedShapes.length > 0) {
collection.add(selectedShapes);
}
};
return (
<div>
<p>Collection size: {size}</p>
<button onClick={handleAddShapes}>Add Selected Shapes</button>
</div>
);
}
```
## API Reference
### BaseCollection
#### Methods
- `add(shapes: TLShape[])` - Add shapes to the collection
- `remove(shapes: TLShape[])` - Remove shapes from the collection
- `clear()` - Remove all shapes from the collection
- `getShapes(): Map<TLShapeId, TLShape>` - Get all shapes in the collection
- `subscribe(listener: () => void): () => void` - Subscribe to collection changes
#### Properties
- `size: number` - Number of shapes in the collection
- `editor: Editor` - Reference to the tldraw editor
#### Protected Methods (Override these)
- `onAdd(shapes: TLShape[])` - Called when shapes are added
- `onRemove(shapes: TLShape[])` - Called when shapes are removed
- `onShapeChange(prev: TLShape, next: TLShape)` - Called when a shape changes
- `onMembershipChange()` - Called when collection membership changes
### useCollection Hook
```typescript
const { collection, size } = useCollection<T extends BaseCollection>(collectionId: string)
```
Returns:
- `collection: T` - The collection instance
- `size: number` - Current number of shapes in the collection
## Example
See `ExampleCollection.ts` and `ExampleCollectionComponent.tsx` for a complete working example that demonstrates:
- Creating a custom collection
- Setting up the CollectionProvider
- Using the useCollection hook
- Adding/removing shapes from collections
- Reacting to collection changes
The example is integrated into the Board component and provides a UI for testing the collection functionality.

5
src/collections/index.ts Normal file
View File

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

View File

@ -0,0 +1,32 @@
import { useContext, useEffect, useState } from "react";
import { CollectionContext } from "./CollectionProvider";
import { BaseCollection } from "./BaseCollection";
export const useCollection = <T extends BaseCollection = BaseCollection>(collectionId: string): { collection: T | null; size: number } => {
const context = useContext(CollectionContext);
if (!context) {
return { collection: null, size: 0 };
}
const collection = context.get(collectionId);
if (!collection) {
return { collection: null, size: 0 };
}
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 };
};

34
src/css/dev-ui.css Normal file
View File

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

View File

@ -0,0 +1,277 @@
import { Layout } from 'webcola';
import { BaseCollection } from '../collections';
import { Editor, TLArrowShape, TLGeoShape, TLShape, TLShapeId } from '@tldraw/tldraw';
type ColaNode = {
id: TLShapeId;
x: number;
y: number;
width: number;
height: number;
rotation: number;
color?: string;
};
type ColaIdLink = {
source: TLShapeId
target: TLShapeId
};
type ColaNodeLink = {
source: ColaNode
target: ColaNode
};
type AlignmentConstraint = {
type: 'alignment',
axis: 'x' | 'y',
offsets: { node: TLShapeId, offset: number }[]
}
type ColaConstraint = AlignmentConstraint
export class GraphLayoutCollection extends BaseCollection {
override id = 'graph';
graphSim: Layout;
animFrame = -1;
colaNodes: Map<TLShapeId, ColaNode> = new Map();
colaLinks: Map<TLShapeId, ColaIdLink> = new Map();
colaConstraints: ColaConstraint[] = [];
constructor(editor: Editor) {
super(editor)
this.graphSim = new Layout();
const simLoop = () => {
this.step();
this.animFrame = requestAnimationFrame(simLoop);
};
simLoop();
}
override onAdd(shapes: TLShape[]) {
for (const shape of shapes) {
if (shape.type !== "arrow") {
this.addGeo(shape);
}
else {
this.addArrow(shape as TLArrowShape);
}
}
this.refreshGraph();
}
override onRemove(shapes: TLShape[]) {
const removedShapeIds = new Set(shapes.map(shape => shape.id));
for (const shape of shapes) {
this.colaNodes.delete(shape.id);
this.colaLinks.delete(shape.id);
}
// Filter out links where either source or target has been removed
for (const [key, link] of this.colaLinks) {
if (removedShapeIds.has(link.source) || removedShapeIds.has(link.target)) {
this.colaLinks.delete(key);
}
}
this.refreshGraph();
}
override onShapeChange(prev: TLShape, next: TLShape) {
if (prev.type === 'geo' && next.type === 'geo') {
const prevShape = prev as TLGeoShape
const nextShape = next as TLGeoShape
// update color if its changed and refresh constraints which use this
if (prevShape.props.color !== nextShape.props.color) {
const existingNode = this.colaNodes.get(next.id);
if (existingNode) {
this.colaNodes.set(next.id, {
...existingNode,
color: nextShape.props.color,
});
}
this.refreshGraph();
}
}
}
step = () => {
this.graphSim.start(1, 0, 0, 0, true, false);
for (const node of this.graphSim.nodes() as ColaNode[]) {
const shape = this.editor.getShape(node.id);
const { w, h } = this.editor.getShapeGeometry(node.id).bounds
if (!shape) continue;
const { x, y } = getCornerToCenterOffset(w, h, shape.rotation);
// Fix positions if we're dragging them
if (this.editor.getSelectedShapeIds().includes(node.id)) {
node.x = shape.x + x;
node.y = shape.y + y;
}
// Update shape props
node.width = w;
node.height = h;
node.rotation = shape.rotation;
this.editor.updateShape({
id: node.id,
type: "geo",
x: node.x - x,
y: node.y - y,
});
}
};
addArrow = (arrow: TLArrowShape) => {
const bindings = this.editor.getBindingsInvolvingShape(arrow.id);
if (bindings.length !== 2) return;
const startBinding = bindings.find(binding => (binding.props as any).terminal === 'start');
const endBinding = bindings.find(binding => (binding.props as any).terminal === 'end');
if (startBinding && endBinding) {
const source = this.editor.getShape(startBinding.toId);
const target = this.editor.getShape(endBinding.toId);
if (source && target) {
const link: ColaIdLink = {
source: source.id,
target: target.id
};
this.colaLinks.set(arrow.id, link);
}
}
}
addGeo = (shape: TLShape) => {
const { w, h } = this.editor.getShapeGeometry(shape).bounds
const { x, y } = getCornerToCenterOffset(w, h, shape.rotation)
const node: ColaNode = {
id: shape.id,
x: shape.x + x,
y: shape.y + y,
width: w,
height: h,
rotation: shape.rotation,
color: (shape.props as any).color
};
this.colaNodes.set(shape.id, node);
}
refreshGraph() {
// TODO: remove this hardcoded behaviour
this.editor.selectNone()
this.refreshConstraints();
const nodes = [...this.colaNodes.values()];
const nodeIdToIndex = new Map(nodes.map((n, i) => [n.id, i]));
// Convert the Map values to an array for processing
const links = Array.from(this.colaLinks.values()).map(l => ({
source: nodeIdToIndex.get(l.source),
target: nodeIdToIndex.get(l.target)
}));
const constraints = this.colaConstraints.map(constraint => {
if (constraint.type === 'alignment') {
return {
...constraint,
offsets: constraint.offsets.map(offset => ({
node: nodeIdToIndex.get(offset.node),
offset: offset.offset
}))
};
}
return constraint;
});
this.graphSim
.nodes(nodes)
// @ts-ignore
.links(links)
.constraints(constraints)
// you could use .linkDistance(250) too, which is stable but does not handle size/rotation
.linkDistance((edge) => calcEdgeDistance(edge as ColaNodeLink))
.avoidOverlaps(true)
.handleDisconnected(true)
}
refreshConstraints() {
const alignmentConstraintX: AlignmentConstraint = {
type: 'alignment',
axis: 'x',
offsets: [],
};
const alignmentConstraintY: AlignmentConstraint = {
type: 'alignment',
axis: 'y',
offsets: [],
};
// Iterate over shapes and generate constraints based on conditions
for (const node of this.colaNodes.values()) {
if (node.color === "red") {
// Add alignment offset for red shapes
alignmentConstraintX.offsets.push({ node: node.id, offset: 0 });
}
if (node.color === "blue") {
// Add alignment offset for red shapes
alignmentConstraintY.offsets.push({ node: node.id, offset: 0 });
}
}
const constraints = [];
if (alignmentConstraintX.offsets.length > 0) {
constraints.push(alignmentConstraintX);
}
if (alignmentConstraintY.offsets.length > 0) {
constraints.push(alignmentConstraintY);
}
this.colaConstraints = constraints;
}
}
function getCornerToCenterOffset(w: number, h: number, rotation: number) {
// Calculate the center coordinates relative to the top-left corner
const centerX = w / 2;
const centerY = h / 2;
// Apply rotation to the center coordinates
const rotatedCenterX = centerX * Math.cos(rotation) - centerY * Math.sin(rotation);
const rotatedCenterY = centerX * Math.sin(rotation) + centerY * Math.cos(rotation);
return { x: rotatedCenterX, y: rotatedCenterY };
}
function calcEdgeDistance(edge: ColaNodeLink) {
const LINK_DISTANCE = 100;
// horizontal and vertical distances between centers
const dx = edge.target.x - edge.source.x;
const dy = edge.target.y - edge.source.y;
// the angles of the nodes in radians
const sourceAngle = edge.source.rotation;
const targetAngle = edge.target.rotation;
// Calculate the rotated dimensions of the nodes
const sourceWidth = Math.abs(edge.source.width * Math.cos(sourceAngle)) + Math.abs(edge.source.height * Math.sin(sourceAngle));
const sourceHeight = Math.abs(edge.source.width * Math.sin(sourceAngle)) + Math.abs(edge.source.height * Math.cos(sourceAngle));
const targetWidth = Math.abs(edge.target.width * Math.cos(targetAngle)) + Math.abs(edge.target.height * Math.sin(targetAngle));
const targetHeight = Math.abs(edge.target.width * Math.sin(targetAngle)) + Math.abs(edge.target.height * Math.cos(targetAngle));
// Calculate edge-to-edge distances
const horizontalGap = Math.max(0, Math.abs(dx) - (sourceWidth + targetWidth) / 2);
const verticalGap = Math.max(0, Math.abs(dy) - (sourceHeight + targetHeight) / 2);
// Calculate straight-line distance between the centers of the nodes
const centerToCenterDistance = Math.sqrt(dx * dx + dy * dy);
// Adjust the distance by subtracting the edge-to-edge distance and adding the desired travel distance
const adjustedDistance = centerToCenterDistance -
Math.sqrt(horizontalGap * horizontalGap + verticalGap * verticalGap) +
LINK_DISTANCE;
return adjustedDistance;
};

90
src/graph/GraphUi.tsx Normal file
View File

@ -0,0 +1,90 @@
import { useEditor } from "@tldraw/tldraw";
import { useEffect } from "react";
import "../css/dev-ui.css";
import { useCollection } from "@/collections";
export const GraphUi = () => {
const editor = useEditor();
const { collection, size } = useCollection('graph')
const handleAdd = () => {
if (collection) {
collection.add(editor.getSelectedShapes())
editor.selectNone()
}
}
const handleRemove = () => {
if (collection) {
collection.remove(editor.getSelectedShapes())
editor.selectNone()
}
}
const handleShortcut = () => {
if (!collection) return
const empty = collection.getShapes().size === 0
if (empty)
collection.add(editor.getCurrentPageShapes())
else
collection.clear()
};
const handleHighlight = () => {
if (collection) {
editor.setHintingShapes([...collection.getShapes().values()])
}
}
const handleHelp = () => {
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.");
}
useEffect(() => {
window.addEventListener('toggleGraphLayoutEvent', handleShortcut);
return () => {
window.removeEventListener('toggleGraphLayoutEvent', handleShortcut);
};
}, [handleShortcut]);
return (
<div className="custom-layout">
<div className="custom-toolbar">
<div>{size} shapes</div>
<button
type="button"
title="Add Selected"
className="custom-button"
onClick={handleAdd}
>
Add
</button>
<button
type="button"
title="Remove Selected"
className="custom-button"
onClick={handleRemove}
>
Remove
</button>
<button
type="button"
title="Highlight Collection"
className="custom-button"
onClick={handleHighlight}
>
🔦
</button>
<button
type="button"
title="Show Help"
className="custom-button"
onClick={handleHelp}
>
</button>
</div>
</div>
);
};

21
src/graph/uiOverrides.ts Normal file
View File

@ -0,0 +1,21 @@
import {
TLUiEventSource,
TLUiOverrides,
TLUiTranslationKey,
} from "@tldraw/tldraw";
export const uiOverrides: TLUiOverrides = {
actions(_editor, actions) {
actions['toggle-graph-layout'] = {
id: 'toggle-graph-layout',
label: 'Toggle Graph Layout' as TLUiTranslationKey,
readonlyOk: true,
kbd: 'g',
onSelect(_source: TLUiEventSource) {
const event = new CustomEvent('toggleGraphLayoutEvent');
window.dispatchEvent(event);
},
}
return actions
}
}

View File

@ -29,7 +29,6 @@ import { SlideShape } from "@/shapes/SlideShapeUtil"
import { makeRealSettings, applySettingsMigrations } from "@/lib/settings"
import { PromptShapeTool } from "@/tools/PromptShapeTool"
import { PromptShape } from "@/shapes/PromptShapeUtil"
import { llm } from "@/utils/llmUtils"
import {
lockElement,
unlockElement,
@ -37,6 +36,10 @@ import {
initLockIndicators,
watchForLockedShapes,
} from "@/ui/cameraUtils"
import { Collection, initializeGlobalCollections } from "@/collections"
import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
const collections: Collection[] = [GraphLayoutCollection]
// Default to production URL if env var isn't available
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
@ -145,6 +148,8 @@ export function Board() {
ChangePropagator,
ClickPropagator,
])
// Initialize global collections
initializeGlobalCollections(editor, collections)
}}
/>
</div>

View File

@ -21,6 +21,7 @@ import { llm } from "../utils/llmUtils"
import { getEdge } from "@/propagators/tlgraph"
import { getCustomActions } from './overrides'
import { overrides } from './overrides'
import { useGlobalCollection } from "@/collections"
const getAllFrames = (editor: Editor) => {
return editor
@ -40,6 +41,9 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
const [selectedShapes, setSelectedShapes] = useState<TLShape[]>([])
const [selectedIds, setSelectedIds] = useState<string[]>([])
// Collection functionality using the global collection manager
const { collection, size } = useGlobalCollection('graph')
// Update selection state more frequently
useEffect(() => {
const updateSelection = () => {
@ -63,6 +67,55 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
const hasSelection = selectedIds.length > 0
const hasCameraHistory = cameraHistory.length > 0
// Collection handlers
const handleAddToCollection = () => {
if (collection) {
collection.add(editor.getSelectedShapes())
editor.selectNone()
}
}
const handleRemoveFromCollection = () => {
if (collection) {
collection.remove(editor.getSelectedShapes())
editor.selectNone()
}
}
const handleHighlightCollection = () => {
if (collection) {
editor.setHintingShapes([...collection.getShapes().values()])
}
}
// Check if selected shapes are already in collection
const selectedShapesInCollection = collection ?
selectedShapes.filter(shape => collection.getShapes().has(shape.id)) : []
const hasSelectedShapesInCollection = selectedShapesInCollection.length > 0
const allSelectedShapesInCollection = selectedShapes.length > 0 && selectedShapesInCollection.length === selectedShapes.length
// Check if collection functionality is available
const hasCollectionContext = collection !== null
// Keyboard shortcut for adding to collection
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'c' && !event.ctrlKey && !event.altKey && !event.metaKey) {
event.preventDefault()
if (hasSelection && collection && !allSelectedShapesInCollection) {
handleAddToCollection()
}
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [hasSelection, collection, allSelectedShapesInCollection])
//TO DO: Fix camera history for camera revert
return (
@ -113,6 +166,35 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
<TldrawUiMenuItem {...tools.Prompt} disabled={hasSelection} />
</TldrawUiMenuGroup>
{/* Collections Group */}
<TldrawUiMenuGroup id="collections">
<TldrawUiMenuSubmenu id="collections-dropdown" label="Collections">
<TldrawUiMenuItem
id="add-to-collection"
label="Add to Collection"
icon="plus"
kbd="c"
disabled={!hasSelection || !collection}
onSelect={handleAddToCollection}
/>
<TldrawUiMenuItem
id="remove-from-collection"
label="Remove from Collection"
icon="minus"
disabled={!hasSelection || !collection}
onSelect={handleRemoveFromCollection}
/>
<TldrawUiMenuItem
id="highlight-collection"
label="Highlight Collection"
icon="lightbulb"
disabled={!collection}
onSelect={handleHighlightCollection}
/>
</TldrawUiMenuSubmenu>
</TldrawUiMenuGroup>
{/* TODO: FIX & IMPLEMENT BROADCASTING*/}
{/* <TldrawUiMenuGroup id="broadcast-controls">