refactor(后台富文本): 编辑器从 wangEditor 换成 Tiptap,老题面的图片尺寸、缩进、颜色打开再保存都不丢
Deploy / deploy (push) Canceled after 0s
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:
1 parent
a2dcafae14
commit
9e2217e09d
8 files changed
+874
-230
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user