From 15fa9b8d195672c02c239c37cf85145a96e30091 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 29 Jul 2025 14:52:57 -0400 Subject: [PATCH] implemented collections and graph layout tool --- package-lock.json | 58 +++- package.json | 3 +- src/collections/BaseCollection.ts | 107 +++++++ src/collections/CollectionContextWrapper.tsx | 111 ++++++++ src/collections/CollectionProvider.tsx | 82 ++++++ src/collections/GlobalCollectionManager.tsx | 110 ++++++++ src/collections/README.md | 152 ++++++++++ src/collections/index.ts | 5 + src/collections/useCollection.ts | 32 +++ src/css/dev-ui.css | 34 +++ src/graph/GraphLayoutCollection.tsx | 277 +++++++++++++++++++ src/graph/GraphUi.tsx | 90 ++++++ src/graph/uiOverrides.ts | 21 ++ src/routes/Board.tsx | 31 ++- src/ui/CustomContextMenu.tsx | 82 ++++++ 15 files changed, 1180 insertions(+), 15 deletions(-) create mode 100644 src/collections/BaseCollection.ts create mode 100644 src/collections/CollectionContextWrapper.tsx create mode 100644 src/collections/CollectionProvider.tsx create mode 100644 src/collections/GlobalCollectionManager.tsx create mode 100644 src/collections/README.md create mode 100644 src/collections/index.ts create mode 100644 src/collections/useCollection.ts create mode 100644 src/css/dev-ui.css create mode 100644 src/graph/GraphLayoutCollection.tsx create mode 100644 src/graph/GraphUi.tsx create mode 100644 src/graph/uiOverrides.ts diff --git a/package-lock.json b/package-lock.json index 3334d41..7cd9612 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b6801a8..5eba8a9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/collections/BaseCollection.ts b/src/collections/BaseCollection.ts new file mode 100644 index 0000000..618aef2 --- /dev/null +++ b/src/collections/BaseCollection.ts @@ -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 = 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 { + 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); + } +} \ No newline at end of file diff --git a/src/collections/CollectionContextWrapper.tsx b/src/collections/CollectionContextWrapper.tsx new file mode 100644 index 0000000..f7ccbf6 --- /dev/null +++ b/src/collections/CollectionContextWrapper.tsx @@ -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(undefined); + +export const CollectionContextWrapper: React.FC = ({ + editor, + collections: collectionClasses, + children +}) => { + const [collections, setCollections] = useState | 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(); + 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 ( + + {children} + + ); +}; + +// Hook to use collection context within the wrapper +export const useCollectionContext = ( + 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(collection.size); + + useEffect(() => { + const unsubscribe = collection.subscribe(() => { + setSize(collection.size); + }); + + setSize(collection.size); + return unsubscribe; + }, [collection]); + + return { collection: collection as T, size }; +}; \ No newline at end of file diff --git a/src/collections/CollectionProvider.tsx b/src/collections/CollectionProvider.tsx new file mode 100644 index 0000000..f17933b --- /dev/null +++ b/src/collections/CollectionProvider.tsx @@ -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(undefined); + +const CollectionProvider: React.FC = ({ editor, collections: collectionClasses, children }) => { + const [collections, setCollections] = useState | 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(); + 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 ( + + {collections ? children : null} + + ); +}; + +export { CollectionContext, CollectionProvider, type Collection }; \ No newline at end of file diff --git a/src/collections/GlobalCollectionManager.tsx b/src/collections/GlobalCollectionManager.tsx new file mode 100644 index 0000000..82d7409 --- /dev/null +++ b/src/collections/GlobalCollectionManager.tsx @@ -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 = 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(null); + const [size, setSize] = useState(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); +}; \ No newline at end of file diff --git a/src/collections/README.md b/src/collections/README.md new file mode 100644 index 0000000..60eef6f --- /dev/null +++ b/src/collections/README.md @@ -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(null); + + return ( +
+ {editor && ( + + setEditor(editor)} + // ... other props + /> + + )} +
+ ); +} +``` + +### 3. Use Collections in React Components + +Use the `useCollection` hook to access collections: + +```typescript +import { useCollection } from '@/collections'; + +function MyComponent() { + const { collection, size } = useCollection('my-collection'); + + const handleAddShapes = () => { + const selectedShapes = collection.editor.getSelectedShapes(); + if (selectedShapes.length > 0) { + collection.add(selectedShapes); + } + }; + + return ( +
+

Collection size: {size}

+ +
+ ); +} +``` + +## 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` - 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(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. \ No newline at end of file diff --git a/src/collections/index.ts b/src/collections/index.ts new file mode 100644 index 0000000..4b1e353 --- /dev/null +++ b/src/collections/index.ts @@ -0,0 +1,5 @@ +export * from './BaseCollection'; +export * from './CollectionProvider'; +export * from './CollectionContextWrapper'; +export * from './GlobalCollectionManager'; +export * from './useCollection'; diff --git a/src/collections/useCollection.ts b/src/collections/useCollection.ts new file mode 100644 index 0000000..5e8df90 --- /dev/null +++ b/src/collections/useCollection.ts @@ -0,0 +1,32 @@ +import { useContext, useEffect, useState } from "react"; +import { CollectionContext } from "./CollectionProvider"; +import { BaseCollection } from "./BaseCollection"; + +export const useCollection = (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(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 }; +}; \ No newline at end of file diff --git a/src/css/dev-ui.css b/src/css/dev-ui.css new file mode 100644 index 0000000..d9e3259 --- /dev/null +++ b/src/css/dev-ui.css @@ -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; + } \ No newline at end of file diff --git a/src/graph/GraphLayoutCollection.tsx b/src/graph/GraphLayoutCollection.tsx new file mode 100644 index 0000000..6eb37cf --- /dev/null +++ b/src/graph/GraphLayoutCollection.tsx @@ -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 = new Map(); + colaLinks: Map = 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; +}; \ No newline at end of file diff --git a/src/graph/GraphUi.tsx b/src/graph/GraphUi.tsx new file mode 100644 index 0000000..75e8c35 --- /dev/null +++ b/src/graph/GraphUi.tsx @@ -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 ( +
+
+
{size} shapes
+ + + + +
+
+ ); +}; \ No newline at end of file diff --git a/src/graph/uiOverrides.ts b/src/graph/uiOverrides.ts new file mode 100644 index 0000000..af1558f --- /dev/null +++ b/src/graph/uiOverrides.ts @@ -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 + } + } \ No newline at end of file diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index d67f9a9..4db61c5 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -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" @@ -134,18 +137,20 @@ export function Board() { 64, // Max zoom ], }} - onMount={(editor) => { - setEditor(editor) - editor.registerExternalAssetHandler("url", unfurlBookmarkUrl) - editor.setCurrentTool("hand") - setInitialCameraFromUrl(editor) - handleInitialPageLoad(editor) - registerPropagators(editor, [ - TickPropagator, - ChangePropagator, - ClickPropagator, - ]) - }} + onMount={(editor) => { + setEditor(editor) + editor.registerExternalAssetHandler("url", unfurlBookmarkUrl) + editor.setCurrentTool("hand") + setInitialCameraFromUrl(editor) + handleInitialPageLoad(editor) + registerPropagators(editor, [ + TickPropagator, + ChangePropagator, + ClickPropagator, + ]) + // Initialize global collections + initializeGlobalCollections(editor, collections) + }} /> ) diff --git a/src/ui/CustomContextMenu.tsx b/src/ui/CustomContextMenu.tsx index f5bd02e..25f2f21 100644 --- a/src/ui/CustomContextMenu.tsx +++ b/src/ui/CustomContextMenu.tsx @@ -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([]) const [selectedIds, setSelectedIds] = useState([]) + // 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) { + {/* Collections Group */} + + + + + + + + + {/* TODO: FIX & IMPLEMENT BROADCASTING*/} {/*