diff --git a/src/App.tsx b/src/App.tsx
index 8659c84..affbe10 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,14 +1,14 @@
-import { Tldraw, track, useEditor } from "tldraw";
import "tldraw/tldraw.css";
-// import { DevShapeTool } from "./DevShape/DevShapeTool";
-// import { DevShapeUtil } from "./DevShape/DevShapeUtil";
-// import { DevUi } from "./DevUI";
-// import { uiOverrides } from "./ui-overrides";
+import { Tldraw } from "tldraw";
import { useYjsStore } from "./useYjsStore";
import { AgentButton } from "./components/AgentButton";
+import { SocialShapeUtil } from "./SocialShapeUtil";
+import { SocialShapeTool } from "./SocialShapeTool";
+import { CustomToolbar, overrides } from "./ui";
+
+const shapeUtils = [SocialShapeUtil];
+const tools = [SocialShapeTool];
-// const customShapeUtils = [DevShapeUtil];
-// const customTools = [DevShapeTool];
const HOST_URL = import.meta.env.DEV
? "ws://localhost:1234"
@@ -20,7 +20,7 @@ export default function Canvas() {
const store = useYjsStore({
roomId: roomId,
hostUrl: HOST_URL,
- // shapeUtils: customShapeUtils,
+ shapeUtils: shapeUtils,
});
return (
@@ -28,66 +28,69 @@ export default function Canvas() {
{
+ //@ts-ignore
+ editor.getStateDescendant('select.idle').handleDoubleClickOnCanvas = () => void null;
+ }}
components={{
SharePanel: AgentButton,
+ Toolbar: CustomToolbar,
}}
- // shapeUtils={customShapeUtils}
- // tools={customTools}
- // overrides={uiOverrides}
- >
- {/* */}
-
+ />
);
}
-const NameEditor = track(() => {
- const editor = useEditor();
+// const NameEditor = track(() => {
+// const editor = useEditor();
- const { color, name } = editor.user.getUserPreferences();
+// const { color, name } = editor.user.getUserPreferences();
- return (
-
- {
- editor.user.updateUserPreferences({
- color: e.currentTarget.value,
- });
- }}
- />
- {
- editor.user.updateUserPreferences({
- name: e.currentTarget.value,
- });
- }}
- />
-
- );
-});
+// return (
+//
+// {
+// editor.user.updateUserPreferences({
+// color: e.currentTarget.value,
+// });
+// }}
+// />
+// {
+// editor.user.updateUserPreferences({
+// name: e.currentTarget.value,
+// });
+// }}
+// />
+//
+// );
+// });
diff --git a/src/SocialShapeTool.ts b/src/SocialShapeTool.ts
new file mode 100644
index 0000000..7403827
--- /dev/null
+++ b/src/SocialShapeTool.ts
@@ -0,0 +1,9 @@
+import { BaseBoxShapeTool } from 'tldraw'
+
+export class SocialShapeTool extends BaseBoxShapeTool {
+ static override id = 'social'
+ static override initial = 'idle'
+ override shapeType = 'social'
+
+}
+
diff --git a/src/SocialShapeUtil.tsx b/src/SocialShapeUtil.tsx
new file mode 100644
index 0000000..b12b6ec
--- /dev/null
+++ b/src/SocialShapeUtil.tsx
@@ -0,0 +1,87 @@
+import {
+ BaseBoxShapeUtil,
+ Geometry2d,
+ HTMLContainer,
+ Rectangle2d,
+ TLBaseShape,
+ TLOnResizeHandler,
+ resizeBox,
+ toDomPrecision,
+} from 'tldraw'
+
+export type ValueType = "scalar" | "boolean" | null
+
+export type ISocialShape = TLBaseShape<
+ "social",
+ {
+ w: number
+ h: number
+ text: string
+ selector: string
+ valueType: ValueType
+ }
+>
+
+export class SocialShapeUtil extends BaseBoxShapeUtil {
+ static override type = 'social' as const
+ override canBind = () => false
+ override canEdit = () => false
+ override getDefaultProps(): ISocialShape['props'] {
+ return { w: 160 * 2, h: 90 * 2, text: '', selector: '', valueType: null }
+ }
+ override onResize: TLOnResizeHandler = (shape, info) => {
+ return resizeBox(shape, info)
+ }
+ override getGeometry(shape: ISocialShape): Geometry2d {
+ return new Rectangle2d({
+ width: shape.props.w,
+ height: shape.props.h,
+ isFilled: true,
+ })
+ }
+ indicator(shape: ISocialShape) {
+ const bounds = this.editor.getShapeGeometry(shape).bounds
+ return (
+
+ )
+ }
+
+ override component(machine: ISocialShape) {
+ return (
+
+
+ )
+ }
+
+
+
+ private updateProps(shape: ISocialShape, props: Partial) {
+ this.editor.updateShape({
+ id: shape.id,
+ type: 'social',
+ props: {
+ ...shape.props,
+ ...props
+ },
+ })
+ }
+
+ // static removeParticipantsNotInRoom(editor: Editor, shapeId: TLShapeId) {
+ // const roomMembers = getRoomMembers(editor)
+ // const _shape = editor.getShape(shapeId)
+ // if (!_shape || _shape.type !== 'gmachine') return
+ // const shape = _shape as IGMachineShape
+ // const participants = new Map(shape.props.participants.map(p => [p.id, p]))
+ // participants.forEach((participant: any) => {
+ // if (!roomMembers.some(rm => rm.id === participant.id)) {
+ // participants.delete(participant.id)
+ // }
+ // })
+ // updateProps(editor, shape, { participants: Array.from(participants.values()) })
+ // }
+}
diff --git a/src/components/AgentButton.tsx b/src/components/AgentButton.tsx
index 04b09b8..873a7b8 100644
--- a/src/components/AgentButton.tsx
+++ b/src/components/AgentButton.tsx
@@ -40,6 +40,18 @@ export function AgentButton() {
setBoundToAgent(name)
}
+ const handleAdd = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ const name = randomName()
+ const color = randomHexColor()
+ setAgents([...agents, { name, boundClientIds: [], color }])
+ }
+
+ const handleRename = (name: string, newName: string) => {
+ setBoundToAgent(newName)
+ setAgents(agents.map(agent => agent.name === name ? { ...agent, name: newName } : agent))
+ }
+
return (
{you && handleRename(you.name, newName)}
onColorChange={(color) => handleColorChange(you.name, color)}
onDelete={() => handleDelete(you.name)}
onBind={() => handleBind(you.name)}
@@ -75,28 +89,60 @@ export function AgentButton() {
handleColorChange(agent.name, color)}
onDelete={() => handleDelete(agent.name)}
onBind={() => handleBind(agent.name)}
/>
))}
+
+ handleAdd(e)} />
)
}
-function AgentItem({ agent, isUnbound, onColorChange, onDelete, onBind }: { agent: Agent, isUnbound?: boolean, onColorChange: (color: string) => void, onDelete: () => void, onBind: () => void }) {
+function AgentItem({ agent, isReadonly, canRename, onColorChange, onRename, onDelete, onBind }: { agent: Agent, isReadonly?: boolean, canRename?: boolean, onColorChange: (color: string) => void, onRename?: (name: string) => void, onDelete: () => void, onBind: () => void }) {
return
}
+function AddAgentButton({ onAdd }: { onAdd: (e: React.MouseEvent) => void }) {
+ return
+
+
+}
function Separator() {
return void }) {
return (
);
+}
+
+function randomName(): string {
+ const firstNames = ['Boba', 'Zap', 'Fizz', 'Glorp', 'Squish', 'Blip', 'Floof', 'Ziggy', 'Quark', 'Noodle', 'AI'];
+ const lastNames = ['Bubbles', 'Zoomers', 'Wiggles', 'Snazzle', 'Boop', 'Fizzle', 'Wobble', 'Giggle', 'Squeak', 'Noodle', 'Palace'];
+ return `${firstNames[Math.floor(Math.random() * firstNames.length)]} ${lastNames[Math.floor(Math.random() * lastNames.length)]}`
+}
+
+function randomHexColor(): string {
+ return `#${Math.floor(Math.random() * 16777215).toString(16)}`
}
\ No newline at end of file
diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx
index ceb5af9..f25cca6 100644
--- a/src/components/Dropdown.tsx
+++ b/src/components/Dropdown.tsx
@@ -95,6 +95,8 @@ export function DropdownItem({
border: 'none',
outline: 'none',
}}
+ onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
+ onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
// onClick={(e) => {
// e.preventDefault()
// action?.()
diff --git a/src/ui.tsx b/src/ui.tsx
new file mode 100644
index 0000000..599eee9
--- /dev/null
+++ b/src/ui.tsx
@@ -0,0 +1,38 @@
+import {
+ TLUiOverrides,
+ useTools,
+ useIsToolSelected,
+ DefaultToolbar,
+ TldrawUiMenuItem,
+ DefaultToolbarContent,
+ DefaultToolbarProps,
+} from "tldraw"
+
+export const overrides: TLUiOverrides = {
+ tools(editor, tools) {
+ return {
+ ...tools,
+ social: {
+ id: "social",
+ name: "Social",
+ icon: "color",
+ kbd: "s",
+ label: "Social",
+ onSelect: () => {
+ editor.setCurrentTool("social")
+ },
+ },
+ }
+ },
+}
+
+export const CustomToolbar = (props: DefaultToolbarProps) => {
+ const tools = useTools()
+ const isSocialSelected = useIsToolSelected(tools.social)
+ return (
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/useYjsStore2.ts b/src/useYjsStore2.ts
deleted file mode 100644
index 12cb9ab..0000000
--- a/src/useYjsStore2.ts
+++ /dev/null
@@ -1,330 +0,0 @@
-import {
- InstancePresenceRecordType,
- TLAnyShapeUtilConstructor,
- TLInstancePresence,
- TLRecord,
- TLStoreWithStatus,
- computed,
- createPresenceStateDerivation,
- createTLStore,
- defaultShapeUtils,
- defaultUserPreferences,
- getUserPreferences,
- setUserPreferences,
- react,
- SerializedSchema,
-} from 'tldraw'
-import { useEffect, useMemo, useState } from 'react'
-import { YKeyValue } from 'y-utility/y-keyvalue'
-import { WebsocketProvider } from 'y-websocket'
-import * as Y from 'yjs'
-
-export function useYjsStore({
- roomId = 'example',
- hostUrl = import.meta.env.MODE === 'development'
- ? 'ws://localhost:1234'
- : 'wss://demos.yjs.dev',
- shapeUtils = [],
-}: Partial<{
- hostUrl: string
- roomId: string
- version: number
- shapeUtils: TLAnyShapeUtilConstructor[]
-}>) {
- const [store] = useState(() => {
- const store = createTLStore({
- shapeUtils: [...defaultShapeUtils, ...shapeUtils],
- })
-
- return store
- })
-
- const [storeWithStatus, setStoreWithStatus] = useState({
- status: 'loading',
- })
-
- const { yDoc, yStore, meta, room } = useMemo(() => {
- const yDoc = new Y.Doc({ gc: true })
- const yArr = yDoc.getArray<{ key: string; val: TLRecord }>(`tl_${roomId}`)
- const yStore = new YKeyValue(yArr)
- const meta = yDoc.getMap('meta')
-
- return {
- yDoc,
- yStore,
- meta,
- room: new WebsocketProvider(hostUrl, roomId, yDoc, { connect: true }),
- }
- }, [hostUrl, roomId])
-
- useEffect(() => {
- setStoreWithStatus({ status: 'loading' })
-
- const unsubs: (() => void)[] = []
-
- function handleSync() {
- // 1.
- // Connect store to yjs store and vis versa, for both the document and awareness
-
- /* -------------------- Document -------------------- */
-
- // Sync store changes to the yjs doc
- unsubs.push(
- store.listen(
- function syncStoreChangesToYjsDoc({ changes }) {
- yDoc.transact(() => {
- Object.values(changes.added).forEach((record) => {
- yStore.set(record.id, record)
- })
-
- Object.values(changes.updated).forEach(([_, record]) => {
- yStore.set(record.id, record)
- })
-
- Object.values(changes.removed).forEach((record) => {
- yStore.delete(record.id)
- })
- })
- },
- { source: 'user', scope: 'document' }, // only sync user's document changes
- ),
- )
-
- // Sync the yjs doc changes to the store
- const handleChange = (
- changes: Map<
- string,
- | { action: 'delete'; oldValue: TLRecord }
- | { action: 'update'; oldValue: TLRecord; newValue: TLRecord }
- | { action: 'add'; newValue: TLRecord }
- >,
- transaction: Y.Transaction,
- ) => {
- if (transaction.local) return
-
- const toRemove: TLRecord['id'][] = []
- const toPut: TLRecord[] = []
-
- changes.forEach((change, id) => {
- switch (change.action) {
- case 'add':
- case 'update': {
- const record = yStore.get(id)!
- toPut.push(record)
- break
- }
- case 'delete': {
- toRemove.push(id as TLRecord['id'])
- break
- }
- }
- })
-
- // put / remove the records in the store
- store.mergeRemoteChanges(() => {
- if (toRemove.length) store.remove(toRemove)
- if (toPut.length) store.put(toPut)
- })
- }
-
- yStore.on('change', handleChange)
- unsubs.push(() => yStore.off('change', handleChange))
-
- /* -------------------- Awareness ------------------- */
-
- const yClientId = room.awareness.clientID.toString()
- setUserPreferences({ id: yClientId })
-
- const userPreferences = computed<{
- id: string
- color: string
- name: string
- }>('userPreferences', () => {
- const user = getUserPreferences()
- return {
- id: user.id,
- color: user.color ?? defaultUserPreferences.color,
- name: user.name ?? defaultUserPreferences.name,
- }
- })
-
- // Create the instance presence derivation
- const presenceId = InstancePresenceRecordType.createId(yClientId)
- const presenceDerivation = createPresenceStateDerivation(
- userPreferences,
- presenceId,
- )(store)
-
- // Set our initial presence from the derivation's current value
- room.awareness.setLocalStateField('presence', presenceDerivation.get())
-
- // When the derivation change, sync presence to to yjs awareness
- unsubs.push(
- react('when presence changes', () => {
- const presence = presenceDerivation.get()
- requestAnimationFrame(() => {
- room.awareness.setLocalStateField('presence', presence)
- })
- }),
- )
-
- // Sync yjs awareness changes to the store
- const handleUpdate = (update: {
- added: number[]
- updated: number[]
- removed: number[]
- }) => {
- const states = room.awareness.getStates() as Map<
- number,
- { presence: TLInstancePresence }
- >
-
- const toRemove: TLInstancePresence['id'][] = []
- const toPut: TLInstancePresence[] = []
-
- // Connect records to put / remove
- for (const clientId of update.added) {
- const state = states.get(clientId)
- if (state?.presence && state.presence.id !== presenceId) {
- toPut.push(state.presence)
- }
- }
-
- for (const clientId of update.updated) {
- const state = states.get(clientId)
- if (state?.presence && state.presence.id !== presenceId) {
- toPut.push(state.presence)
- }
- }
-
- for (const clientId of update.removed) {
- toRemove.push(
- InstancePresenceRecordType.createId(clientId.toString()),
- )
- }
-
- // put / remove the records in the store
- store.mergeRemoteChanges(() => {
- if (toRemove.length) store.remove(toRemove)
- if (toPut.length) store.put(toPut)
- })
- }
-
- const handleMetaUpdate = () => {
- const theirSchema = meta.get('schema')
- if (!theirSchema) {
- throw new Error('No schema found in the yjs doc')
- }
- // If the shared schema is newer than our schema, the user must refresh
- const newMigrations = store.schema.getMigrationsSince(theirSchema)
-
- if (!newMigrations.ok || newMigrations.value.length > 0) {
- window.alert('The schema has been updated. Please refresh the page.')
- yDoc.destroy()
- }
- }
- meta.observe(handleMetaUpdate)
- unsubs.push(() => meta.unobserve(handleMetaUpdate))
-
- room.awareness.on('update', handleUpdate)
- unsubs.push(() => room.awareness.off('update', handleUpdate))
-
- // 2.
- // Initialize the store with the yjs doc records—or, if the yjs doc
- // is empty, initialize the yjs doc with the default store records.
- if (yStore.yarray.length) {
- // Replace the store records with the yjs doc records
- const ourSchema = store.schema.serialize()
- const theirSchema = meta.get('schema')
- if (!theirSchema) {
- throw new Error('No schema found in the yjs doc')
- }
-
- const records = yStore.yarray.toJSON().map(({ val }) => val)
-
- const migrationResult = store.schema.migrateStoreSnapshot({
- schema: theirSchema,
- store: Object.fromEntries(
- records.map((record) => [record.id, record]),
- ),
- })
- if (migrationResult.type === 'error') {
- // if the schema is newer than ours, the user must refresh
- console.error(migrationResult.reason)
- window.alert('The schema has been updated. Please refresh the page.')
- return
- }
-
- yDoc.transact(() => {
- // delete any deleted records from the yjs doc
- for (const r of records) {
- if (!migrationResult.value[r.id]) {
- yStore.delete(r.id)
- }
- }
- for (const r of Object.values(migrationResult.value) as TLRecord[]) {
- yStore.set(r.id, r)
- }
- meta.set('schema', ourSchema)
- })
-
- store.loadSnapshot({
- store: migrationResult.value,
- schema: ourSchema,
- })
- } else {
- // Create the initial store records
- // Sync the store records to the yjs doc
- yDoc.transact(() => {
- for (const record of store.allRecords()) {
- yStore.set(record.id, record)
- }
- meta.set('schema', store.schema.serialize())
- })
- }
-
- setStoreWithStatus({
- store,
- status: 'synced-remote',
- connectionStatus: 'online',
- })
- }
-
- let hasConnectedBefore = false
-
- function handleStatusChange({
- status,
- }: {
- status: 'disconnected' | 'connected'
- }) {
- // If we're disconnected, set the store status to 'synced-remote' and the connection status to 'offline'
- if (status === 'disconnected') {
- setStoreWithStatus({
- store,
- status: 'synced-remote',
- connectionStatus: 'offline',
- })
- return
- }
-
- room.off('synced', handleSync)
-
- if (status === 'connected') {
- if (hasConnectedBefore) return
- hasConnectedBefore = true
- room.on('synced', handleSync)
- unsubs.push(() => room.off('synced', handleSync))
- }
- }
-
- room.on('status', handleStatusChange)
- unsubs.push(() => room.off('status', handleStatusChange))
-
- return () => {
- unsubs.forEach((fn) => fn())
- unsubs.length = 0
- }
- }, [room, yDoc, store, yStore, meta])
-
- return storeWithStatus
-}
\ No newline at end of file
diff --git a/todo.md b/todo.md
index 5909cff..73d9eb7 100644
--- a/todo.md
+++ b/todo.md
@@ -1,5 +1,8 @@
high level:
- agent bindings
+ - create UI
+ - sync user prefs with bindings
+ - sync in doc meta
- agent selectors
- basic "social" shape
- graph projections