fix(自学): 手机上读课好读多了:长代码不再被截掉半行(折行接着缩进),表格不再一字一行,填空的注释不再折成像另一行代码、空也大了;往下读时底栏收起、读到课末不再有两个「下一课」;点下一课落在新课开头而不是末尾;输出预测不再把同一个问题问两遍
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 22:40:39 +08:00
1 parent 4c47e9389b
commit 3dbdc7cb1b
8 files changed
+263 -65

No files matched your search

@@ -10,6 +10,8 @@ import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import { mainWrapExtensions, wrapMain } from "../wrapMain"
import { hangWrapExtensions } from "../hangIndent"
import { useBreakpoints } from "shared/composables/breakpoints"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
@@ -35,7 +37,12 @@ const message = useMessage()
const { copy } = useClipboard({ legacy: true })
const original = computed(() => (props.wrapMain ? wrapMain(props.code) : props.code))
const extensions = computed(() => (props.wrapMain ? mainWrapExtensions : []))
const { isDesktop } = useBreakpoints()
// 手机上折行(见 hangIndent.ts);桌面正文 720 宽,长行少,照旧横着滚
const extensions = computed(() => [
...(props.wrapMain ? mainWrapExtensions : []),
...(isDesktop.value ? [] : hangWrapExtensions),
])
const value = ref(original.value)
const input = ref("")
const running = ref(false)
@@ -2,6 +2,7 @@
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseDebugData } from "utils/types"
import { highlightLines } from "shared/highlight"
import { hangStyle } from "../hangIndent"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -93,7 +94,7 @@ function reset() {
<n-space vertical :size="6">
<div
v-for="(_line, idx) in data.lines"
v-for="(line, idx) in data.lines"
:key="idx"
:style="lineStyle(idx)"
@click="toggle(idx)"
@@ -101,7 +102,7 @@ function reset() {
<span style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0">
{{ idx + 1 }}
</span>
<span v-html="lineHtml[idx]" style="white-space: pre; min-width: 0; overflow-x: auto" />
<span v-html="lineHtml[idx]" :style="{ minWidth: 0, ...hangStyle(line) }" />
</div>
</n-space>
@@ -2,6 +2,7 @@
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseFillData } from "utils/types"
import { highlightWithHoles } from "shared/highlight"
import { hangStyle } from "../hangIndent"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -37,6 +38,32 @@ const segments = computed<Segment[]>(() => {
)
})
/**
* 按行切开,每行自己折行、悬挂缩进(见 hangIndent.ts)。原来整段 pre-wrap,
* 手机上行尾的注释折下来顶格,看着像另起了一行代码。
* 高亮的 HTML 在换行处是断开的(见 shared/highlight.ts 的 walk),按 "\n" 切不会切坏标签。
*/
const lines = computed(() => {
const rows: { indent: string; parts: Segment[] }[] = [{ indent: "", parts: [] }]
for (const seg of segments.value) {
if (seg.type === "blank") {
rows[rows.length - 1].parts.push(seg)
continue
}
seg.html.split("\n").forEach((html, i) => {
if (i > 0) rows.push({ indent: "", parts: [] })
if (html) rows[rows.length - 1].parts.push({ type: "code", html })
})
}
for (const row of rows) {
const first = row.parts[0]
if (first?.type === "code") {
row.indent = first.html.replace(/<[^>]*>/g, "").match(/^[ \t]*/)![0]
}
}
return rows
})
const blankCount = computed(() => segments.value.filter((s) => s.type === "blank").length)
const userInputs = ref<string[]>([])
const wrongBlanks = ref<Set<number>>(new Set())
@@ -78,52 +105,59 @@ function inputWidth(idx: number): string {
{{ data.question }}
</p>
<pre
<div
:style="{
fontFamily: 'var(--learn-code-font)',
fontSize: '16px',
lineHeight: '1.6',
lineHeight: '1.9',
background: 'var(--learn-soft)',
border: '1px solid var(--learn-line)',
borderRadius: '6px',
padding: '12px',
overflowX: 'auto',
whiteSpace: 'pre-wrap',
margin: 0,
padding: '10px 12px',
}"
><template v-for="(seg, i) in segments" :key="i"
><span v-if="seg.type === 'code'" v-html="seg.html" /><input
v-else
:value="userInputs[seg.index]"
:disabled="allCorrect"
autocapitalize="off"
autocorrect="off"
autocomplete="off"
spellcheck="false"
:style="{
width: inputWidth(seg.index),
fontFamily: 'var(--learn-code-font)',
fontSize: '16px',
padding: '0 6px',
borderRadius: '3px',
border: `1.5px solid ${
allCorrect
? '#18a058'
: wrongBlanks.has(seg.index)
? '#d03050'
: 'var(--learn-control-line)'
}`,
background: allCorrect
? 'rgba(24,160,88,0.08)'
: wrongBlanks.has(seg.index)
? 'rgba(208,48,80,0.07)'
: 'transparent',
outline: 'none',
color: 'inherit',
minWidth: '4ch',
}"
@input="userInputs[seg.index] = ($event.target as HTMLInputElement).value"
/></template></pre>
>
<div
v-for="(row, r) in lines"
:key="r"
:style="{ minHeight: '1.9em', ...hangStyle(row.indent) }"
>
<template v-for="(seg, i) in row.parts" :key="i"
><span v-if="seg.type === 'code'" v-html="seg.html" /><input
v-else
:value="userInputs[seg.index]"
:disabled="allCorrect"
autocapitalize="off"
autocorrect="off"
autocomplete="off"
spellcheck="false"
:style="{
width: inputWidth(seg.index),
fontFamily: 'var(--learn-code-font)',
fontSize: '16px',
lineHeight: '1.4',
padding: '2px 6px',
textIndent: 0,
borderRadius: '4px',
border: `1.5px solid ${
allCorrect
? '#18a058'
: wrongBlanks.has(seg.index)
? '#d03050'
: 'var(--learn-control-line)'
}`,
background: allCorrect
? 'rgba(24,160,88,0.08)'
: wrongBlanks.has(seg.index)
? 'rgba(208,48,80,0.07)'
: 'var(--learn-pane)',
outline: 'none',
color: 'inherit',
minWidth: '5ch',
}"
@input="userInputs[seg.index] = ($event.target as HTMLInputElement).value"
/></template>
</div>
</div>
<ExerciseResult
v-if="wrongBlanks.size > 0 || allCorrect"
@@ -74,7 +74,11 @@ function reset() {
}"
><code style="font-family: inherit" v-html="codeHtml" /></pre>
<p style="font-weight: 500; margin: 14px 0 8px">这段代码会输出什么?</p>
<!-- 题目本身多半就是这句,再写一遍就成了同一个问题问两次 -->
<p v-if="!data.question.includes('输出什么')" style="font-weight: 500; margin: 14px 0 8px">
这段代码会输出什么?
</p>
<div v-else style="height: 14px" />
<n-input
v-model:value="userInput"
type="textarea"
@@ -4,6 +4,7 @@ import type { Exercise, ExerciseSortData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
import { highlightLines } from "shared/highlight"
import { useBreakpoints } from "shared/composables/breakpoints"
import { hangStyle } from "../hangIndent"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -140,7 +141,7 @@ const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props
<span style="color: #bbb; cursor: grab">⠿</span>
<span
v-html="lineHtml[line.originalIdx]"
style="white-space: pre; min-width: 0; overflow-x: auto"
:style="{ minWidth: 0, ...hangStyle(line.text) }"
/>
</div>
</n-space>
@@ -66,6 +66,15 @@ function inline(text: string) {
margin: 28px 0 12px;
scroll-margin-top: 16px;
}
/* 手机上四列的表会把每格挤成一两个字一行(「浮」「点」「数」):给格子留个最小宽度、表头不折,
放不下就整张横着滚(Markdown.vue 已经让表格自己滚) */
.body :deep(.oj-markdown th),
.body :deep(.oj-markdown td) {
min-width: 4em;
}
.body :deep(.oj-markdown th) {
white-space: nowrap;
}
.goals {
margin: 4px 0 8px;
padding: 12px 16px;
+78
View File
@@ -0,0 +1,78 @@
import { RangeSetBuilder } from "@codemirror/state"
import {
Decoration,
type DecorationSet,
EditorView,
ViewPlugin,
type ViewUpdate,
} from "@codemirror/view"
/**
* 手机上代码折行。原来是横着滚,可没有滚动条也看不出右边还有,
* 综合练习那种长 print 整整半行看不见。折下来的那截缩到这一行的代码下面再进两格,
* 一眼看得出还是同一行。
*/
/** 行首空白占几格,tab 按 4 */
function leadingWidth(text: string): number {
let width = 0
for (const ch of text) {
if (ch === " ") width++
else if (ch === "\t") width += 4
else break
}
return width
}
/** 静态的代码行(练一练的排序、找错、填空):折行,接着的那截悬挂缩进 */
export function hangStyle(text: string): Record<string, string> {
const width = leadingWidth(text) + 2
return {
whiteSpace: "pre-wrap",
// 中文连着的一串别从中间断(「类」「型」各在一行),实在放不下才随便断
wordBreak: "keep-all",
overflowWrap: "anywhere",
paddingLeft: `${width}ch`,
textIndent: `-${width}ch`,
}
}
function hangDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>()
for (const { from, to } of view.visibleRanges) {
for (let pos = from; pos <= to;) {
const line = view.state.doc.lineAt(pos)
const width = leadingWidth(line.text) + 2
// 6px 是 CodeMirror 给 .cm-line 的左内边距,盖掉了要补回来
builder.add(
line.from,
line.from,
Decoration.line({
attributes: {
style: `padding-left: calc(${width}ch + 6px); text-indent: -${width}ch; word-break: keep-all; overflow-wrap: anywhere`,
},
}),
)
pos = line.to + 1
}
}
return builder.finish()
}
const hangPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = hangDecorations(view)
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = hangDecorations(update.view)
}
}
},
{ decorations: (plugin) => plugin.decorations },
)
/** 编辑器(正文里能运行的代码块):折行 + 悬挂缩进 */
export const hangWrapExtensions = [EditorView.lineWrapping, hangPlugin]
+85 -21
View File
@@ -138,7 +138,11 @@ async function init() {
activeId.value = ""
await nextTick()
reader.value?.scrollTo({ top: 0 })
if (!isDesktop.value) window.scrollTo({ top: 0 })
// 手机上滚的是外层 n-layout 的容器,不是 window —— 原来 window.scrollTo 什么都没滚,
// 点「下一课」落在下一课的末尾
if (!isDesktop.value) scroller()?.scrollTo({ top: 0 })
pagerHidden.value = false
atEnd.value = false
}
watch(
@@ -178,6 +182,51 @@ function onSolved(exerciseId: number) {
progress.value = { ...progress.value, [id]: { ...row, exerciseSolved: solvedCount.value } }
}
// ------------------------------------------------------------------ 手机底栏
// 往下读时收起上一课 / 目录 / 下一课,往回翻再出来;读到课末也收着 ——
// 课末卡片里就有「下一课」,两个摆在一起是重复的
const mobileRoot = ref<HTMLElement | null>(null)
const endRef = ref<HTMLElement | null>(null)
const pagerHidden = ref(false)
const atEnd = ref(false)
/** 往上找真正在滚的那层。n-layout-content 自己也带 .n-layout-scroll-container 类却不滚,按类名找会找错 */
function scroller(): HTMLElement | null {
for (let el = mobileRoot.value?.parentElement; el; el = el.parentElement) {
const overflow = getComputedStyle(el).overflowY
if ((overflow === "auto" || overflow === "scroll") && el.scrollHeight > el.clientHeight) {
return el
}
}
return null
}
let lastTop = 0
function onMobileScroll(event: Event) {
const box = event.target as HTMLElement
const top = box.scrollTop
if (top < 60) pagerHidden.value = false
else if (top > lastTop + 6) pagerHidden.value = true
else if (top < lastTop - 6) pagerHidden.value = false
lastTop = top
const end = endRef.value?.getBoundingClientRect().top
atEnd.value = end !== undefined && end < box.getBoundingClientRect().bottom
}
// 滚动事件不冒泡,在 document 上用捕获拿,不用先找到容器再挂
useEventListener(
document,
"scroll",
(event: Event) => {
const box = event.target
if (isDesktop.value || !(box instanceof HTMLElement)) return
// 只认包着本页的那层(目录抽屉、代码块里横着滚的不算)
if (!mobileRoot.value || !box.contains(mobileRoot.value)) return
onMobileScroll(event)
},
{ capture: true, passive: true },
)
// ------------------------------------------------------------------ 读到哪一节
const reader = ref<HTMLElement | null>(null)
const activeId = ref("")
@@ -234,7 +283,9 @@ function jumpExercise(id: number) {
<main class="main">
<div ref="reader" class="reader" @scroll.passive="onScroll">
<article v-if="tutorial" class="article">
<!-- 按课重建:复用的话正文里的编辑器是就地换内容,CodeMirror 会把光标滚进视野,
刚滚回顶上的页面又被拽到某个代码块那儿 -->
<article v-if="tutorial" :key="tutorial.id" class="article">
<div class="crumb muted">
<span v-if="tutorial.project">{{ tutorial.project }}</span>
<span v-if="tutorial.project">·</span>
@@ -302,7 +353,7 @@ function jumpExercise(id: number) {
</div>
<!-- 手机 -->
<div v-else class="mobile">
<div v-else ref="mobileRoot" class="mobile">
<div class="mbar">
<n-button @click="menuOpen = true">
<template #icon><Icon icon="ph:list" /></template>
@@ -322,7 +373,7 @@ function jumpExercise(id: number) {
</span>
</span>
</div>
<article v-if="tutorial" class="marticle">
<article v-if="tutorial" :key="tutorial.id" class="marticle">
<div v-if="tutorial.project" class="crumb muted">{{ tutorial.project }}</div>
<h1 class="h1 small">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
@@ -334,24 +385,26 @@ function jumpExercise(id: number) {
:solved-ids="solvedIds"
@solved="onSolved"
/>
<LessonEnd
:step="step"
:total="titles.length"
:course-label="courseLabel"
:exercise-count="parsed.exerciseCount"
:solved-count="solvedCount"
:first-unsolved="firstUnsolved"
:problems="tutorial.problems"
:next="next"
:groups="groups"
:state-of="stateOf"
:done-count="done"
compact
@jump="jumpExercise"
/>
<div ref="endRef">
<LessonEnd
:step="step"
:total="titles.length"
:course-label="courseLabel"
:exercise-count="parsed.exerciseCount"
:solved-count="solvedCount"
:first-unsolved="firstUnsolved"
:problems="tutorial.problems"
:next="next"
:groups="groups"
:state-of="stateOf"
:done-count="done"
compact
@jump="jumpExercise"
/>
</div>
</article>
<n-spin v-else-if="loading" class="spin" />
<nav class="mpager">
<nav class="mpager" :class="{ hidden: pagerHidden || atEnd }">
<n-button v-if="prev" size="large" @click="router.push(prev.to)">
<template #icon><Icon icon="ph:caret-left-bold" /></template>
上一课
@@ -374,7 +427,14 @@ function jumpExercise(id: number) {
</n-button>
</nav>
<n-drawer v-model:show="menuOpen" placement="bottom" height="85%" class="sheet">
<!-- 不自动聚焦:一打开焦点框套在「自学首页」上,手机上看着像出了错 -->
<n-drawer
v-model:show="menuOpen"
placement="bottom"
height="85%"
class="sheet"
:auto-focus="false"
>
<n-drawer-content :native-scrollbar="false" closable>
<template #header>
<span class="sheet-head">
@@ -582,6 +642,10 @@ function jumpExercise(id: number) {
display: flex;
align-items: center;
background: v-bind("theme.bodyColor");
transition: transform 0.2s ease;
}
.mpager.hidden {
transform: translateY(100%);
}
.sheet-head {
display: flex;