Files
OJ2/apps/web/src/oj/problem/utils/errorMark.ts
T
xuyueandClaude Opus 5.5 11ff340110 feat(题目): 语法没过时把代码里每一处中文标点都标出来,和「换成英文(N 处)」对得上
- 原来编辑器里只标报错点名的第一处,按钮却写着「4 处」,学生对不上、也不知道一键替换
  会改哪里。字符串和注释里的中文标点是合法的,不标(和一键替换同一套判断)
- 这种情况下没交上去:结果页签那一段叫「交之前检查」,标题写「还没有交上去」,
  不再叫「提交结果」

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

164 lines
5.7 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 { StateEffect, StateField, type EditorState } from "@codemirror/state"
import { Decoration, EditorView, ViewPlugin, type DecorationSet } from "@codemirror/view"
import type { PunctuationFix } from "./chinesePunctuation"
/**
* 在题目页的编辑器里标出编译错误的位置:整行淡红底,`^` 标的那一段加红波浪线。
*
* 中职学生读不懂英文报错,「看这里」比任何文字都直接。标记跟着编辑映射位置
* (在上面插一行,标记跟着往下走),下一次提交时清掉。
*/
interface Mark {
line: number
/** 行内列号,[from, to);没有就只标整行 */
from?: number
to?: number
}
const setMark = StateEffect.define<Mark | null>()
const lineDeco = Decoration.line({ class: "cm-error-line" })
const rangeDeco = Decoration.mark({ class: "cm-error-range" })
function build(state: EditorState, mark: Mark): DecorationSet {
if (mark.line < 1 || mark.line > state.doc.lines) return Decoration.none
const line = state.doc.line(mark.line)
const ranges = [lineDeco.range(line.from)]
if (mark.from !== undefined && mark.to !== undefined) {
const from = line.from + Math.min(mark.from, line.length)
const to = line.from + Math.min(mark.to, line.length)
if (to > from) ranges.push(rangeDeco.range(from, to))
}
return Decoration.set(ranges)
}
/**
* 中文标点的标记:报错只点名第一处,但「把中文标点都换成英文(N 处)」说的是 N 处 ——
* 编辑器里只看得到一处的话,学生对不上那个 N,也不知道一键替换会改哪里。
* 字符串和注释里的不标(那是合法的,见 chinesePunctuation.ts)
*/
const setPunctuation = StateEffect.define<PunctuationFix[]>()
const punctuationDeco = Decoration.mark({ class: "cm-punctuation-mark" })
const punctuationField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
deco = deco.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setPunctuation)) {
const max = tr.state.doc.length
deco = Decoration.set(
effect.value
.filter((fix) => fix.to <= max && fix.to > fix.from)
.map((fix) => punctuationDeco.range(fix.from, fix.to)),
true,
)
}
// 清错误标记的时候(下一次提交、一键替换)一起清
if (effect.is(setMark) && effect.value === null) deco = Decoration.none
}
return deco
},
provide: (field) => EditorView.decorations.from(field),
})
const markField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(deco, tr) {
deco = deco.map(tr.changes)
for (const effect of tr.effects) {
if (effect.is(setMark)) deco = effect.value ? build(tr.state, effect.value) : Decoration.none
}
return deco
},
provide: (field) => EditorView.decorations.from(field),
})
/**
* 题目页当前那个编辑器。结果面板和编辑器是兄弟组件,隔着好几层,
* 与其一路透传 ref,不如让编辑器挂载时自己登记。只挂在题目页主编辑器上
* (ProblemEditor 的扩展数组),自测、流程图那些编辑器不登记。
*/
let current: EditorView | null = null
const tracker = ViewPlugin.define((view) => {
current = view
return {
destroy() {
if (current === view) current = null
},
}
})
const theme = EditorView.baseTheme({
".cm-error-line": { backgroundColor: "rgba(208, 48, 80, 0.12)" },
".cm-error-range": {
textDecoration: "underline wavy #d03050",
textUnderlineOffset: "3px",
backgroundColor: "rgba(208, 48, 80, 0.18)",
},
".cm-punctuation-mark": {
backgroundColor: "rgba(240, 160, 32, 0.35)",
outline: "1px solid rgba(240, 160, 32, 0.8)",
borderRadius: "2px",
},
})
export const errorMarkExtensions = [markField, punctuationField, tracker, theme]
/** 标出这些中文标点(位置是整篇代码里的偏移,来自 findChinesePunctuation) */
export function showPunctuationMarks(fixes: PunctuationFix[]) {
current?.dispatch({ effects: setPunctuation.of(fixes) })
}
/**
* 标出第 `line` 行并滚过去。`sourceLine` 是报错里打出来的那一行(去了行首空格),
* 编辑器里这一行对得上才标列范围 —— 学生看结果之前可能已经改过代码了,
* 对不上时列号没有意义,只标整行。
*/
export function showErrorMark(
line: number,
sourceLine: string | null,
caret: { from: number; to: number } | null,
) {
const view = current
if (!view || line < 1 || line > view.state.doc.lines) return
const text = view.state.doc.line(line).text
const mark: Mark = { line }
if (caret && sourceLine !== null) {
const stripped = text.replace(/^ +/, "")
if (stripped.trimEnd() === sourceLine.trimEnd()) {
const indent = text.length - stripped.length
mark.from = indent + caret.from
mark.to = indent + caret.to
}
}
const pos = view.state.doc.line(line).from
view.dispatch({
effects: [setMark.of(mark), EditorView.scrollIntoView(pos, { y: "center" })],
})
}
export function clearErrorMark() {
current?.dispatch({ effects: setMark.of(null) })
}
/** 当前编辑器里的代码;编辑器没挂载时为 null */
export function currentCode() {
return current?.state.doc.toString() ?? null
}
/**
* 在编辑器里批量替换。走编辑器事务而不是直接改 store 里的字符串:
* 这样一次 Ctrl+Z 就能撤回,协作中的对方也能同步到。
*
* 不打 userEvent:editTrace 按 `isUserEvent("input")` 数「自己敲的字」,
* 打成 `input.*` 就会把这次替换算成学生手打的。
*/
export function applyFixes(fixes: PunctuationFix[]) {
if (!current || !fixes.length) return false
current.dispatch({ changes: fixes, effects: setMark.of(null) })
return true
}