feat(web): 协作编辑走 collab 通道
Yjs sync/awareness 协议直接跑在 /ws/collab 上,服务端哑转发。 内容源是学生:学生端先把编辑器内容写进 ytext 再挂 yCollab, 教师端 seedContent 恒为 null —— 这条根治了老实现里谁的代码活下来看运气的问题。 订正 brief 里的一处疏漏:SyncCodeEditor.vue 挂在每个用户的题目页上,教师也不例外 (ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色)。教师接单同样会让 collabStore.room 非空,若不按角色收窄,教师自己停在某道题页面上接单时,这个组件 会把教师自己的编辑器内容当成种子插入文档,还会跟 CollabModal 抢 setBinaryHandler 这个单例槽位。改为 `room && !collabStore.isTeacher` 才起协作, 教师端的协作只归 CollabModal 管。 另外两处修正: - editorView 用 shallowRef 而非 ref —— CodeMirror 的 EditorView 是带 getter 的类 实例,ref() 的深度 UnwrapRef 会把它拆成丢了原型方法的假类型,vue-tsc 报错。 - Header.vue 挂 CollabModal 放进根 n-flex 内部而不是同级 —— 组件一旦变成多根 fragment,default.vue 里 `<Header class="header" />` 的 class 就没有单一根节点 可以落地,header 行会丢掉居中样式(实测触发了 Vue 的 Extraneous non-props attributes 警告)。n-modal 默认 teleport 到 body,塞在 这里不影响其实际渲染位置。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
93
apps/web/src/shared/components/CollabModal.vue
Normal file
93
apps/web/src/shared/components/CollabModal.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { cpp } from "@codemirror/lang-cpp"
|
||||
import { bracketMatching } from "@codemirror/language"
|
||||
import { closeBrackets } from "@codemirror/autocomplete"
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
import { Codemirror } from "vue-codemirror"
|
||||
import { oneDark } from "../themes/oneDark"
|
||||
import { smoothy } from "../themes/smoothy"
|
||||
import { styleTheme } from "shared/extensions/baseTheme"
|
||||
import { useCollabDoc } from "../composables/collabDoc"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
|
||||
const isDark = useDark()
|
||||
const collabStore = useCollabStore()
|
||||
const { start, stop, getInitialExtension } = useCollabDoc()
|
||||
|
||||
const code = ref("")
|
||||
// shallowRef,理由见 SyncCodeEditor.vue:EditorView 是类实例,ref() 的深度
|
||||
// UnwrapRef 会把它拆成一个丢了原型方法的假类型,vue-tsc 报莫名其妙的类型错。
|
||||
const editorView = shallowRef<EditorView | null>(null)
|
||||
|
||||
// 教师端只在自己发起接单时开。学生端的协作在 SyncCodeEditor 里
|
||||
const show = computed({
|
||||
get: () => collabStore.isTeacher && collabStore.room !== null,
|
||||
set: (value: boolean) => {
|
||||
if (!value) collabStore.leave()
|
||||
},
|
||||
})
|
||||
|
||||
const extensions = computed(() => [
|
||||
styleTheme,
|
||||
cpp(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
isDark.value ? oneDark : smoothy,
|
||||
getInitialExtension(),
|
||||
])
|
||||
|
||||
const handleEditorReady = (payload: { view: EditorView }) => {
|
||||
editorView.value = payload.view
|
||||
}
|
||||
|
||||
watch(
|
||||
() => collabStore.room,
|
||||
async (room) => {
|
||||
if (room && collabStore.isTeacher) {
|
||||
await nextTick()
|
||||
if (!editorView.value) return
|
||||
// ★ 教师端 seedContent 必须是 null —— 内容全部来自学生端
|
||||
start({ editorView: editorView.value as EditorView, seedContent: null })
|
||||
} else {
|
||||
stop()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(stop)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
:style="{ width: '80vw', maxWidth: '1100px' }"
|
||||
:title="`正在帮 ${collabStore.room?.peerName ?? ''} · ${collabStore.room?.problemId ?? ''}`"
|
||||
>
|
||||
<template #header-extra>
|
||||
<n-button
|
||||
text
|
||||
tag="a"
|
||||
target="_blank"
|
||||
:href="`/problem/${collabStore.room?.problemId}`"
|
||||
>
|
||||
打开题面
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<Codemirror
|
||||
v-model="code"
|
||||
indentWithTab
|
||||
:extensions="extensions"
|
||||
:tab-size="4"
|
||||
style="height: 60vh; font-size: 18px"
|
||||
@ready="handleEditorReady"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" @click="collabStore.leave()">结束协作</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import { useLearnProgress } from "shared/composables/learnProgress"
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
import { useScreenModeStore } from "shared/store/screenMode"
|
||||
import { logout } from "../api"
|
||||
import CollabModal from "./CollabModal.vue"
|
||||
import HelpRequestList from "./HelpRequestList.vue"
|
||||
import { useConfigStore } from "../store/config"
|
||||
import { useUserStore } from "../store/user"
|
||||
@@ -363,6 +364,14 @@ function handleMenuSelect(key: string) {
|
||||
</template>
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<!--
|
||||
挂在根 n-flex 内部而不是同级:Header.vue 一旦变成多根 fragment,
|
||||
default.vue 里 `<Header class="header" />` 那个 class 就没有任何单一
|
||||
根节点可以落地(Vue 会报 "Extraneous non-props attributes" 警告并把它
|
||||
整个丢弃),header 行随之丢掉 `max-width: 2000px` 那条居中样式。
|
||||
n-modal 默认 teleport 到 body,塞在这里不影响它的实际渲染位置。
|
||||
-->
|
||||
<CollabModal v-if="userStore.isTeacherOrAbove" />
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,13 +10,18 @@ import {
|
||||
completeAnyWord,
|
||||
} from "@codemirror/autocomplete"
|
||||
import type { Extension } from "@codemirror/state"
|
||||
import type { EditorView } from "@codemirror/view"
|
||||
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 { useCollabDoc } from "../composables/collabDoc"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
|
||||
const isDark = useDark()
|
||||
const collabStore = useCollabStore()
|
||||
const { start, stop, getInitialExtension } = useCollabDoc()
|
||||
|
||||
interface Props {
|
||||
problem: string
|
||||
@@ -51,8 +56,46 @@ const extensions = computed(() => [
|
||||
autocompletion({
|
||||
override: [enhanceCompletion(language), completeAnyWord],
|
||||
}),
|
||||
getInitialExtension(),
|
||||
])
|
||||
|
||||
interface EditorReadyPayload {
|
||||
view: EditorView
|
||||
}
|
||||
|
||||
// shallowRef,不是 ref:CodeMirror 的 EditorView 是带 getter 的类实例,
|
||||
// Vue 的 UnwrapRef 深度展开会把它结构化成一个丢了原型方法的假类型,
|
||||
// vue-tsc 会报 "missing dispatchTransactions/_root/..." 这类莫名其妙的错。
|
||||
// 项目里旧的 sync.ts 用的是裸变量,同一个道理,这里换成 shallowRef 规避。
|
||||
const editorView = shallowRef<EditorView | null>(null)
|
||||
|
||||
const handleEditorReady = (payload: EditorReadyPayload) => {
|
||||
editorView.value = payload.view
|
||||
}
|
||||
|
||||
// 房间开了才建文档。学生点求助时什么都不做 —— 老师没来之前不该动他的编辑器。
|
||||
// 只在学生端启用:这个组件挂在每个用户的题目页上(教师也不例外,
|
||||
// ProblemEditor.vue 只按语言是不是 Flowchart 分支,不看角色),
|
||||
// 教师接单同样会让 collabStore.room 非空 —— 如果这里不按角色收窄,
|
||||
// 教师自己停在某道题的页面上接单时,这个组件会把**教师自己的编辑器内容**
|
||||
// 当成种子插入文档,还会跟 CollabModal 抢 setBinaryHandler 这个单例槽位,
|
||||
// 两者谁后调用谁把对方顶掉。教师端的协作只归 CollabModal 管。
|
||||
watch(
|
||||
() => collabStore.room,
|
||||
(room) => {
|
||||
if (room && !collabStore.isTeacher && editorView.value) {
|
||||
// 学生端:当前编辑器内容就是内容源
|
||||
start({
|
||||
editorView: editorView.value,
|
||||
seedContent: editorView.value.state.doc.toString(),
|
||||
})
|
||||
} else {
|
||||
stop()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(stop)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -64,5 +107,6 @@ const extensions = computed(() => [
|
||||
:tab-size="4"
|
||||
:placeholder="placeholder"
|
||||
:style="{ height, fontSize: `${fontSize}px` }"
|
||||
@ready="handleEditorReady"
|
||||
/>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user