implemented collections and graph layout tool
This commit is contained in:
parent
ea66699783
commit
bc831c7516
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from './BaseCollection';
|
||||
export * from './CollectionProvider';
|
||||
export * from './CollectionContextWrapper';
|
||||
export * from './GlobalCollectionManager';
|
||||
export * from './useCollection';
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue