openpencil/src/composables/use-collab.ts
Danila Poyarkov cc7f055dfc Format
2026-04-06 14:40:19 +03:00

557 lines
16 KiB
TypeScript

import { useLocalStorage } from '@vueuse/core'
import { joinRoom as joinTrysteroRoom } from 'trystero/mqtt'
import { ref, watch, onUnmounted, computed, type InjectionKey, inject } from 'vue'
import { IndexeddbPersistence } from 'y-indexeddb'
import * as awarenessProtocol from 'y-protocols/awareness'
import * as Y from 'yjs'
import {
TRYSTERO_APP_ID,
PEER_COLORS,
ROOM_ID_LENGTH,
ROOM_ID_CHARS,
YJS_JSON_FIELDS
} from '@/constants'
import { randomIndex } from '@open-pencil/core'
import type { EditorStore } from '@/stores/editor'
import type { Color, SceneNode } from '@open-pencil/core'
import type { Room } from 'trystero'
export interface RemotePeer {
clientId: number
name: string
color: Color
cursor?: { x: number; y: number; pageId: string }
selection?: string[]
}
export interface CollabState {
connected: boolean
roomId: string | null
peers: RemotePeer[]
localName: string
localColor: Color
}
export const DEFAULT_COLLAB_STATE: CollabState = {
connected: false,
roomId: null,
peers: [],
localName: '',
localColor: { r: 0.5, g: 0.5, b: 0.5, a: 1 }
}
export function useCollab(storeOrGetter: EditorStore | (() => EditorStore)) {
const getStore = () =>
typeof storeOrGetter === 'function' ? (storeOrGetter as () => EditorStore)() : storeOrGetter
const storedName = useLocalStorage('op-collab-name', '')
const state = ref<CollabState>({
connected: false,
roomId: null,
peers: [],
localName: storedName.value,
localColor: PEER_COLORS[randomIndex(PEER_COLORS.length)]
})
let ydoc: Y.Doc | null = null
let awareness: awarenessProtocol.Awareness | null = null
let ynodes: Y.Map<Y.Map<unknown>> | null = null
let yimages: Y.Map<Uint8Array> | null = null
let room: Room | null = null
let persistence: IndexeddbPersistence | null = null
let connectedStore: EditorStore | null = null
let suppressGraphSync = false
let suppressYjsEvents = false
let unbindGraphEvents: (() => void) | null = null
let stopZoomWatch: (() => void) | null = null
let sendYjsUpdate: ((data: Uint8Array, peerId?: string) => void) | null = null
let sendAwareness: ((data: Uint8Array, peerId?: string) => void) | null = null
let sendSyncStep1: ((data: Uint8Array, peerId?: string) => void) | null = null
const remotePeers = computed(() => state.value.peers)
function connect(roomId: string) {
if (room) disconnect()
const store = getStore()
connectedStore = store
state.value.roomId = roomId
ydoc = new Y.Doc()
awareness = new awarenessProtocol.Awareness(ydoc)
ynodes = ydoc.getMap('nodes')
yimages = ydoc.getMap('images')
persistence = new IndexeddbPersistence(`op-room-${roomId}`, ydoc)
awareness.on('change', () => {
updatePeersList()
tickFollow()
})
ynodes.observeDeep((events) => {
if (suppressYjsEvents) return
suppressGraphSync = true
try {
applyYjsToGraph(events)
} finally {
suppressGraphSync = false
}
store.requestRender()
})
yimages.observe((event) => {
if (suppressYjsEvents) return
for (const [key, change] of event.changes.keys) {
if (change.action === 'add' || change.action === 'update') {
const data = yimages?.get(key)
if (data) store.graph.images.set(key, new Uint8Array(data))
} else {
store.graph.images.delete(key)
}
}
store.requestRender()
})
room = joinTrysteroRoom(
{
appId: TRYSTERO_APP_ID,
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' },
{
urls: 'turn:openrelay.metered.ca:443',
username: 'openrelayproject',
credential: 'openrelayproject'
},
{
urls: 'turn:openrelay.metered.ca:443?transport=tcp',
username: 'openrelayproject',
credential: 'openrelayproject'
}
]
}
},
roomId
)
const [sendUpdate, getUpdate] = room.makeAction<Uint8Array>('yjs-update')
const [sendAw, getAw] = room.makeAction<Uint8Array>('awareness')
const [sendSync, getSync] = room.makeAction<Uint8Array>('sync-step1')
const [sendSyncReply, getSyncReply] = room.makeAction<Uint8Array>('sync-reply')
sendYjsUpdate = (data, peerId) => void (peerId ? sendUpdate(data, peerId) : sendUpdate(data))
sendAwareness = (data, peerId) => void (peerId ? sendAw(data, peerId) : sendAw(data))
sendSyncStep1 = (data, peerId) => void (peerId ? sendSync(data, peerId) : sendSync(data))
getUpdate((data) => {
if (!ydoc) return
Y.applyUpdate(ydoc, new Uint8Array(data), 'remote')
})
getAw((data) => {
if (!awareness) return
awarenessProtocol.applyAwarenessUpdate(awareness, new Uint8Array(data), null)
})
getSync((data, peerId) => {
if (!ydoc) return
const sv = new Uint8Array(data)
const update = Y.encodeStateAsUpdate(ydoc, sv)
void sendSyncReply(update, peerId)
})
getSyncReply((data) => {
if (!ydoc) return
Y.applyUpdate(ydoc, new Uint8Array(data), 'remote')
})
ydoc.on('update', (update: Uint8Array, origin: unknown) => {
if (origin === 'remote') return
sendYjsUpdate?.(update)
})
const localAwareness = awareness
const localYdoc = ydoc
localAwareness.on(
'update',
({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => {
const changedClients = [...added, ...updated, ...removed]
const encodedUpdate = awarenessProtocol.encodeAwarenessUpdate(
localAwareness,
changedClients
)
sendAwareness?.(encodedUpdate)
}
)
room.onPeerJoin((peerId) => {
state.value.connected = true
const sv = Y.encodeStateVector(localYdoc)
sendSyncStep1?.(sv, peerId)
const encodedUpdate = awarenessProtocol.encodeAwarenessUpdate(localAwareness, [
localAwareness.clientID
])
sendAwareness?.(encodedUpdate, peerId)
})
room.onPeerLeave(() => {
const remoteClients = [...localAwareness.getStates().keys()].filter(
(id) => id !== localAwareness.clientID
)
awarenessProtocol.removeAwarenessStates(localAwareness, remoteClients, 'peer-left')
updatePeersList()
})
state.value.connected = true
broadcastAwareness()
stopZoomWatch = watch(
() => store.state.zoom,
(zoom) => {
if (!awareness) return
const prev = awareness.getLocalState()?.cursor as
| { x: number; y: number; pageId: string; zoom: number }
| undefined
if (prev) {
awareness.setLocalStateField('cursor', { ...prev, zoom })
}
}
)
function onGraphMutation(nodeId: string) {
if (!suppressGraphSync && ydoc && ynodes) {
syncNodeToYjs(nodeId)
}
}
const unbindUpdated = store.graph.emitter.on('node:updated', (id) => onGraphMutation(id))
const unbindCreated = store.graph.emitter.on('node:created', (node) => onGraphMutation(node.id))
const unbindReparented = store.graph.emitter.on('node:reparented', (nodeId) =>
onGraphMutation(nodeId)
)
const unbindReordered = store.graph.emitter.on('node:reordered', (nodeId) =>
onGraphMutation(nodeId)
)
const unbindDeleted = store.graph.emitter.on('node:deleted', (id) => {
if (!suppressGraphSync && ydoc && ynodes) {
suppressYjsEvents = true
const map = ynodes
ydoc.transact(() => {
map.delete(id)
})
suppressYjsEvents = false
}
})
unbindGraphEvents = () => {
unbindUpdated()
unbindCreated()
unbindReparented()
unbindReordered()
unbindDeleted()
}
}
function disconnect() {
const store = connectedStore ?? getStore()
unbindGraphEvents?.()
unbindGraphEvents = null
stopZoomWatch?.()
stopZoomWatch = null
void room?.leave()
room = null
sendYjsUpdate = null
sendAwareness = null
sendSyncStep1 = null
if (awareness) {
awareness.destroy()
awareness = null
}
if (persistence) {
void persistence.destroy()
persistence = null
}
if (ydoc) {
ydoc.destroy()
ydoc = null
}
ynodes = null
yimages = null
state.value.connected = false
state.value.roomId = null
state.value.peers = []
followingPeer.value = null
store.state.remoteCursors = []
store.requestRender()
connectedStore = null
}
function syncNodeToYjs(nodeId: string) {
const store = connectedStore ?? getStore()
if (!ydoc || !ynodes) return
const node = store.graph.getNode(nodeId)
if (!node) return
const localYnodes = ynodes
const localYimages = yimages
suppressYjsEvents = true
ydoc.transact(() => {
let ynode = localYnodes.get(nodeId)
if (!ynode) {
ynode = new Y.Map()
localYnodes.set(nodeId, ynode)
}
syncNodePropsToYMap(node, ynode)
if (localYimages) {
for (const fill of node.fills) {
if (fill.imageHash && !localYimages.has(fill.imageHash)) {
const data = store.graph.images.get(fill.imageHash)
if (data) localYimages.set(fill.imageHash, data)
}
}
}
})
suppressYjsEvents = false
}
function syncNodePropsToYMap(node: SceneNode, ynode: Y.Map<unknown>) {
for (const [key, value] of Object.entries(node)) {
if (typeof value === 'object' && value !== null) {
ynode.set(key, JSON.stringify(value))
} else {
ynode.set(key, value)
}
}
}
function syncAllNodesToYjs() {
const store = connectedStore ?? getStore()
if (!ydoc || !ynodes) return
const localYnodes = ynodes
const localYimages = yimages
suppressYjsEvents = true
ydoc.transact(() => {
for (const node of store.graph.getAllNodes()) {
let ynode = localYnodes.get(node.id)
if (!ynode) {
ynode = new Y.Map()
localYnodes.set(node.id, ynode)
}
syncNodePropsToYMap(node, ynode)
}
})
if (localYimages) {
ydoc.transact(() => {
for (const [hash, data] of store.graph.images) {
if (!localYimages.has(hash)) {
localYimages.set(hash, data)
}
}
})
}
suppressYjsEvents = false
}
function applyYjsToGraph(events: Y.YEvent<Y.Map<unknown>>[]) {
const store = connectedStore ?? getStore()
if (!ynodes) return
const localYnodes = ynodes
for (const event of events) {
if (event.target === localYnodes) {
for (const [key, change] of event.changes.keys) {
if (change.action === 'add') {
const ynode = localYnodes.get(key)
if (ynode) applyYnodeToGraph(key, ynode)
} else if (change.action === 'delete') {
store.graph.deleteNode(key)
}
}
} else if (event.target.parent === localYnodes) {
const nodeId = findNodeIdForYMap(event.target)
if (nodeId) {
const ynode = localYnodes.get(nodeId)
if (ynode) applyYnodeToGraph(nodeId, ynode)
}
}
}
}
function findNodeIdForYMap(ymap: Y.Map<unknown>): string | null {
if (!ynodes) return null
for (const [key, value] of ynodes.entries()) {
if (value === ymap) return key
}
return null
}
function applyYnodeToGraph(nodeId: string, ynode: Y.Map<unknown>) {
const store = connectedStore ?? getStore()
const existing = store.graph.getNode(nodeId)
const props: Record<string, unknown> = {}
for (const [key, value] of ynode.entries()) {
if (YJS_JSON_FIELDS.has(key)) {
try {
props[key] = typeof value === 'string' ? JSON.parse(value) : value
} catch {
props[key] = value
}
} else {
props[key] = value
}
}
if (existing) {
store.graph.updateNode(nodeId, props as Partial<SceneNode>)
} else {
const parentId = props.parentId as string
if (parentId && store.graph.getNode(parentId)) {
const type = props.type as SceneNode['type']
const node = store.graph.createNode(type, parentId, props as Partial<SceneNode>)
store.graph.nodes.delete(node.id)
node.id = nodeId
store.graph.nodes.set(nodeId, node)
}
}
}
function broadcastAwareness() {
if (!awareness) return
awareness.setLocalStateField('user', {
name: state.value.localName,
color: state.value.localColor
})
}
function updateCursor(x: number, y: number, pageId: string) {
const store = connectedStore ?? getStore()
if (!awareness) return
awareness.setLocalStateField('cursor', { x, y, pageId, zoom: store.state.zoom })
}
function updateSelection(ids: string[]) {
if (!awareness) return
awareness.setLocalStateField('selection', ids)
}
function updatePeersList() {
const store = connectedStore ?? getStore()
if (!awareness) return
const states = awareness.getStates()
const peers: RemotePeer[] = []
const localClientId = awareness.clientID
const currentPageId = store.state.currentPageId
states.forEach((peerState, clientId) => {
if (clientId === localClientId) return
const user = peerState.user as { name?: string; color?: Color } | undefined
if (!user) return
peers.push({
clientId,
name: user.name || 'Anonymous',
color: user.color || PEER_COLORS[clientId % PEER_COLORS.length],
cursor: peerState.cursor as RemotePeer['cursor'],
selection: peerState.selection as string[]
})
})
state.value.peers = peers
store.state.remoteCursors = peers
.filter((p) => p.cursor && p.cursor.pageId === currentPageId)
.map((p) => {
const cursor = p.cursor as NonNullable<RemotePeer['cursor']>
return {
name: p.name,
color: p.color,
x: cursor.x,
y: cursor.y,
selection: p.selection
}
})
store.requestRender()
}
function setLocalName(name: string) {
state.value.localName = name
storedName.value = name
broadcastAwareness()
}
function generateRoomId(): string {
let result = ''
for (let i = 0; i < ROOM_ID_LENGTH; i++) {
result += ROOM_ID_CHARS[randomIndex(ROOM_ID_CHARS.length)]
}
return result
}
function shareCurrentDoc(): string {
const roomId = generateRoomId()
connect(roomId)
syncAllNodesToYjs()
return roomId
}
function joinRoom(roomId: string) {
connect(roomId)
}
const followingPeer = ref<number | null>(null)
function followPeer(clientId: number | null) {
followingPeer.value = clientId
}
function tickFollow() {
const store = connectedStore ?? getStore()
if (!followingPeer.value || !awareness) return
const peerState = awareness.getStates().get(followingPeer.value)
if (!peerState?.cursor) {
followingPeer.value = null
return
}
const cursor = peerState.cursor as { x: number; y: number; pageId: string; zoom?: number }
if (cursor.pageId !== store.state.currentPageId) {
void store.switchPage(cursor.pageId)
}
const canvas = document.querySelector('canvas')
if (!canvas) return
if (cursor.zoom) store.state.zoom = cursor.zoom
const cw = canvas.width / devicePixelRatio
const ch = canvas.height / devicePixelRatio
store.state.panX = cw / 2 - cursor.x * store.state.zoom
store.state.panY = ch / 2 - cursor.y * store.state.zoom
store.requestRender()
}
onUnmounted(() => {
disconnect()
})
return {
state,
remotePeers,
followingPeer,
connect: joinRoom,
disconnect,
shareCurrentDoc,
updateCursor,
updateSelection,
setLocalName,
followPeer,
tickFollow
}
}
export type CollabReturn = ReturnType<typeof useCollab>
export const COLLAB_KEY = Symbol('collab') as InjectionKey<CollabReturn>
export function useCollabInjected() {
return inject(COLLAB_KEY)
}