Files
boundless/shared/board.ts
吴璨 fad9760265 自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器
- 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作
- 空间(标签)有独立地址,增删改通过接口同步到所有设备
- 口令登录(签名 Cookie 会话、登录限流、同源校验)
- 图片上传到服务器,过大照片上传前自动压缩
- 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备
- 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush)
- 选中样式与拖拽性能优化:拖动结束后才同步位置
- Docker / docker-compose 部署,README 与 CLAUDE.md 更新
2026-10-21 18:09:03 +08:00

185 lines
5.2 KiB
TypeScript

// 前后端共用的 Yjs 文档结构。
//
// 文档划分:
// - `meta`:空间(标签)列表。客户端只读,增删改都走 REST 接口由服务端写入。
// - `board:<spaceId>`:一个空间的画布。`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<Y.Map<unknown>>('spaces')
}
export function boardCards(doc: Y.Doc) {
return doc.getMap<Y.Map<unknown>>('cards')
}
export function boardEdges(doc: Y.Doc) {
return doc.getMap<EdgeFields>('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<unknown>): 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<unknown>): 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<unknown> {
const map = new Y.Map<unknown>()
for (const [key, value] of Object.entries(fields)) {
if (value !== undefined) map.set(key, value)
}
return map
}
/** 只写入真正变化的字段,避免产生多余的同步数据。 */
export function patchCardMap(map: Y.Map<unknown>, patch: Partial<CardFields>) {
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
}