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 "tldraw/tldraw.css";
|
||||||
// import { DevShapeTool } from "./DevShape/DevShapeTool";
|
import { Tldraw } from "tldraw";
|
||||||
// import { DevShapeUtil } from "./DevShape/DevShapeUtil";
|
|
||||||
// import { DevUi } from "./DevUI";
|
|
||||||
// import { uiOverrides } from "./ui-overrides";
|
|
||||||
import { useYjsStore } from "./useYjsStore";
|
import { useYjsStore } from "./useYjsStore";
|
||||||
import { AgentButton } from "./components/AgentButton";
|
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
|
const HOST_URL = import.meta.env.DEV
|
||||||
? "ws://localhost:1234"
|
? "ws://localhost:1234"
|
||||||
|
|
@ -20,7 +20,7 @@ export default function Canvas() {
|
||||||
const store = useYjsStore({
|
const store = useYjsStore({
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
hostUrl: HOST_URL,
|
hostUrl: HOST_URL,
|
||||||
// shapeUtils: customShapeUtils,
|
shapeUtils: shapeUtils,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -28,66 +28,69 @@ export default function Canvas() {
|
||||||
<Tldraw
|
<Tldraw
|
||||||
autoFocus
|
autoFocus
|
||||||
store={store}
|
store={store}
|
||||||
|
shapeUtils={shapeUtils}
|
||||||
|
tools={tools}
|
||||||
|
overrides={overrides}
|
||||||
|
onMount={(editor) => {
|
||||||
|
//@ts-ignore
|
||||||
|
editor.getStateDescendant('select.idle').handleDoubleClickOnCanvas = () => void null;
|
||||||
|
}}
|
||||||
components={{
|
components={{
|
||||||
SharePanel: AgentButton,
|
SharePanel: AgentButton,
|
||||||
|
Toolbar: CustomToolbar,
|
||||||
}}
|
}}
|
||||||
// shapeUtils={customShapeUtils}
|
/>
|
||||||
// tools={customTools}
|
|
||||||
// overrides={uiOverrides}
|
|
||||||
>
|
|
||||||
{/* <DevUi /> */}
|
|
||||||
</Tldraw>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NameEditor = track(() => {
|
// const NameEditor = track(() => {
|
||||||
const editor = useEditor();
|
// const editor = useEditor();
|
||||||
|
|
||||||
|
|
||||||
const { color, name } = editor.user.getUserPreferences();
|
// const { color, name } = editor.user.getUserPreferences();
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<div
|
// <div
|
||||||
style={{
|
// style={{
|
||||||
// TODO: style this properly and consistently with tldraw
|
// // TODO: style this properly and consistently with tldraw
|
||||||
pointerEvents: "all",
|
// pointerEvents: "all",
|
||||||
display: "flex",
|
// display: "flex",
|
||||||
width: "148px",
|
// width: "148px",
|
||||||
margin: "4px 8px",
|
// margin: "4px 8px",
|
||||||
border: "none",
|
// border: "none",
|
||||||
}}
|
// }}
|
||||||
>
|
// >
|
||||||
<input
|
// <input
|
||||||
style={{
|
// style={{
|
||||||
borderRadius: "9px 0px 0px 9px",
|
// borderRadius: "9px 0px 0px 9px",
|
||||||
border: "none",
|
// border: "none",
|
||||||
backgroundColor: "white",
|
// backgroundColor: "white",
|
||||||
boxShadow: "0px 0px 4px rgba(0, 0, 0, 0.25)",
|
// boxShadow: "0px 0px 4px rgba(0, 0, 0, 0.25)",
|
||||||
}}
|
// }}
|
||||||
type="color"
|
// type="color"
|
||||||
value={color}
|
// value={color}
|
||||||
onChange={(e) => {
|
// onChange={(e) => {
|
||||||
editor.user.updateUserPreferences({
|
// editor.user.updateUserPreferences({
|
||||||
color: e.currentTarget.value,
|
// color: e.currentTarget.value,
|
||||||
});
|
// });
|
||||||
}}
|
// }}
|
||||||
/>
|
// />
|
||||||
<input
|
// <input
|
||||||
style={{
|
// style={{
|
||||||
width: "100%",
|
// width: "100%",
|
||||||
borderRadius: "0px 9px 9px 0px",
|
// borderRadius: "0px 9px 9px 0px",
|
||||||
border: "none",
|
// border: "none",
|
||||||
backgroundColor: "white",
|
// backgroundColor: "white",
|
||||||
boxShadow: "0px 0px 4px rgba(0, 0, 0, 0.25)",
|
// boxShadow: "0px 0px 4px rgba(0, 0, 0, 0.25)",
|
||||||
}}
|
// }}
|
||||||
value={name}
|
// value={name}
|
||||||
onChange={(e) => {
|
// onChange={(e) => {
|
||||||
editor.user.updateUserPreferences({
|
// editor.user.updateUserPreferences({
|
||||||
name: e.currentTarget.value,
|
// name: e.currentTarget.value,
|
||||||
});
|
// });
|
||||||
}}
|
// }}
|
||||||
/>
|
// />
|
||||||
</div>
|
// </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)
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|
@ -56,6 +68,8 @@ export function AgentButton() {
|
||||||
>
|
>
|
||||||
{you && <AgentItem
|
{you && <AgentItem
|
||||||
agent={you}
|
agent={you}
|
||||||
|
canRename
|
||||||
|
onRename={(newName) => handleRename(you.name, newName)}
|
||||||
onColorChange={(color) => handleColorChange(you.name, color)}
|
onColorChange={(color) => handleColorChange(you.name, color)}
|
||||||
onDelete={() => handleDelete(you.name)}
|
onDelete={() => handleDelete(you.name)}
|
||||||
onBind={() => handleBind(you.name)}
|
onBind={() => handleBind(you.name)}
|
||||||
|
|
@ -75,28 +89,60 @@ export function AgentButton() {
|
||||||
<AgentItem
|
<AgentItem
|
||||||
key={agent.name}
|
key={agent.name}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
isUnbound
|
isReadonly
|
||||||
onColorChange={(color) => handleColorChange(agent.name, color)}
|
onColorChange={(color) => handleColorChange(agent.name, color)}
|
||||||
onDelete={() => handleDelete(agent.name)}
|
onDelete={() => handleDelete(agent.name)}
|
||||||
onBind={() => handleBind(agent.name)}
|
onBind={() => handleBind(agent.name)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<Separator />
|
||||||
|
<AddAgentButton onAdd={(e) => handleAdd(e)} />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</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>
|
return <DropdownItem>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{!isUnbound && <ColorPicker color={agent.color} onChange={onColorChange} />}
|
{!isReadonly && <ColorPicker color={agent.color} onChange={onColorChange} />}
|
||||||
<span onClick={onBind} style={{ color: isUnbound ? 'grey' : 'black' }}>{agent.name}</span>
|
{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>
|
</div>
|
||||||
<DeleteButton onClick={onDelete} />
|
<DeleteButton onClick={onDelete} />
|
||||||
</div>
|
</div>
|
||||||
</DropdownItem>
|
</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() {
|
function Separator() {
|
||||||
return <DropdownMenu.Separator
|
return <DropdownMenu.Separator
|
||||||
|
|
@ -143,7 +189,10 @@ function DeleteButton({ onClick }: { onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClick()
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
@ -155,10 +204,20 @@ function DeleteButton({ onClick }: { onClick: () => void }) {
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
transition: 'background-color 0.2s',
|
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'}
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</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',
|
border: 'none',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
// onClick={(e) => {
|
// onClick={(e) => {
|
||||||
// e.preventDefault()
|
// e.preventDefault()
|
||||||
// action?.()
|
// 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