feat(题目页): 手机上写代码顺手多了:键盘上面多一排常用符号(括号、引号、冒号、缩进…点一下就进去,括号自动配对),代码字号改小、长行折行不再看不见右半截;输入输出说明上下排、例子不再被左边两列标签挤窄;题单里那条不再超出屏幕
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:56:02 +08:00
1 parent 3dbdc7cb1b
commit 7e68e11371
13 files changed
+262 -16

No files matched your search

@@ -10,7 +10,7 @@ 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 { hangWrapExtensions } from "shared/extensions/hangIndent"
import { useBreakpoints } from "shared/composables/breakpoints"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
@@ -2,7 +2,7 @@
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseDebugData } from "utils/types"
import { highlightLines } from "shared/highlight"
import { hangStyle } from "../hangIndent"
import { hangStyle } from "shared/extensions/hangIndent"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -2,7 +2,7 @@
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseFillData } from "utils/types"
import { highlightWithHoles } from "shared/highlight"
import { hangStyle } from "../hangIndent"
import { hangStyle } from "shared/extensions/hangIndent"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -4,7 +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"
import { hangStyle } from "shared/extensions/hangIndent"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -311,7 +311,8 @@ onSolvedHere(() => {
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`">
‹ {{ setBar.title }}
</router-link>
<div v-if="setBar.joined" class="set-progress" aria-hidden="true">
<!-- 手机上一排放不下题单名 + 进度条 + 做完几道 + 两个大箭头,进度条让掉,「做完 x/y」还在 -->
<div v-if="setBar.joined && !isMobile" class="set-progress" aria-hidden="true">
<div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" />
</div>
<span v-if="setBar.joined" class="meta">
@@ -319,7 +320,9 @@ onSolvedHere(() => {
<template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template>
</span>
<template v-else>
<span class="meta">还没加入,在这里做对了不算进度</span>
<span class="meta">{{
isMobile ? "没加入,不算进度" : "还没加入,在这里做对了不算进度"
}}</span>
<n-button size="tiny" type="primary" :loading="joiningSet" @click="joinHere"
>加入题单</n-button
>
@@ -5,6 +5,7 @@ import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import SubmitCode from "./SubmitCode.vue"
import { editorFocused } from "../utils/symbolKeys"
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
@@ -29,7 +30,8 @@ const canRunSamples = computed(
</script>
<template>
<div class="mobile-actions">
<!-- 写代码时(键盘弹着)让位给符号键:两条都贴底会叠在一起,安卓上还会被键盘顶上来 -->
<div v-show="!editorFocused" class="mobile-actions">
<n-button
v-if="canRunSamples"
size="large"
@@ -154,10 +154,16 @@ const sampleTextWidth = computed(() => {
const sampleTable = useTemplateRef<HTMLElement>("sampleTable")
const { width: sampleTableWidth } = useElementSize(sampleTable)
/**
* 窄(手机,或桌面左栏拖到最窄):「例子 N」不占一列,改成每个例子头上一行小字。
* 390 宽的手机上那一列连同间距要吃掉 64px,框只剩一半多
*/
const sampleNarrow = computed(() => sampleTableWidth.value > 0 && sampleTableWidth.value < 480)
/** 摆成表时,半格里留给文字的宽度 */
const sampleHalfWidth = computed(
() =>
(sampleTableWidth.value - SAMPLE_NO_W - SAMPLE_GAP * 2) / 2 -
(sampleTableWidth.value - (sampleNarrow.value ? 0 : SAMPLE_NO_W + SAMPLE_GAP) - SAMPLE_GAP) /
2 -
SAMPLE_PAD_L -
SAMPLE_PAD_R -
SAMPLE_COPY_W,
@@ -179,7 +185,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</script>
<template>
<div v-if="problem">
<div v-if="problem" class="problem-content">
<header class="problem-head">
<div class="title-row">
<span class="pid">{{ problem._id }}</span>
@@ -329,12 +335,12 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
<div
ref="sampleTable"
class="sample-table"
:class="{ stacked: samplesStacked }"
:class="{ stacked: samplesStacked, narrow: sampleNarrow }"
role="table"
aria-label="例子"
>
<div v-if="!samplesStacked" class="sample-row" role="row">
<span role="columnheader"></span>
<span v-if="!sampleNarrow" role="columnheader"></span>
<span role="columnheader" class="sample-label">输入</span>
<span role="columnheader" class="sample-label">输出</span>
</div>
@@ -437,6 +443,10 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
margin: 0;
}
.problem-content {
container: problem-content / inline-size;
}
.block-link {
margin-left: auto;
}
@@ -460,6 +470,13 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
gap: 16px;
}
/* 窄的时候输入、输出上下排:并排的话长一点的说明挤成一字两行的窄条,旁边一栏空着 */
@container problem-content (max-width: 480px) {
.io {
grid-template-columns: minmax(0, 1fr);
}
}
/* 语法要求:一行的浅绿框,硬性的判定条件,放在最前面 */
.requirements {
padding: 8px 12px;
@@ -584,6 +601,26 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
line-height: 1.5;
}
/* 窄:「例子 N」占满一行当小标题,下面输入、输出(并排或上下排) */
.narrow .sample-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.narrow.stacked .sample-row {
grid-template-columns: 32px minmax(0, 1fr);
}
.narrow .sample-no,
.narrow.stacked .sample-no {
grid-column: 1 / -1;
grid-row: auto;
padding-top: 4px;
}
.sample-table.narrow {
gap: 10px;
}
.sample-cell {
min-width: 0;
display: flex;
@@ -10,6 +10,8 @@ import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import { errorMarkExtensions } from "oj/problem/utils/errorMark"
import { editorFocusTracker } from "oj/problem/utils/symbolKeys"
import { hangWrapExtensions } from "shared/extensions/hangIndent"
import EditorToolbar from "./EditorToolbar.vue"
import { useDraftKey } from "../composables/draftKey"
import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
@@ -85,8 +87,11 @@ watch(
const { isDesktop } = useBreakpoints()
// 拼一次、留同一个数组:每次渲染新建会让编辑器反复重配扩展
const editorExtensions = [...editTraceExtensions, ...errorMarkExtensions]
// 拼一次、留同一个数组:每次渲染新建会让编辑器反复重配扩展。
// 手机上另加折行(20px 一行只放得下二十来个字,长行看不见右半截)
const desktopExtensions = [...editTraceExtensions, ...errorMarkExtensions, editorFocusTracker]
const mobileExtensions = [...desktopExtensions, ...hangWrapExtensions]
const editorExtensions = computed(() => (isDesktop.value ? desktopExtensions : mobileExtensions))
const contestID = route.params.contestID || null
// 草稿存在哪个键下(题单入口单独一份),和「重置代码」共用一份算法
@@ -223,6 +228,7 @@ onBeforeUnmount(() => flowchartStore.attachEditor(null))
:collab="ctx.collab"
:height="editorHeight"
:extra-extensions="editorExtensions"
:font-size="isDesktop ? 20 : 15"
@update:model-value="changeCode"
/>
</div>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useCodeStore } from "oj/store/code"
import { editorFocused, SYMBOL_KEYS, typeSymbol } from "../utils/symbolKeys"
/**
* 手机上贴着键盘顶的一排符号键,编辑器有焦点(键盘弹着)时才出来。
*
* 位置按 visualViewport 算:iOS 弹键盘时布局视口不变,position: fixed; bottom: 0 的东西
* 会被键盘盖住,只有可视视口的底边才是键盘的上沿。
* 按下时 preventDefault:不抢编辑器的焦点,键盘不收起来。
*/
const theme = useThemeVars()
const codeStore = useCodeStore()
const keys = computed(() => {
const language = codeStore.code.language
if (language === "SQL") return SYMBOL_KEYS.sql
if (language === "Python") return SYMBOL_KEYS.python
if (language === "C" || language === "C++") return SYMBOL_KEYS.c
return []
})
const BAR_HEIGHT = 44
const top = ref<number | null>(null)
function place() {
const viewport = window.visualViewport
top.value = viewport ? viewport.offsetTop + viewport.height - BAR_HEIGHT : null
}
useEventListener(() => window.visualViewport, "resize", place)
useEventListener(() => window.visualViewport, "scroll", place)
watch(editorFocused, (focused) => focused && place(), { immediate: true })
// 键盘弹着时点了别处换题、离开页面,编辑器直接卸载,不一定等得到失焦
onBeforeUnmount(() => {
editorFocused.value = false
})
</script>
<template>
<div
v-if="editorFocused && keys.length"
class="symbol-keys"
:style="top === null ? { bottom: '0px' } : { top: `${top}px` }"
role="toolbar"
aria-label="常用符号"
>
<button
v-for="key in keys"
:key="key.label"
type="button"
class="key"
:class="{ wide: key.label.length > 1 }"
@pointerdown.prevent
@mousedown.prevent
@click="typeSymbol(key)"
>
{{ key.label }}
</button>
</div>
</template>
<style scoped>
.symbol-keys {
position: fixed;
left: 0;
right: 0;
z-index: 20;
height: 44px;
box-sizing: border-box;
padding: 5px 8px;
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
background-color: v-bind("theme.bodyColor");
border-top: 1px solid rgba(128, 128, 128, 0.2);
}
.symbol-keys::-webkit-scrollbar {
display: none;
}
.key {
flex: none;
min-width: 36px;
height: 34px;
padding: 0 6px;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
font-family: Consolas, Monaco, monospace;
font-size: 17px;
color: inherit;
touch-action: manipulation;
}
.key.wide {
font-family: inherit;
font-size: 14px;
padding: 0 10px;
}
.key:active {
background-color: rgba(24, 160, 88, 0.15);
}
</style>
+2
View File
@@ -34,6 +34,7 @@ const ProblemContent = defineAsyncComponent(() => import("./components/ProblemCo
const ProblemDrawer = defineAsyncComponent(() => import("./components/ProblemDrawer.vue"))
const ResultPane = defineAsyncComponent(() => import("./components/ResultPane.vue"))
const MobileActionBar = defineAsyncComponent(() => import("./components/MobileActionBar.vue"))
const SymbolKeys = defineAsyncComponent(() => import("./components/SymbolKeys.vue"))
interface Props {
problemID: string
@@ -386,6 +387,7 @@ onBeforeUnmount(() => {
</n-tabs>
<ProblemDrawer v-model="drawer" />
<MobileActionBar />
<SymbolKeys />
</div>
</template>
<NotFound
@@ -107,6 +107,11 @@ const theme = EditorView.baseTheme({
export const errorMarkExtensions = [markField, punctuationField, tracker, theme]
/** 题目页主编辑器(没挂着就是 null)。手机上的符号键往它里面插字 */
export function currentEditorView(): EditorView | null {
return current
}
/** 标出这些中文标点(位置是整篇代码里的偏移,来自 findChinesePunctuation) */
export function showPunctuationMarks(fixes: PunctuationFix[]) {
current?.dispatch({ effects: setPunctuation.of(fixes) })
@@ -0,0 +1,75 @@
import { insertBracket } from "@codemirror/autocomplete"
import { EditorView } from "@codemirror/view"
import { ref } from "vue"
import { currentEditorView } from "./errorMark"
/**
* 手机上写代码的符号键(SymbolKeys.vue)。括号、引号、冒号在手机键盘上都要切到符号页,
* 一行 print(int(input())) 来回切七八次。
*/
/** 题目页主编辑器有没有焦点 —— 有焦点就是键盘弹着,这时才摆符号键 */
export const editorFocused = ref(false)
export const editorFocusTracker = EditorView.focusChangeEffect.of((_state, focusing) => {
editorFocused.value = focusing
return null
})
export interface SymbolKey {
label: string
/** 插进去的字;不写就是 label */
text?: string
}
const INDENT: SymbolKey = { label: "缩进", text: " " }
/** 按语言给一排:最常用的在前,放不下的往右滑 */
export const SYMBOL_KEYS: Record<"python" | "c" | "sql", SymbolKey[]> = {
python: [
INDENT,
...["(", ")", '"', ":", "=", "[", "]", ",", "'", "+", "-", "*", "/", "%", "<", ">", "#"].map(
(label) => ({ label }),
),
],
c: [
INDENT,
...[
"(",
")",
";",
'"',
"{",
"}",
"=",
"%",
"&",
"[",
"]",
",",
"+",
"-",
"*",
"/",
"<",
">",
].map((label) => ({ label })),
],
sql: ["(", ")", "'", ",", "*", "=", ";", "<", ">", "%"].map((label) => ({ label })),
}
/**
* 在光标处插一个键。括号、引号走 CodeMirror 自己的配对规则(和键盘上敲是一样的:
* 敲「(」补上「)」、光标前正好是「)」时敲「)」是跳过去),别的原样插。
*/
export function typeSymbol(key: SymbolKey) {
const view = currentEditorView()
if (!view) return
const text = key.text ?? key.label
const bracket = text.length === 1 ? insertBracket(view.state, text) : null
view.dispatch(
bracket ?? view.state.update(view.state.replaceSelection(text), { userEvent: "input.type" }),
)
view.dispatch({ effects: EditorView.scrollIntoView(view.state.selection.main.head) })
view.focus()
}
@@ -8,9 +8,9 @@ import {
} from "@codemirror/view"
/**
* 手机上代码折行。原来是横着滚,可没有滚动条也看不出右边还有,
* 综合练习那种长 print 整整半行看不见。折下来的那截缩到这一行的代码下面再进两格,
* 一眼看得出还是同一行。
* 手机上代码折行(自学的代码块和练一练、题目页的编辑器)。原来是横着滚,可没有滚动条
* 也看不出右边还有,自学综合练习那种长 print 整整半行看不见。折下来的那截缩到这一行的
* 代码下面再进两格,一眼看得出还是同一行。
*/
/** 行首空白占几格,tab 按 4 */
@@ -37,6 +37,8 @@ export function hangStyle(text: string): Record<string, string> {
}
}
const glue = Decoration.mark({ attributes: { style: "white-space: nowrap" } })
function hangDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>()
for (const { from, to } of view.visibleRanges) {
@@ -53,6 +55,12 @@ function hangDecorations(view: EditorView): DecorationSet {
},
}),
)
// 行首缩进后面紧跟一长串没空格的(printf("%d",a*100+b*10+c);),浏览器会在缩进后面折,
// 第一行只剩一截空白。把缩进和第一个字粘成不折的一段,长串就接在缩进后面再折
const indent = line.text.match(/^[ \t]+/)?.[0].length ?? 0
if (indent > 0 && indent < line.length) {
builder.add(line.from, line.from + indent + 1, glue)
}
pos = line.to + 1
}
}