function finite(value) { return typeof value === 'number' && Number.isFinite(value) } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)) } export function init(seed) { return { title: typeof seed?.room?.title === 'string' ? seed.room.title : 'Untitled room', objects: {}, privateByUser: {}, revision: 0, } } export function tick(state, intents) { const next = { title: state?.title ?? 'Untitled room', objects: { ...(state?.objects ?? {}) }, privateByUser: { ...(state?.privateByUser ?? {}) }, revision: Number.isInteger(state?.revision) ? state.revision : 0, } for (const entry of intents ?? []) { const userId = entry?.userId const intent = entry?.intent if (typeof userId !== 'string' || !intent || typeof intent !== 'object') continue if (!['join', 'upsert', 'remove', 'presence'].includes(intent.type)) continue if (!next.privateByUser[userId]) { next.privateByUser[userId] = { cursor: null, selectionId: null } } if (intent.type === 'upsert' && typeof intent.id === 'string' && intent.id.length <= 80 && finite(intent.x) && finite(intent.y)) { next.objects[intent.id] = { id: intent.id, kind: intent.kind === 'shape' ? 'shape' : 'note', x: clamp(intent.x, 0, 10000), y: clamp(intent.y, 0, 10000), text: typeof intent.text === 'string' ? intent.text.slice(0, 2000) : '', updatedBy: userId, } } if (intent.type === 'remove' && typeof intent.id === 'string') { delete next.objects[intent.id] } if (intent.type === 'presence' && finite(intent.x) && finite(intent.y)) { next.privateByUser[userId] = { cursor: { x: clamp(intent.x, 0, 10000), y: clamp(intent.y, 0, 10000) }, selectionId: typeof intent.selectionId === 'string' ? intent.selectionId.slice(0, 80) : null, } } } next.revision += 1 return next } export function views(state) { const out = {} for (const userId of Object.keys(state?.privateByUser ?? {})) { out[userId] = { stateJson: JSON.stringify({ title: state.title, objects: Object.values(state.objects ?? {}), private: state.privateByUser[userId], revision: state.revision, }), } } return out }