refactor(后台富文本): 编辑器从 wangEditor 换成 Tiptap,老题面的图片尺寸、缩进、颜色打开再保存都不丢
Deploy / deploy (push) Canceled after 0s

拿库里全部 3384 条题面 / 提示 / 公告 / 比赛说明做过往返比对,补上图片 style、base64 图片、
段落缩进三处后文字和格式都对得上。工具栏照画布做成一行浅灰小图标,四套档位不变,
加了行内代码、光标在表格里出加删行列。表格格子里的段落在学生端不再把行撑高。
编辑器 JS 638 kB → 455 kB,wangEditor 的 39 kB 样式不用了;polyfill 清单重新审过,
去掉只有 wangEditor 用到的 web.immediate。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-20 20:20:13 +08:00
1 parent a2dcafae14
commit 9e2217e09d
8 files changed
+874 -230

No files matched your search

+4
View File
@@ -168,6 +168,10 @@ Naive 的日期选择器按浏览器本地时区渲染、没有 `timezone` 属
- `utils/functions.ts` — `parseTime` / `zonedParts` / `zonedYear`(东八区时间口径,见上)、
`duration`、压缩与剪贴板等杂项
- `utils/judge.ts` — Judge-related utilities
- `shared/richText.ts` — 后台富文本(`shared/components/TextEditor.vue`,Tiptap)的扩展配置。
库里是 wangEditor 时代的 HTML,Tiptap 认不出的东西打开再保存就悄悄没了 —— **动扩展之前
拿全量存量内容做一遍往返比对**(HTML → Tiptap → HTML,再过 markdown-it 比学生看到的),
图片尺寸、缩进那两个补丁就是这么比出来的
### Environment Variables
+8 -2
View File
@@ -22,10 +22,16 @@
"@codemirror/view": "^6.43.14",
"@lezer/highlight": "^1.2.5",
"@oj2/contract": "workspace:*",
"@tiptap/core": "3.32.0",
"@tiptap/extension-image": "3.32.0",
"@tiptap/extension-table": "3.32.0",
"@tiptap/extension-text-align": "3.32.0",
"@tiptap/extension-text-style": "3.32.0",
"@tiptap/pm": "3.32.0",
"@tiptap/starter-kit": "3.32.0",
"@tiptap/vue-3": "3.32.0",
"@vueuse/core": "^15.0.0",
"@vueuse/router": "^15.0.0",
"@wangeditor-next/editor": "^6.5.0",
"@wangeditor-next/editor-for-vue": "^6.5.0",
"canvas-confetti": "^1.9.4",
"chart.js": "^4.5.1",
"chartjs-chart-wordcloud": "^4.4.5",
+1 -14
View File
@@ -1530,10 +1530,7 @@ onMounted(async () => {
font-size: 13px;
}
/*
* align-items: start 不能省:wangEditor 给内容区写死了 height: 100%,格子把 .field 拉伸成
* 确定高度之后这个 100% 就按外框算,工具栏的高度被挤出去,编辑框盖到下面的知识点那行上
*/
/* 输入、输出两格各按自己的内容长高,不互相拉齐 */
.io {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1712,16 +1709,6 @@ onMounted(async () => {
grid-template-columns: minmax(0, 1fr);
}
/* 老题面里的图片带着写死的像素宽(有 1118px 的),把整页撑出横向滚动 */
.card :deep(.w-e-image-container) {
max-width: 100%;
height: auto !important;
}
.card :deep(.w-e-image-container img) {
height: auto !important;
}
.inlineRow {
flex-wrap: wrap;
}
@@ -103,6 +103,12 @@ onKeyStroke("Escape", () => {
margin: 0.25em 0;
}
/* 后台富文本(Tiptap)存的表格,格子里的字包在 <p> 里;不收掉边距,每行都被撑高一截 */
.oj-markdown td > p,
.oj-markdown th > p {
margin: 0;
}
.oj-markdown a {
color: v-bind("theme.primaryColor");
text-decoration: none;
+680 -102
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { IDomEditor, IEditorConfig, IToolbarConfig } from "@wangeditor-next/editor"
import { Editor, Toolbar } from "@wangeditor-next/editor-for-vue"
import "@wangeditor-next/editor/dist/css/style.css"
import { Icon } from "@iconify/vue"
import { EditorContent, useEditor } from "@tiptap/vue-3"
import { useThemeVars } from "naive-ui"
import { richTextExtensions, toEditorHtml } from "shared/richText"
import { uploadImage } from "../../admin/api"
interface Props {
@@ -15,148 +16,522 @@ interface Props {
}
const rawHtml = defineModel<string>("value")
type InsertFnType = (url: string, alt: string, href: string) => void
const { title, minHeight = 0, simple = false, compact = false, mini = false } = defineProps<Props>()
const message = useMessage()
const theme = useThemeVars()
const editorRef = shallowRef<IDomEditor>()
const toolbarEditorRef = shallowRef<IDomEditor>()
type Tool =
| "heading"
| "fontSize"
| "blockquote"
| "bold"
| "italic"
| "underline"
| "strike"
| "code"
| "color"
| "bgColor"
| "bulletList"
| "orderedList"
| "alignLeft"
| "alignCenter"
| "alignRight"
| "image"
| "emoji"
| "link"
| "table"
| "codeBlock"
| "hr"
| "clear"
| "undo"
| "redo"
| "|"
const toolbarConfig: Partial<IToolbarConfig> = {
toolbarKeys: [
"blockquote",
"headerSelect",
const TOOLBARS: Record<"full" | "simple" | "compact" | "mini", Tool[]> = {
full: [
"heading",
"fontSize",
"lineHeight",
"blockquote",
"|",
"bold",
"underline",
"italic",
"through",
"strike",
"code",
"color",
"bgColor",
"|",
"bulletedList",
"numberedList",
"justifyLeft",
"justifyCenter",
"justifyRight",
"bulletList",
"orderedList",
"alignLeft",
"alignCenter",
"alignRight",
"|",
"uploadImage",
"emotion",
"insertLink",
"insertTable",
"divider",
"image",
"emoji",
"link",
"table",
"codeBlock",
"hr",
"|",
"clearStyle",
"clear",
"undo",
"redo",
],
}
const toolbarConfigSimple: Partial<IToolbarConfig> = {
toolbarKeys: [
"bold",
"color",
"bgColor",
"emotion",
"uploadImage",
"insertLink",
"clearStyle",
"undo",
"redo",
],
}
const toolbarConfigCompact: Partial<IToolbarConfig> = {
toolbarKeys: [
simple: ["bold", "color", "bgColor", "emoji", "image", "link", "clear", "undo", "redo"],
compact: [
"bold",
"code",
"color",
"bgColor",
"|",
"bulletedList",
"numberedList",
"bulletList",
"orderedList",
"|",
"uploadImage",
"insertTable",
"insertLink",
"emotion",
"image",
"table",
"link",
"emoji",
"|",
"clearStyle",
"clear",
"undo",
"redo",
],
mini: ["bold", "color", "image", "link", "undo", "redo"],
}
const toolbarConfigMini: Partial<IToolbarConfig> = {
toolbarKeys: ["bold", "color", "uploadImage", "insertLink", "undo", "redo"],
}
const tools = computed(
() => TOOLBARS[mini ? "mini" : simple ? "simple" : compact ? "compact" : "full"],
)
const editorConfig: Partial<IEditorConfig> = {
scroll: false,
MENU_CONF: {
// @ts-ignore
uploadImage: { customUpload },
/** 最后一次交出去的值:v-model 回来的是它就不用再 setContent,不然光标会跳到开头 */
let lastEmitted: string | undefined
const editor = useEditor({
content: toEditorHtml(rawHtml.value ?? ""),
extensions: richTextExtensions,
editorProps: {
// 截图直接粘贴、拖进来的图片文件走上传;从网页复制的带 HTML,照常粘
handlePaste: (_view, event) => uploadFiles(event.clipboardData),
handleDrop: (_view, event) => uploadFiles((event as DragEvent).dataTransfer),
},
onUpdate({ editor }) {
// 空编辑器交空串,不交 `<p></p>`:调用方拿「空」判断必填
lastEmitted = editor.isEmpty ? "" : editor.getHTML()
rawHtml.value = lastEmitted
},
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor) editor.destroy()
})
function onClick() {
if (!editorRef.value) return
editorRef.value.blur()
editorRef.value.focus()
watch(rawHtml, (value) => {
if (!editor.value || value === lastEmitted) return
lastEmitted = value
editor.value.commands.setContent(toEditorHtml(value ?? ""), { emitUpdate: false })
})
/** 工具栏一律用它:保住编辑区的选区,点完按钮光标还在原处 */
function chain() {
return editor.value!.chain().focus()
}
async function handleCreated(editor: IDomEditor) {
editorRef.value = editor
await nextTick()
toolbarEditorRef.value = editor
function isActive(name: string, attributes?: Record<string, unknown>) {
return editor.value?.isActive(name, attributes) ?? false
}
async function customUpload(file: File, insertFn: InsertFnType) {
const path = await uploadImage(file)
function isAligned(align: "left" | "center" | "right") {
return editor.value?.isActive({ textAlign: align }) ?? false
}
function uploadFiles(data: DataTransfer | null | undefined) {
if (!data || data.getData("text/html")) return false
const images = Array.from(data.files).filter((file) => file.type.startsWith("image/"))
if (!images.length) return false
images.forEach(insertUploaded)
return true
}
async function insertUploaded(file: File) {
const path = await uploadImage(file).catch(() => "")
if (!path) {
message.error("图片上传失败")
return
}
const url = path
const alt = "图片"
const href = ""
insertFn(url, alt, href)
chain().setImage({ src: path, alt: "图片" }).run()
}
const fileInput = ref<HTMLInputElement>()
function onPickImage(event: Event) {
const input = event.target as HTMLInputElement
Array.from(input.files ?? []).forEach(insertUploaded)
input.value = ""
}
const HEADINGS = [
{ label: "正文", key: 0 },
{ label: "标题 1", key: 1 },
{ label: "标题 2", key: 2 },
{ label: "标题 3", key: 3 },
]
const headingLabel = computed(() => {
const level = [1, 2, 3].find((level) => isActive("heading", { level }))
return level ? `标题 ${level}` : "正文"
})
function onHeading(level: number) {
if (level === 0) chain().setParagraph().run()
else
chain()
.setHeading({ level: level as 1 | 2 | 3 })
.run()
}
const FONT_SIZES = ["12px", "14px", "16px", "18px", "20px", "24px", "28px", "32px"]
const fontSizeOptions = [
{ label: "默认", key: "default" },
...FONT_SIZES.map((size) => ({ label: size.replace("px", ""), key: size })),
]
const fontSizeLabel = computed(() => {
const size = editor.value?.getAttributes("textStyle").fontSize as string | undefined
return size ? size.replace("px", "") : "字号"
})
function onFontSize(size: string) {
if (size !== "default") chain().setFontSize(size).run()
else chain().unsetFontSize().run()
}
// 红是 wangEditor 的那个红:库里 163 处强调都是它
const COLORS = [
"#e13c39",
"#e67e22",
"#d4a017",
"#18a058",
"#2080f0",
"#8e44ad",
"#8a8f96",
"#1f2225",
]
const BG_COLORS = ["#f5db4d", "#fde2e1", "#ffe8cc", "#e7f5ed", "#e6f0fd", "#f0f0f3"]
const showColor = ref(false)
const showBgColor = ref(false)
const currentColor = computed(
() => editor.value?.getAttributes("textStyle").color as string | undefined,
)
function onColor(color: string | null) {
if (color) chain().setColor(color).run()
else chain().unsetColor().run()
showColor.value = false
}
function onBgColor(color: string | null) {
if (color) chain().setBackgroundColor(color).run()
else chain().unsetBackgroundColor().run()
showBgColor.value = false
}
// 机房没有彩色 emoji 字体,靠站点自带的 Noto 兜底;数字键帽、↔️ 那批兜不住,别往这里加
const EMOJIS = [..."😀😄😂🤣😊😍😎🤔😅😭😡😱👍👎👏🙏💪👉👆👇✅❌❗❓💡📌📝🎯🔥⭐🎉🏆🚀💻📚⏰"]
const showEmoji = ref(false)
function onEmoji(emoji: string) {
chain().insertContent(emoji).run()
showEmoji.value = false
}
const showLink = ref(false)
const linkUrl = ref("")
watch(showLink, (show) => {
if (show) linkUrl.value = (editor.value?.getAttributes("link").href as string) ?? ""
})
function applyLink() {
const url = linkUrl.value.trim()
showLink.value = false
if (!url) {
chain().extendMarkRange("link").unsetLink().run()
} else if (editor.value!.state.selection.empty && !isActive("link")) {
// 没选字就把网址本身插进去当链接文字
chain()
.insertContent({ type: "text", text: url, marks: [{ type: "link", attrs: { href: url } }] })
.run()
} else {
chain().extendMarkRange("link").setLink({ href: url }).run()
}
}
interface Button {
icon: string
label: string
run: () => void
active?: () => boolean
}
const BUTTONS: Partial<Record<Tool, Button>> = {
blockquote: {
icon: "ph:quotes",
label: "引用",
run: () => chain().toggleBlockquote().run(),
active: () => isActive("blockquote"),
},
bold: {
icon: "ph:text-b-bold",
label: "加粗",
run: () => chain().toggleBold().run(),
active: () => isActive("bold"),
},
italic: {
icon: "ph:text-italic",
label: "斜体",
run: () => chain().toggleItalic().run(),
active: () => isActive("italic"),
},
underline: {
icon: "ph:text-underline",
label: "下划线",
run: () => chain().toggleUnderline().run(),
active: () => isActive("underline"),
},
strike: {
icon: "ph:text-strikethrough",
label: "删除线",
run: () => chain().toggleStrike().run(),
active: () => isActive("strike"),
},
code: {
icon: "ph:code",
label: "行内代码",
run: () => chain().toggleCode().run(),
active: () => isActive("code"),
},
bulletList: {
icon: "ph:list-bullets",
label: "无序列表",
run: () => chain().toggleBulletList().run(),
active: () => isActive("bulletList"),
},
orderedList: {
icon: "ph:list-numbers",
label: "有序列表",
run: () => chain().toggleOrderedList().run(),
active: () => isActive("orderedList"),
},
alignLeft: {
icon: "ph:text-align-left",
label: "靠左",
run: () => chain().setTextAlign("left").run(),
active: () => isAligned("left"),
},
alignCenter: {
icon: "ph:text-align-center",
label: "居中",
run: () => chain().setTextAlign("center").run(),
active: () => isAligned("center"),
},
alignRight: {
icon: "ph:text-align-right",
label: "靠右",
run: () => chain().setTextAlign("right").run(),
active: () => isAligned("right"),
},
image: { icon: "ph:image", label: "插入图片", run: () => fileInput.value?.click() },
table: {
icon: "ph:table",
label: "插入表格",
run: () => chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
},
codeBlock: {
icon: "ph:code-block",
label: "代码块",
run: () => chain().toggleCodeBlock().run(),
active: () => isActive("codeBlock"),
},
hr: { icon: "ph:minus", label: "分割线", run: () => chain().setHorizontalRule().run() },
clear: {
icon: "ph:eraser",
label: "清除格式",
run: () => chain().unsetAllMarks().clearNodes().run(),
},
undo: { icon: "ph:arrow-counter-clockwise", label: "撤销", run: () => chain().undo().run() },
redo: { icon: "ph:arrow-clockwise", label: "重做", run: () => chain().redo().run() },
}
/** 光标在表格里才出来的那几个 */
const TABLE_BUTTONS: Button[] = [
{ icon: "ph:rows-plus-bottom", label: "下面加一行", run: () => chain().addRowAfter().run() },
{
icon: "ph:columns-plus-right",
label: "右边加一列",
run: () => chain().addColumnAfter().run(),
},
{ icon: "ph:rows", label: "删掉这一行", run: () => chain().deleteRow().run() },
{ icon: "ph:columns", label: "删掉这一列", run: () => chain().deleteColumn().run() },
{ icon: "ph:trash", label: "删掉表格", run: () => chain().deleteTable().run() },
]
const items = computed(() => tools.value.map((tool) => ({ tool, button: BUTTONS[tool] })))
const inTable = computed(() => isActive("table"))
</script>
<template>
<div class="title" v-if="title">{{ title }}</div>
<div class="editorWrapper">
<Toolbar
class="toolbar"
:editor="toolbarEditorRef"
:defaultConfig="
mini
? toolbarConfigMini
: simple
? toolbarConfigSimple
: compact
? toolbarConfigCompact
: toolbarConfig
"
mode="simple"
/>
<Editor
@click="onClick"
:style="{ minHeight: minHeight + 'px' }"
v-model="rawHtml"
:defaultConfig="editorConfig"
mode="simple"
@onCreated="handleCreated"
<div class="rte">
<div class="bar" v-if="editor">
<template v-for="({ tool, button }, index) in items" :key="index">
<span v-if="tool === '|'" class="sep" />
<button
v-else-if="button"
type="button"
class="tb"
:class="{ on: button.active?.() }"
:title="button.label"
@mousedown.prevent
@click="button.run()"
>
<Icon :icon="button.icon" />
</button>
<n-dropdown
v-else-if="tool === 'heading'"
trigger="click"
:options="HEADINGS"
@select="onHeading"
>
<button type="button" class="tb text" title="段落格式" @mousedown.prevent>
{{ headingLabel }}<Icon icon="ph:caret-down" width="10" />
</button>
</n-dropdown>
<n-dropdown
v-else-if="tool === 'fontSize'"
trigger="click"
:options="fontSizeOptions"
@select="onFontSize"
>
<button type="button" class="tb text" title="字号" @mousedown.prevent>
{{ fontSizeLabel }}<Icon icon="ph:caret-down" width="10" />
</button>
</n-dropdown>
<n-popover v-else-if="tool === 'color'" v-model:show="showColor" trigger="click">
<template #trigger>
<button type="button" class="tb" title="文字颜色" @mousedown.prevent>
<Icon icon="ph:text-a-underline" :style="{ color: currentColor }" />
</button>
</template>
<div class="swatches">
<button
v-for="color in COLORS"
:key="color"
type="button"
class="swatch"
:style="{ background: color }"
@mousedown.prevent
@click="onColor(color)"
/>
</div>
<n-button size="tiny" quaternary block @mousedown.prevent @click="onColor(null)">
去掉颜色
</n-button>
</n-popover>
<n-popover v-else-if="tool === 'bgColor'" v-model:show="showBgColor" trigger="click">
<template #trigger>
<button type="button" class="tb" title="背景色" @mousedown.prevent>
<Icon icon="ph:highlighter" />
</button>
</template>
<div class="swatches">
<button
v-for="color in BG_COLORS"
:key="color"
type="button"
class="swatch"
:style="{ background: color }"
@mousedown.prevent
@click="onBgColor(color)"
/>
</div>
<n-button size="tiny" quaternary block @mousedown.prevent @click="onBgColor(null)">
去掉背景色
</n-button>
</n-popover>
<n-popover v-else-if="tool === 'emoji'" v-model:show="showEmoji" trigger="click">
<template #trigger>
<button type="button" class="tb" title="表情" @mousedown.prevent>
<Icon icon="ph:smiley" />
</button>
</template>
<div class="emojis">
<button
v-for="emoji in EMOJIS"
:key="emoji"
type="button"
class="emoji"
@mousedown.prevent
@click="onEmoji(emoji)"
>
{{ emoji }}
</button>
</div>
</n-popover>
<n-popover v-else-if="tool === 'link'" v-model:show="showLink" trigger="click">
<template #trigger>
<button
type="button"
class="tb"
:class="{ on: isActive('link') }"
title="链接"
@mousedown.prevent
>
<Icon icon="ph:link" />
</button>
</template>
<n-flex :size="6" :wrap="false" style="width: 280px">
<n-input
v-model:value="linkUrl"
size="small"
placeholder="https://"
@keydown.enter.prevent="applyLink"
/>
<n-button size="small" type="primary" @click="applyLink">确定</n-button>
</n-flex>
</n-popover>
</template>
<template v-if="inTable">
<span class="sep" />
<button
v-for="button in TABLE_BUTTONS"
:key="button.label"
type="button"
class="tb"
:title="button.label"
@mousedown.prevent
@click="button.run()"
>
<Icon :icon="button.icon" />
</button>
</template>
</div>
<EditorContent
:editor="editor"
class="content"
:style="{ '--min-height': minHeight + 'px' }"
@click.self="editor?.commands.focus('end')"
/>
<input ref="fileInput" type="file" accept="image/*" multiple hidden @change="onPickImage" />
</div>
</template>
@@ -165,12 +540,215 @@ async function customUpload(file: File, insertFn: InsertFnType) {
margin-bottom: 12px;
}
.toolbar {
border-bottom: 1px solid #ddd;
.rte {
border: 1px solid v-bind("theme.borderColor");
border-radius: 4px;
margin-bottom: 20px;
background: v-bind("theme.cardColor");
}
.editorWrapper {
border: 1px solid #ddd;
margin-bottom: 20px;
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
padding: 3px 6px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.tb {
height: 24px;
min-width: 24px;
padding: 0 4px;
border: 0;
border-radius: 3px;
background: none;
color: v-bind("theme.textColor3");
font: inherit;
font-size: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
cursor: pointer;
}
.tb svg {
width: 16px;
height: 16px;
}
.tb.text svg {
width: 10px;
height: 10px;
}
.tb:hover {
background: v-bind("theme.hoverColor");
color: v-bind("theme.textColor1");
}
.tb.on {
color: v-bind("theme.primaryColor");
background: v-bind("theme.primaryColorSuppl + '1f'");
}
.sep {
width: 1px;
height: 14px;
margin: 0 4px;
background: v-bind("theme.dividerColor");
}
.swatches {
display: grid;
grid-template-columns: repeat(4, 22px);
gap: 6px;
margin-bottom: 6px;
}
.swatch {
width: 22px;
height: 22px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 3px;
cursor: pointer;
}
.emojis {
display: grid;
grid-template-columns: repeat(8, 28px);
gap: 2px;
}
.emoji {
width: 28px;
height: 28px;
border: 0;
border-radius: 3px;
background: none;
font-size: 18px;
cursor: pointer;
}
.emoji:hover {
background: v-bind("theme.hoverColor");
}
/* 正文样式往学生端 .oj-markdown 靠,写的时候看到的和学生看到的差不多 */
.content {
padding: 8px 10px;
font-size: 13px;
line-height: 1.7;
cursor: text;
}
.content :deep(.ProseMirror) {
min-height: var(--min-height);
outline: none;
overflow-wrap: break-word;
}
.content :deep(.ProseMirror > :first-child) {
margin-top: 0;
}
.content :deep(.ProseMirror > :last-child) {
margin-bottom: 0;
}
.content :deep(p) {
margin: 0.4em 0;
}
.content :deep(h1),
.content :deep(h2),
.content :deep(h3),
.content :deep(h4) {
margin: 0.8em 0 0.4em;
line-height: 1.4;
}
.content :deep(ul),
.content :deep(ol) {
padding-left: 1.6em;
margin: 0.4em 0;
}
.content :deep(li > p),
.content :deep(td > p),
.content :deep(th > p) {
margin: 0;
}
.content :deep(a) {
color: v-bind("theme.primaryColor");
}
/* 老题面里有写死 1118px 宽的图,别把编辑框撑出横向滚动 */
.content :deep(img) {
max-width: 100%;
height: auto !important;
vertical-align: middle;
}
.content :deep(.ProseMirror-selectednode) {
outline: 2px solid v-bind("theme.primaryColor");
}
.content :deep(blockquote) {
margin: 0.4em 0;
padding-left: 10px;
border-left: 3px solid v-bind("theme.dividerColor");
color: v-bind("theme.textColor3");
}
.content :deep(code) {
padding: 1px 4px;
border-radius: 3px;
background: v-bind("theme.codeColor");
font-family: v-bind("theme.fontFamilyMono");
font-size: 0.92em;
}
.content :deep(pre) {
margin: 0.4em 0;
padding: 8px 10px;
border-radius: 4px;
background: v-bind("theme.codeColor");
overflow-x: auto;
}
.content :deep(pre code) {
padding: 0;
background: none;
}
.content :deep(hr) {
border: 0;
border-top: 1px solid v-bind("theme.dividerColor");
margin: 0.8em 0;
}
.content :deep(table) {
border-collapse: collapse;
margin: 0.4em 0;
}
.content :deep(td),
.content :deep(th) {
min-width: 40px;
padding: 4px 8px;
border: 1px solid v-bind("theme.dividerColor");
vertical-align: top;
}
.content :deep(th) {
font-weight: 600;
background: rgba(128, 128, 128, 0.08);
}
.content :deep(.selectedCell) {
background: v-bind("theme.primaryColorSuppl + '1f'");
}
</style>
+66
View File
@@ -0,0 +1,66 @@
import { Extension } from "@tiptap/core"
import { Image } from "@tiptap/extension-image"
import { TableKit } from "@tiptap/extension-table"
import { TextAlign } from "@tiptap/extension-text-align"
import { TextStyleKit } from "@tiptap/extension-text-style"
import { StarterKit } from "@tiptap/starter-kit"
/**
* 后台富文本(题面、提示、公告、比赛说明)的 Tiptap 配置。
*
* 库里存的是 wangEditor 时代写下的 HTML,Tiptap 只认 schema 里有的东西,认不出的
* 打开再保存就**悄悄没了**。换编辑器前拿全量 3384 条做过往返比对(HTML → Tiptap → HTML,
* 再过一遍 markdown-it 比学生看到的),下面两个补丁就是比出来的:不补的话 232 条的
* 图片尺寸、28 条的缩进会丢。base64 内嵌图片(12 条)靠 `allowBase64`。
* 改这里的扩展之前,照那次的办法重新比一遍。
*/
/** 图片的宽高大多写在 style 里(`width: 384px; height: 435px`,也有百分比),原样留着 */
const StyledImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
style: {
default: null,
parseHTML: (element) => element.getAttribute("style"),
renderHTML: (attributes) => (attributes.style ? { style: attributes.style } : {}),
},
}
},
})
/** 段落的首行缩进、左边距。老题面里有 `text-indent: 28pt`、`padding-left: 40px` 这种 */
const Indent = Extension.create({
name: "indent",
addGlobalAttributes() {
return (["text-indent", "padding-left", "margin-left"] as const).map((property) => ({
types: ["paragraph", "heading"],
attributes: {
[property]: {
default: null,
parseHTML: (element: HTMLElement) => element.style.getPropertyValue(property) || null,
renderHTML: (attributes: Record<string, string | null>) =>
attributes[property] ? { style: `${property}: ${attributes[property]}` } : {},
},
},
}))
},
})
export const richTextExtensions = [
StarterKit.configure({ link: { openOnClick: false } }),
TextStyleKit,
TextAlign.configure({ types: ["heading", "paragraph"] }),
TableKit,
StyledImage.configure({ inline: true, allowBase64: true }),
Indent,
]
/**
* 交给编辑器之前的整理。没有标签的纯文本(库里 125 条)原样喂进去,换行会被当成空白
* 吞掉 —— 学生端是 markdown-it(breaks: true)渲染的,原来那些换行是真的会换行
*/
export function toEditorHtml(value: string) {
if (value.includes("<")) return value
return value.replace(/&/g, "&amp;").replace(/\n/g, "<br>")
}
+1 -10
View File
@@ -9,7 +9,7 @@ import { NaiveUiResolver } from "unplugin-vue-components/resolvers"
// 但比 vite 8 的默认构建 target(chrome111)低。所以这个插件必须留着:它同时管两件事
// —— 把 build.target 压到 chrome105/es2020,再给现代产物补 core-js 的 API polyfill。
//
// 下面这份清单是 `modernPolyfills: true` 自动探测出来的 50 项,抄下来写死:自动探测要
// 下面这份清单是 `modernPolyfills: true` 自动探测出来的 49 项,抄下来写死:自动探测要
// 对每个产物跑一遍 Babel 扫描,构建从 3 秒涨到 12 秒。**升级前端依赖后重新审计一次**:
// 临时把下面的 modernPolyfills 改成 true,再 DEBUG=vite:legacy bun run build
// (写死清单时它只回显这份清单,不会重新探测)
@@ -60,7 +60,6 @@ const polyfills = [
"es.weak-map.get-or-insert-computed",
"esnext.array.group",
"web.dom-exception.stack",
"web.immediate",
"web.url-search-params.delete",
"web.url-search-params.has",
"web.url-search-params.size",
@@ -126,14 +125,6 @@ export default defineConfig(() => {
// 这里直接读它。tsconfig.json 是 references 空壳也没关系,会顺着找过去(实测)
tsconfigPaths: true,
},
build: {
// 超过 500 kB 的 chunk 只有懒加载的 wangeditor(后台富文本,TextEditor-*.js),
// 640 kB 上下。默认阈值下这条警告每次构建都在报,真涨了反而没人看,所以抬到比它
// 略高。首屏入口 index-*.js 在 175 kB 左右。
// (原来还有 Python 语法检查用的 skulpt、mermaid 的 @mermaid-js/parser,前者挪到
// 服务端的 CPython、后者换成 pele 之后都没了)
chunkSizeWarningLimit: 1000,
},
server: {
port: 5173,
proxy: {