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