add social shape
This commit is contained in:
parent
c668f32d04
commit
d34955e5dd
125
src/App.tsx
125
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() {
|
|||
<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>
|
||||
// );
|
||||
// });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { BaseBoxShapeTool } from 'tldraw'
|
||||
|
||||
export class SocialShapeTool extends BaseBoxShapeTool {
|
||||
static override id = 'social'
|
||||
static override initial = 'idle'
|
||||
override shapeType = 'social'
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -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()) })
|
||||
// }
|
||||
}
|
||||
|
|
@ -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)}`
|
||||
}
|
||||
|
|
@ -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?.()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue