Files
OJ2/apps/web/src/shared/components/SyncCodeEditor.vue
T
xuyueandClaude Opus 5.5 1fcd12f6f4 feat(举手与协作): 顶栏「N 人举手」点开按题分组带错法;看板多一列「在写」点了就能看他的代码;帮完了回看板、还有人就「下一个」
学生那边按钮写「xuyue 在帮你 / 在看你的代码」。顺带修了老师接单后编辑器里留着自己的
草稿或模板、学生的代码插在前面、两边从此不一致的问题。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:04:57 +08:00

228 lines
9.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { bracketMatching } from "@codemirror/language"
import CodeMirror from "./CodeMirror.vue"
import { autocompletion, closeBrackets, completeAnyWord } from "@codemirror/autocomplete"
import type { EditorView } from "@codemirror/view"
import type { Extension } from "@codemirror/state"
import type { LANGUAGE } from "utils/types"
import { oneDark } from "../themes/oneDark"
import { smoothy } from "../themes/smoothy"
import { styleTheme } from "shared/extensions/baseTheme"
import { enhanceCompletion } from "shared/extensions/autocompletion"
import { languageExtension } from "shared/extensions/language"
import { useCollabDoc } from "../composables/collabDoc"
import { useCollabStore } from "shared/store/collab"
const isDark = useDark()
const collabStore = useCollabStore()
const { start, stop, getInitialExtension } = useCollabDoc()
interface Props {
language?: LANGUAGE
fontSize?: number
height?: string
readonly?: boolean
placeholder?: string
/** 追加的 CodeMirror 扩展。传一个稳定的数组实例,每次渲染新建会让编辑器反复重配 */
extraExtensions?: Extension[]
/**
* 当前这个编辑器属于哪道题(题目的展示 ID)。
*
* **协作只在题号对得上时才建立。** 教师端现在也在题目页里协作(原来是单独的
* CollabModal 弹框),他接单时人可能停在别的题上 —— 不比对题号的话那个编辑器
* 会绑上学生的文档,老师看着自己的题面改着别人的代码。学生那边同理:排队期间
* 切到别的题,接通了也不该把这道题的代码交出去。
*/
problemId?: string
/**
* 这个入口接不接协作。比赛入口传 false:房间只记题号,比赛题号 1、2、3 和公开题撞号,
* 光比对题号挡不住(见 problemPageContext 的 `collab`)
*/
collab?: boolean
}
const {
language = "Python",
fontSize = 20,
height = "100%",
readonly = false,
placeholder = "",
extraExtensions = [],
problemId = "",
collab = true,
} = defineProps<Props>()
const code = defineModel<string>("value")
const langExtension = computed(() => languageExtension(language))
const extensions = computed(() => [
styleTheme,
langExtension.value,
bracketMatching(),
closeBrackets(),
isDark.value ? oneDark : smoothy,
autocompletion({
override: [enhanceCompletion(language), completeAnyWord],
}),
getInitialExtension(),
...extraExtensions,
])
// shallowRef,不是 ref:CodeMirror 的 EditorView 是带 getter 的类实例,
// Vue 的 UnwrapRef 深度展开会把它结构化成一个丢了原型方法的假类型,
// vue-tsc 会报 "missing dispatchTransactions/_root/..." 这类莫名其妙的错。
// 项目里旧的 sync.ts 用的是裸变量,同一个道理,这里换成 shallowRef 规避。
const editorView = shallowRef<EditorView | null>(null)
/** 房间开着,而且开的就是这道题 */
const roomIsHere = computed(
() =>
collab && collabStore.room !== null && (!problemId || collabStore.room.problemId === problemId),
)
/**
* 这个编辑器**接进过**当前这个房间。
*
* 用来区分两件看起来一样的事:「房间从我这儿挪走了」(我得明确结束协作)和
* 「房间在别处开起来了」(跟我无关,不能替别人把协作掐掉)。老师在别的题上
* 接单时后者天天发生 —— room 先 open、再导航过来,那一瞬间旧页面的编辑器
* 看到的就是「房间非空但不是我这道题」。
*/
const bound = ref(false)
const bind = (view: EditorView) => {
if (!roomIsHere.value) return
bound.value = true
// ★ 内容源只有学生一个:学生端拿自己的编辑器内容当种子,教师端必须传 null,
// 理由见 collabDoc.ts 的 StartOptions
start({
editorView: view,
seedContent: collabStore.isTeacher ? null : view.state.doc.toString(),
})
}
/**
* 拆掉本地绑定。**房间还在、只是不在这道题上了**(人走了,不是协作结束了)时
* 补一条 leave:
*
* - 教师从学生这道题切去别的题 —— 不发的话学生那边一直挂着「老师正在帮你」,
* 而老师的编辑器早就不在这个房间里,字一个也过不去;
* - 学生在协作中切去别的题 —— 同理。
*
* `/problem/1001` → `/problem/1003` 是同一条路由换 params,**组件被复用、不卸载**,
* 所以这件事不能只靠 onUnmounted(实测就是这么漏的)。
*/
const detach = () => {
const wasBound = bound.value
bound.value = false
stop()
if (wasBound && collabStore.room) collabStore.leave("left")
}
const handleEditorReady = (view: EditorView) => {
editorView.value = view
// 也从这里起:学生排队时切去看提交记录、老师在这期间接了单,
// 等他切回来时 room 早就非空了,只靠下面的 watch 是等不到的
bind(view)
}
/**
* 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器。
*
* **现在两边都走这里。** 教师端原来是一个独立的 CollabModal(弹框里再挂一个
* CodeMirror),接单改成跳到题目页之后,两边协作的都是页面上这一个编辑器,
* setBinaryHandler 那个单例槽位也只剩一个使用者,不会再有两个编辑器谁后调用
* 谁把对方顶掉的问题。
*
* 所以收窄的条件从「不是教师」换成了「房间开的是这道题」(roomIsHere)——
* 教师停在别的题上时照样不绑。
*/
watch(
// 两个源都要盯:
//
// - `room` 这个对象本身(换人、结束都会整个替换它)—— 同一道题上老师刚结束
// 一个学生又接下一个时,roomIsHere 全程为真,只看它就不会重建文档,
// 老师会留在上一轮那份已经没人的 Y.Doc 上;
// - `roomIsHere` —— 老师接单时人在**另一道题**的页面上,跳过来是
// `/problem/1002` → `/problem/1001`,同一条路由只换 params,**组件被复用**,
// `@ready` 不会再触发一次,而 room 早在导航之前就 open 了。只盯 room 的话
// 老师会停在一个空编辑器上干等(实测就是这样)。
//
// 协作期间学生切语言是就地改 room.language、不替换对象,所以不会误重连。
[() => collabStore.room, roomIsHere],
() => {
if (roomIsHere.value) {
if (editorView.value) bind(editorView.value)
} else detach()
},
)
// 求助期间切了语言:同步给老师,他那边的高亮和补全跟着换。
// 教师端不发 —— 协作中教师这个编辑器的语言本来就是跟着学生走的
// (ProblemEditor 的 editorLanguage),反过来发一遍只会把学生的语言覆盖掉
watch(
() => language,
(lang) => {
if (!collabStore.isTeacher) collabStore.updateLanguage(lang)
},
)
/**
* 学生**排队期间离开了这道题**:撤掉求助。
*
* 那条求助说的是「我卡在这道题」,人不在这道题上了就不成立了 —— 原来它会一直挂在
* 队列里,老师接进来时学生的编辑器不在这道题上、根本不会绑,老师对着一个空编辑器
* 敲字,两边都没有提示。把语言切成流程图同理:那一档连求助按钮都没有,留着他自己
* 也取消不掉。
*
* **只认「离开」这件事,不跟房间状态挂钩**:老师走开时服务端会把求助退回排队,
* 那条刚补上的 pending 不能被这里顺手取消掉。
*/
const cancelPendingIfLeaving = () => {
if (collabStore.isTeacher) return
if (collabStore.helpStatus === "pending") collabStore.cancelHelp()
}
// 同一条路由换 params(切到别的题)组件会被复用,所以这件事不能只靠 onUnmounted
watch(
() => problemId,
(next, previous) => {
if (previous && next !== previous) cancelPendingIfLeaving()
},
)
/**
* 学生开着这道题的编辑器:报给服务端,老师看板「在写」那一列就是它,点了能直接打开他的
* 代码(不用等他举手)。换题、换语言重报一次,编辑器卸载(切走、切成流程图)报 null。
* 比赛入口不报(collab = false):比赛题号和公开题撞号,老师打开的会是另一道题
*/
watch(
() => [collab ? problemId : "", language] as const,
([id, lang]) => collabStore.reportPresence(id || null, lang),
{ immediate: true },
)
onUnmounted(() => {
collabStore.reportPresence(null)
cancelPendingIfLeaving()
// 卸载意味着编辑器没了(切走整个页面、或者把语言切成流程图),CRDT 会话没法
// 接着用:回来时只能新建 Y.Doc,再拿编辑器内容当种子就会和对方那份合并成重复
// 文本。所以不是「悄悄把绑定拆了」,而是走 detach 明确结束协作 ——
// 教师端同理,他现在是在题目页里协作,离开这一页就是这次帮忙结束了。
detach()
})
</script>
<template>
<CodeMirror
v-model="code"
:extensions="extensions"
:disabled="readonly"
:tab-size="4"
:placeholder="placeholder"
:height="height"
:font-size="`${fontSize}px`"
@ready="handleEditorReady"
/>
</template>