feat(题目页): 手机上写代码顺手多了:键盘上面多一排常用符号(括号、引号、冒号、缩进…点一下就进去,括号自动配对),代码字号改小、长行折行不再看不见右半截;输入输出说明上下排、例子不再被左边两列标签挤窄;题单里那条不再超出屏幕
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user