// 前后端共用的 Yjs 文档结构。 // // 文档划分: // - `meta`:空间(标签)列表。客户端只读,增删改都走 REST 接口由服务端写入。 // - `board:`:一个空间的画布。`cards` 和 `edges` 两个 Y.Map。 // // 每张卡片是一个嵌套 Y.Map,这样位置、尺寸、文字等字段可以分别合并, // 两台设备同时改同一张卡片的不同字段不会互相覆盖。 import * as Y from 'yjs' export const META_DOC = 'meta' const BOARD_PREFIX = 'board:' export type CardKind = 'note' | 'image' export type CardColor = 'paper' | 'sun' | 'mint' | 'sky' | 'rose' export const CARD_COLORS: CardColor[] = ['paper', 'sun', 'mint', 'sky', 'rose'] export interface SpaceInfo { id: string name: string emoji: string createdAt: number updatedAt: number cardCount: number } export interface CardFields { kind: CardKind title: string content: string /** 图片地址(服务端 /api/images/...),仅图片卡片有 */ image?: string color: CardColor x: number y: number width: number height: number /** 叠放顺序,越大越靠上 */ z: number createdAt: number updatedAt: number } export interface Card extends CardFields { id: string } export interface EdgeFields { source: string target: string sourceHandle?: string | null targetHandle?: string | null } export interface Edge extends EdgeFields { id: string } export function boardDocName(spaceId: string) { return `${BOARD_PREFIX}${spaceId}` } export function spaceIdFromDocName(name: string): string | null { return name.startsWith(BOARD_PREFIX) ? name.slice(BOARD_PREFIX.length) : null } export function metaSpaces(doc: Y.Doc) { return doc.getMap>('spaces') } export function boardCards(doc: Y.Doc) { return doc.getMap>('cards') } export function boardEdges(doc: Y.Doc) { return doc.getMap('edges') } function num(value: unknown, fallback: number) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback } function str(value: unknown, fallback = '') { return typeof value === 'string' ? value : fallback } export function readSpace(id: string, map: Y.Map): SpaceInfo { return { id, name: str(map.get('name'), '未命名空间'), emoji: str(map.get('emoji'), '✦'), createdAt: num(map.get('createdAt'), 0), updatedAt: num(map.get('updatedAt'), 0), cardCount: num(map.get('cardCount'), 0), } } export function readSpaces(doc: Y.Doc): SpaceInfo[] { const spaces: SpaceInfo[] = [] metaSpaces(doc).forEach((map, id) => spaces.push(readSpace(id, map))) return spaces.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id)) } export function readCard(id: string, map: Y.Map): Card { const kind = map.get('kind') === 'image' ? 'image' : 'note' const color = map.get('color') const image = map.get('image') return { id, kind, title: str(map.get('title')), content: str(map.get('content')), image: typeof image === 'string' && image ? image : undefined, color: CARD_COLORS.includes(color as CardColor) ? (color as CardColor) : 'paper', x: num(map.get('x'), 0), y: num(map.get('y'), 0), width: num(map.get('width'), 300), height: num(map.get('height'), 220), z: num(map.get('z'), 0), createdAt: num(map.get('createdAt'), 0), updatedAt: num(map.get('updatedAt'), 0), } } export function readCards(doc: Y.Doc): Card[] { const cards: Card[] = [] boardCards(doc).forEach((map, id) => cards.push(readCard(id, map))) return cards.sort((a, b) => a.z - b.z || a.createdAt - b.createdAt) } export function readEdges(doc: Y.Doc): Edge[] { const edges: Edge[] = [] boardEdges(doc).forEach((edge, id) => edges.push({ ...edge, id })) return edges } /** 新建卡片的 Y.Map。只能在事务内调用并立即放进 cards。 */ export function createCardMap(fields: CardFields): Y.Map { const map = new Y.Map() for (const [key, value] of Object.entries(fields)) { if (value !== undefined) map.set(key, value) } return map } /** 只写入真正变化的字段,避免产生多余的同步数据。 */ export function patchCardMap(map: Y.Map, patch: Partial) { let changed = false for (const [key, value] of Object.entries(patch)) { if (value === undefined) { if (map.has(key)) { map.delete(key) changed = true } } else if (map.get(key) !== value) { map.set(key, value) changed = true } } return changed } /** 用另一份文档的内容整体替换画布(恢复历史版本时使用)。 */ export function replaceBoardContent(target: Y.Doc, source: Y.Doc) { const cards = boardCards(target) const edges = boardEdges(target) for (const id of [...cards.keys()]) cards.delete(id) for (const id of [...edges.keys()]) edges.delete(id) for (const card of readCards(source)) { const { id, ...fields } = card cards.set(id, createCardMap(fields)) } for (const edge of readEdges(source)) { const { id, ...fields } = edge if (cards.has(fields.source) && cards.has(fields.target)) edges.set(id, fields) } } export function docFromUpdate(update: Uint8Array) { const doc = new Y.Doc() Y.applyUpdate(doc, update) return doc }