Files
OJ2/apps/web/src/oj/problem/utils/editTrace.ts
T
xuyueandClaude Opus 5.5 a8b83ba762 refactor(题目): 比赛和题单改用题库那个编辑器——题单里的求助终于接得上,一键换中文标点也能用了
比赛和题单原来用的是另一个精简版编辑器(BasicEditor),没有协作绑定、也没有
错误标记扩展:题单里学生点「求助」,老师接单后编辑器绑不上;比赛和题单里
「把中文标点都换成英文」点了没反应、出错行也不标红。三种入口统一成 ProblemEditor,
删掉 BasicEditor。流程图仍然只在题库入口出现(语言列表那道闸没动)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:18:58 +08:00

135 lines
4.8 KiB
TypeScript
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.
import { EditorView } from "@codemirror/view"
import type { SubmissionTrace } from "@oj2/contract"
/**
* 编辑过程信号的采集,随提交一起报给后端(落进 `submission_trace`)。
* 字段含义见契约的 `submissionTraceSchema`。**只数字符数和次数,不留任何按键内容。**
*
* 是模块单例而不是 Pinia store:编辑器(ProblemEditor 里)和
* 提交按钮(EditorToolbar → SubmitCode)是兄弟组件,得共用一份计数;而 CodeMirror 的
* 扩展对象一旦经过 store 就会被包成响应式代理,facet 靠身份比较,代理过的扩展
* 直接失效。计数本身也不需要响应式。
*
* **只数带 userEvent 的事务。** 下面这些都不带,所以天然不会被算进去:
* - `codeStore.setCode()` —— vue-codemirror 的 setDoc 只 dispatch 一个 changes:
* 提交前的自动格式化、载入草稿 / 模板、切语言都走这条;
* - 课堂协作里对方的改动 —— y-codemirror.next 应用远程更新时只挂 ySyncAnnotation。
*
* 撤销 / 重做、编辑器内部拖动(`move.drop`)也不数:它们既不是新写的也不是外来的。
*/
/** 两次编辑间隔超过这个就算走开了,中间这段不计入活跃时长 */
const IDLE_MS = 60_000
let key: string | null = null
let startedAt = 0
let lastEditAt: number | null = null
let activeMs = 0
let typedChars = 0
let pastedChars = 0
let pasteCount = 0
let maxPaste = 0
let deletedChars = 0
let blurCount = 0
let initialLen = 0
function reset(len: number) {
startedAt = performance.now()
lastEditAt = null
activeMs = 0
typedChars = 0
pastedChars = 0
pasteCount = 0
maxPaste = 0
deletedChars = 0
blurCount = 0
initialLen = len
}
/**
* 开始(或接着)记一道题。编辑器载入代码之后调。
*
* `traceKey` 没变就**什么都不做** —— 同一道题里切去看提交记录、再切回来,
* 编辑器可能会重新挂载,不能因此把这一段的计数清掉。换了题才重新开始。
* 切语言不换 key:学生改用另一种语言重写这道题,仍然是同一段做题过程。
*/
export function beginEditTrace(traceKey: string, len: number) {
if (traceKey === key) return
key = traceKey
reset(len)
}
/** 这一段的快照,附在提交请求上 */
export function snapshotEditTrace(collab: boolean): SubmissionTrace {
return {
activeMs: Math.round(activeMs),
sinceOpenMs: Math.round(performance.now() - startedAt),
typedChars,
pastedChars,
pasteCount,
maxPaste,
deletedChars,
blurCount,
initialLen,
collab,
}
}
/** 提交成功之后调:下一条提交只记从这里往后的那一段 */
export function restartEditTrace(len: number) {
reset(len)
}
function touch() {
const now = performance.now()
if (lastEditAt !== null && now - lastEditAt <= IDLE_MS) activeMs += now - lastEditAt
lastEditAt = now
}
// 只数 hidden 这一个事件:切标签页时 window 的 blur 和 visibilitychange 会一起触发,
// 两个都数就是一次记两下。代价是同屏切到别的窗口(页面仍可见)不计,这本来就只是辅助信号。
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "hidden") return
blurCount++
// 切走的这段不算活跃,回来之后的第一下编辑重新起算
lastEditAt = null
})
/** 挂到题目页的代码编辑器上。同一个实例,别每次渲染新建 —— 那会让编辑器反复重配扩展 */
export const editTraceExtensions = [
EditorView.updateListener.of((update) => {
if (!update.docChanged) return
for (const tr of update.transactions) {
if (!tr.docChanged) continue
// 顺序要紧:isUserEvent("input") 也会匹配 "input.paste"
const pasted = tr.isUserEvent("input.paste") || tr.isUserEvent("input.drop")
const typed = !pasted && tr.isUserEvent("input")
const deleted = tr.isUserEvent("delete")
if (!pasted && !typed && !deleted) continue
let inserted = 0
let removed = 0
tr.changes.iterChanges((fromA, toA, _fromB, _toB, text) => {
// 原样替换不算:closeBrackets 越过已有的右括号 / 引号时,是把 `)` 替换成 `)`
// 而不是只挪光标(@codemirror/autocomplete 的 handleClose),不排掉的话
// 每敲一个右括号就多记一个键入加一个删除
if (toA - fromA === text.length && tr.startState.sliceDoc(fromA, toA) === text.toString())
return
removed += toA - fromA
inserted += text.length
})
// 选中一段再打字 / 粘贴,被替换掉的那部分也算删除
deletedChars += removed
if (pasted) {
pastedChars += inserted
pasteCount++
if (inserted > maxPaste) maxPaste = inserted
} else if (typed) {
typedChars += inserted
}
touch()
}
}),
]