add social shape

This commit is contained in:
Orion Reed 2024-07-19 15:10:33 +02:00
parent c668f32d04
commit d34955e5dd
8 changed files with 268 additions and 397 deletions

View File

@ -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() {
<Tldraw
autoFocus
store={store}
shapeUtils={shapeUtils}
tools={tools}
overrides={overrides}
onMount={(editor) => {
//@ts-ignore
editor.getStateDescendant('select.idle').handleDoubleClickOnCanvas = () => void null;
}}
components={{
SharePanel: AgentButton,
Toolbar: CustomToolbar,
}}
// shapeUtils={customShapeUtils}
// tools={customTools}
// overrides={uiOverrides}
>
{/* <DevUi /> */}
</Tldraw>
/>
</div>
);
}
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 (
<div
style={{
// TODO: style this properly and consistently with tldraw
pointerEvents: "all",
display: "flex",
width: "148px",
margin: "4px 8px",
border: "none",
}}
>
<input
style={{
borderRadius: "9px 0px 0px 9px",
border: "none",
backgroundColor: "white",
boxShadow: "0px 0px 4px rgba(0, 0, 0, 0.25)",
}}
type="color"
value={color}
onChange={(e) => {
editor.user.updateUserPreferences({
color: e.currentTarget.value,
});
}}
/>
<input
style={{
width: "100%",
borderRadius: "0px 9px 9px 0px",
border: "none",
backgroundColor: "white",
boxShadow: "0px 0px 4px rgba(0, 0, 0, 0.25)",
}}
value={name}
onChange={(e) => {
editor.user.updateUserPreferences({
name: e.currentTarget.value,
});
}}
/>
</div>
);
});
// return (
// <div
// style={{
// // TODO: style this properly and consistently with tldraw
// pointerEvents: "all",
// display: "flex",
// width: "148px",
// margin: "4px 8px",
// border: "none",
// }}
// >
// <input
// style={{
// borderRadius: "9px 0px 0px 9px",
// border: "none",
// backgroundColor: "white",
// boxShadow: "0px 0px 4px rgba(0, 0, 0, 0.25)",
// }}
// type="color"
// value={color}
// onChange={(e) => {
// editor.user.updateUserPreferences({
// color: e.currentTarget.value,
// });
// }}
// />
// <input
// style={{
// width: "100%",
// borderRadius: "0px 9px 9px 0px",
// border: "none",
// backgroundColor: "white",
// boxShadow: "0px 0px 4px rgba(0, 0, 0, 0.25)",
// }}
// value={name}
// onChange={(e) => {
// editor.user.updateUserPreferences({
// name: e.currentTarget.value,
// });
// }}
// />
// </div>
// );
// });

9
src/SocialShapeTool.ts Normal file
View File

@ -0,0 +1,9 @@
import { BaseBoxShapeTool } from 'tldraw'
export class SocialShapeTool extends BaseBoxShapeTool {
static override id = 'social'
static override initial = 'idle'
override shapeType = 'social'
}

87
src/SocialShapeUtil.tsx Normal file
View File

@ -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<ISocialShape> {
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<ISocialShape> = (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 (
<rect
width={toDomPrecision(bounds.width)}
height={toDomPrecision(bounds.height)}
rx={4}
/>
)
}
override component(machine: ISocialShape) {
return (
<HTMLContainer style={{ padding: 4, borderRadius: 4, border: '1px solid #ccc', pointerEvents: 'all' }}>
<textarea style={{ width: '100%', height: '100%', border: 'none', outline: 'none', resize: 'none' }} value={machine.props.text} onChange={(e) => this.updateProps(machine, { text: e.target.value })} />
</HTMLContainer>
)
}
private updateProps(shape: ISocialShape, props: Partial<ISocialShape['props']>) {
this.editor.updateShape<ISocialShape>({
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()) })
// }
}

View File

@ -40,6 +40,18 @@ export function AgentButton() {
setBoundToAgent(name)
}
const handleAdd = (e: React.MouseEvent<HTMLDivElement>) => {
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 (
<div>
<Dropdown
@ -56,6 +68,8 @@ export function AgentButton() {
>
{you && <AgentItem
agent={you}
canRename
onRename={(newName) => 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() {
<AgentItem
key={agent.name}
agent={agent}
isUnbound
isReadonly
onColorChange={(color) => handleColorChange(agent.name, color)}
onDelete={() => handleDelete(agent.name)}
onBind={() => handleBind(agent.name)}
/>
))}
<Separator />
<AddAgentButton onAdd={(e) => handleAdd(e)} />
</Dropdown>
</div>
)
}
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 <DropdownItem>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{!isUnbound && <ColorPicker color={agent.color} onChange={onColorChange} />}
<span onClick={onBind} style={{ color: isUnbound ? 'grey' : 'black' }}>{agent.name}</span>
{!isReadonly && <ColorPicker color={agent.color} onChange={onColorChange} />}
{canRename ? (
<input
type="text"
value={agent.name}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
e.preventDefault()
e.stopPropagation()
onRename?.(e.target.value)
}}
// onKeyDown={(e) => {
// // e.preventDefault()
// e.stopPropagation()
// }}
style={{ color: isReadonly ? 'grey' : 'black' }}
/>
) : (
<span onClick={onBind} style={{ color: isReadonly ? 'grey' : 'black' }}>{agent.name}</span>
)}
</div>
<DeleteButton onClick={onDelete} />
</div>
</DropdownItem>
}
function AddAgentButton({ onAdd }: { onAdd: (e: React.MouseEvent<HTMLDivElement>) => void }) {
return <DropdownItem>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<div onClick={onAdd} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span>Add Name</span>
<span style={{
marginLeft: 'auto', fontSize: '20px',
}}>+</span>
</div>
</div>
</DropdownItem>
}
function Separator() {
return <DropdownMenu.Separator
@ -143,7 +189,10 @@ function DeleteButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
onClick={(e) => {
e.stopPropagation()
onClick()
}}
style={{
background: 'none',
border: 'none',
@ -155,10 +204,20 @@ function DeleteButton({ onClick }: { onClick: () => void }) {
borderRadius: '50%',
transition: 'background-color 0.2s',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#e3e3e3'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
×
</button>
);
}
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)}`
}

View File

@ -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?.()

38
src/ui.tsx Normal file
View File

@ -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 (
<DefaultToolbar {...props}>
<TldrawUiMenuItem {...tools.social} isSelected={isSocialSelected} />
<DefaultToolbarContent />
</DefaultToolbar>
)
}

View File

@ -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<TLStoreWithStatus>({
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<SerializedSchema>('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
}

View File

@ -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