Some checks failed
Deploy / deploy (push) Has been cancelled
原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在 web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts` 还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边 什么风格」。 - 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认, printWidth 80 —— 和前端已有的格式一致,不另立一套宽度); - 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建 配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉; - `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照 (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会 重写的 `auto-imports.d.ts` / `components.d.ts`; - 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、 前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是 prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
6.0 KiB
TypeScript
187 lines
6.0 KiB
TypeScript
import { Compartment } from "@codemirror/state"
|
||
import type { EditorView } from "@codemirror/view"
|
||
import { useCollabStore } from "shared/store/collab"
|
||
import { useUserStore } from "shared/store/user"
|
||
|
||
/** y-websocket 那套消息头,服务端不解析,只有两端认 */
|
||
const MESSAGE_SYNC = 0
|
||
const MESSAGE_AWARENESS = 1
|
||
|
||
const TEACHER_COLOR = "#ff6b6b"
|
||
const STUDENT_COLOR = "#4dabf7"
|
||
|
||
interface StartOptions {
|
||
editorView: EditorView
|
||
/**
|
||
* 文档的初始内容。
|
||
*
|
||
* **学生端传当前编辑器内容,教师端必须传 null。** 这是硬规则:
|
||
* 求助是学生发起的,学生的代码是唯一内容源。老师端插入任何初始内容都会
|
||
* 与学生的内容合并,结果就是两份代码拼在一起 —— 老实现的竞态就是这么来的。
|
||
*/
|
||
seedContent: string | null
|
||
}
|
||
|
||
export function useCollabDoc() {
|
||
const collabStore = useCollabStore()
|
||
const userStore = useUserStore()
|
||
const compartment = new Compartment()
|
||
|
||
let doc: any = null
|
||
let awareness: any = null
|
||
let view: EditorView | null = null
|
||
let detachDocUpdate: (() => void) | null = null
|
||
let detachAwarenessUpdate: (() => void) | null = null
|
||
/**
|
||
* 会话代号。每次 start / stop 都推进一格。
|
||
*
|
||
* start 要 await 六个动态 import,房间可能在这期间就关了(机房首次加载
|
||
* y* 那几个 chunk 正是最慢的时候)。stop() 先跑完的话它面对的是 doc === null,
|
||
* 什么也拆不到;等 import 回来 start 的后半段照样建文档、装 binaryHandler、
|
||
* 往编辑器里挂 yCollab、还发一轮 SyncStep1 —— 给一个已经不存在的房间。
|
||
*/
|
||
let generation = 0
|
||
|
||
async function start({ editorView, seedContent }: StartOptions) {
|
||
// 已经有一份在跑就先收掉。重复 start 只可能来自时序竞争,叠加没有意义
|
||
if (doc) stop()
|
||
const myGeneration = ++generation
|
||
|
||
const [
|
||
Y,
|
||
awarenessProtocol,
|
||
syncProtocol,
|
||
encoding,
|
||
decoding,
|
||
{ yCollab },
|
||
] = await Promise.all([
|
||
import("yjs"),
|
||
import("y-protocols/awareness"),
|
||
import("y-protocols/sync"),
|
||
import("lib0/encoding"),
|
||
import("lib0/decoding"),
|
||
import("y-codemirror.next"),
|
||
])
|
||
|
||
// 等 chunk 的这段时间里房间关了(或者又开了新的一轮),整个放弃
|
||
if (myGeneration !== generation) return
|
||
|
||
view = editorView
|
||
doc = new Y.Doc()
|
||
const ytext = doc.getText("codemirror")
|
||
awareness = new awarenessProtocol.Awareness(doc)
|
||
|
||
// ★ 顺序不能反:先把内容写进 ytext,再挂 yCollab。
|
||
// yCollab 挂上去时会用 ytext 覆盖编辑器内容,先挂就会把学生的代码清空。
|
||
if (seedContent) ytext.insert(0, seedContent)
|
||
|
||
const send = (build: (encoder: any) => void) => {
|
||
const encoder = encoding.createEncoder()
|
||
build(encoder)
|
||
collabStore.sendBinary(encoding.toUint8Array(encoder))
|
||
}
|
||
|
||
collabStore.setBinaryHandler((data) => {
|
||
const decoder = decoding.createDecoder(new Uint8Array(data))
|
||
const messageType = decoding.readVarUint(decoder)
|
||
if (messageType === MESSAGE_SYNC) {
|
||
const encoder = encoding.createEncoder()
|
||
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
||
syncProtocol.readSyncMessage(decoder, encoder, doc, "remote")
|
||
// 只有需要回话时才发(readSyncMessage 可能什么都没写)
|
||
if (encoding.length(encoder) > 1) {
|
||
collabStore.sendBinary(encoding.toUint8Array(encoder))
|
||
}
|
||
} else if (messageType === MESSAGE_AWARENESS) {
|
||
awarenessProtocol.applyAwarenessUpdate(
|
||
awareness,
|
||
decoding.readVarUint8Array(decoder),
|
||
"remote",
|
||
)
|
||
}
|
||
})
|
||
|
||
const onDocUpdate = (update: Uint8Array, origin: any) => {
|
||
if (origin === "remote") return
|
||
send((encoder) => {
|
||
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
||
syncProtocol.writeUpdate(encoder, update)
|
||
})
|
||
}
|
||
doc.on("update", onDocUpdate)
|
||
detachDocUpdate = () => doc?.off("update", onDocUpdate)
|
||
|
||
const onAwarenessUpdate = (
|
||
{
|
||
added,
|
||
updated,
|
||
removed,
|
||
}: { added: number[]; updated: number[]; removed: number[] },
|
||
origin: any,
|
||
) => {
|
||
if (origin === "remote") return
|
||
const changed = added.concat(updated, removed)
|
||
send((encoder) => {
|
||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS)
|
||
encoding.writeVarUint8Array(
|
||
encoder,
|
||
awarenessProtocol.encodeAwarenessUpdate(awareness, changed),
|
||
)
|
||
})
|
||
}
|
||
awareness.on("update", onAwarenessUpdate)
|
||
detachAwarenessUpdate = () => awareness?.off("update", onAwarenessUpdate)
|
||
|
||
awareness.setLocalStateField("user", {
|
||
name: userStore.user?.username ?? "匿名",
|
||
color: userStore.isTeacherOrAbove ? TEACHER_COLOR : STUDENT_COLOR,
|
||
})
|
||
|
||
editorView.dispatch({
|
||
effects: compartment.reconfigure(yCollab(ytext, awareness)),
|
||
})
|
||
|
||
// 握手:双方都发 SyncStep1,各自回 Step2,两边收敛。
|
||
// 服务端是哑转发,不参与同步,所以这一步必须由两端对称完成
|
||
send((encoder) => {
|
||
encoding.writeVarUint(encoder, MESSAGE_SYNC)
|
||
syncProtocol.writeSyncStep1(encoder, doc)
|
||
})
|
||
send((encoder) => {
|
||
encoding.writeVarUint(encoder, MESSAGE_AWARENESS)
|
||
encoding.writeVarUint8Array(
|
||
encoder,
|
||
awarenessProtocol.encodeAwarenessUpdate(awareness, [doc.clientID]),
|
||
)
|
||
})
|
||
}
|
||
|
||
function stop() {
|
||
generation += 1
|
||
collabStore.setBinaryHandler(null)
|
||
detachDocUpdate?.()
|
||
detachAwarenessUpdate?.()
|
||
detachDocUpdate = null
|
||
detachAwarenessUpdate = null
|
||
|
||
if (view) {
|
||
try {
|
||
view.dispatch({ effects: compartment.reconfigure([]) })
|
||
} catch (error) {
|
||
console.warn("移除协同编辑扩展失败:", error)
|
||
}
|
||
view = null
|
||
}
|
||
awareness?.destroy()
|
||
doc?.destroy()
|
||
awareness = null
|
||
doc = null
|
||
}
|
||
|
||
function getInitialExtension() {
|
||
return compartment.of([])
|
||
}
|
||
|
||
return { start, stop, getInitialExtension }
|
||
}
|