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",
|
"react-router-dom": "^7.0.2",
|
||||||
"recoil": "^0.7.7",
|
"recoil": "^0.7.7",
|
||||||
"tldraw": "^3.6.0",
|
"tldraw": "^3.6.0",
|
||||||
"vercel": "^39.1.1"
|
"vercel": "^39.1.1",
|
||||||
|
"webcola": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/types": "^6.0.0",
|
"@cloudflare/types": "^6.0.0",
|
||||||
|
|
@ -11328,6 +11329,61 @@
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true
|
"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": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@
|
||||||
"react-router-dom": "^7.0.2",
|
"react-router-dom": "^7.0.2",
|
||||||
"recoil": "^0.7.7",
|
"recoil": "^0.7.7",
|
||||||
"tldraw": "^3.6.0",
|
"tldraw": "^3.6.0",
|
||||||
"vercel": "^39.1.1"
|
"vercel": "^39.1.1",
|
||||||
|
"webcola": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/types": "^6.0.0",
|
"@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 { makeRealSettings, applySettingsMigrations } from "@/lib/settings"
|
||||||
import { PromptShapeTool } from "@/tools/PromptShapeTool"
|
import { PromptShapeTool } from "@/tools/PromptShapeTool"
|
||||||
import { PromptShape } from "@/shapes/PromptShapeUtil"
|
import { PromptShape } from "@/shapes/PromptShapeUtil"
|
||||||
import { llm } from "@/utils/llmUtils"
|
|
||||||
import {
|
import {
|
||||||
lockElement,
|
lockElement,
|
||||||
unlockElement,
|
unlockElement,
|
||||||
|
|
@ -37,6 +36,10 @@ import {
|
||||||
initLockIndicators,
|
initLockIndicators,
|
||||||
watchForLockedShapes,
|
watchForLockedShapes,
|
||||||
} from "@/ui/cameraUtils"
|
} 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
|
// Default to production URL if env var isn't available
|
||||||
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
|
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
|
||||||
|
|
@ -134,18 +137,20 @@ export function Board() {
|
||||||
64, // Max zoom
|
64, // Max zoom
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
setEditor(editor)
|
setEditor(editor)
|
||||||
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
|
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
|
||||||
editor.setCurrentTool("hand")
|
editor.setCurrentTool("hand")
|
||||||
setInitialCameraFromUrl(editor)
|
setInitialCameraFromUrl(editor)
|
||||||
handleInitialPageLoad(editor)
|
handleInitialPageLoad(editor)
|
||||||
registerPropagators(editor, [
|
registerPropagators(editor, [
|
||||||
TickPropagator,
|
TickPropagator,
|
||||||
ChangePropagator,
|
ChangePropagator,
|
||||||
ClickPropagator,
|
ClickPropagator,
|
||||||
])
|
])
|
||||||
}}
|
// Initialize global collections
|
||||||
|
initializeGlobalCollections(editor, collections)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { llm } from "../utils/llmUtils"
|
||||||
import { getEdge } from "@/propagators/tlgraph"
|
import { getEdge } from "@/propagators/tlgraph"
|
||||||
import { getCustomActions } from './overrides'
|
import { getCustomActions } from './overrides'
|
||||||
import { overrides } from './overrides'
|
import { overrides } from './overrides'
|
||||||
|
import { useGlobalCollection } from "@/collections"
|
||||||
|
|
||||||
const getAllFrames = (editor: Editor) => {
|
const getAllFrames = (editor: Editor) => {
|
||||||
return editor
|
return editor
|
||||||
|
|
@ -40,6 +41,9 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
const [selectedShapes, setSelectedShapes] = useState<TLShape[]>([])
|
const [selectedShapes, setSelectedShapes] = useState<TLShape[]>([])
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
// Collection functionality using the global collection manager
|
||||||
|
const { collection, size } = useGlobalCollection('graph')
|
||||||
|
|
||||||
// Update selection state more frequently
|
// Update selection state more frequently
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateSelection = () => {
|
const updateSelection = () => {
|
||||||
|
|
@ -63,6 +67,55 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
const hasSelection = selectedIds.length > 0
|
const hasSelection = selectedIds.length > 0
|
||||||
const hasCameraHistory = cameraHistory.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
|
//TO DO: Fix camera history for camera revert
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -113,6 +166,35 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
<TldrawUiMenuItem {...tools.Prompt} disabled={hasSelection} />
|
<TldrawUiMenuItem {...tools.Prompt} disabled={hasSelection} />
|
||||||
</TldrawUiMenuGroup>
|
</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*/}
|
{/* TODO: FIX & IMPLEMENT BROADCASTING*/}
|
||||||
{/* <TldrawUiMenuGroup id="broadcast-controls">
|
{/* <TldrawUiMenuGroup id="broadcast-controls">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue