hulai-website/components/admin/RichTextEditor.vue

162 行
6.2 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<div class="rich-editor">
<div v-if="editor" class="rich-editor-toolbar">
<button type="button" :class="btn(editor.isActive('heading', { level: 2 }))" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" title="大标题">H2</button>
<button type="button" :class="btn(editor.isActive('heading', { level: 3 }))" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" title="小标题">H3</button>
<button type="button" :class="btn(editor.isActive('paragraph'))" @click="editor.chain().focus().setParagraph().run()" title="正文"></button>
<span class="divider" />
<button type="button" :class="btn(editor.isActive('bold'))" @click="editor.chain().focus().toggleBold().run()" title="加粗"><b>B</b></button>
<button type="button" :class="btn(editor.isActive('italic'))" @click="editor.chain().focus().toggleItalic().run()" title="斜体"><i>I</i></button>
<button type="button" :class="btn(editor.isActive('strike'))" @click="editor.chain().focus().toggleStrike().run()" title="删除线"><s>S</s></button>
<span class="divider" />
<button type="button" :class="btn(editor.isActive('bulletList'))" @click="editor.chain().focus().toggleBulletList().run()" title="无序列表"> 列表</button>
<button type="button" :class="btn(editor.isActive('orderedList'))" @click="editor.chain().focus().toggleOrderedList().run()" title="有序列表">1. 列表</button>
<button type="button" :class="btn(editor.isActive('blockquote'))" @click="editor.chain().focus().toggleBlockquote().run()" title="引用">" 引用</button>
<span class="divider" />
<button type="button" :class="btn(editor.isActive('link'))" @click="setLink" title="链接">🔗 链接</button>
<button type="button" class="toolbar-btn" @click="addImage" title="插入图片">🖼 图片</button>
<span class="divider" />
<button type="button" class="toolbar-btn" @click="editor.chain().focus().undo().run()" title="撤销">↶</button>
<button type="button" class="toolbar-btn" @click="editor.chain().focus().redo().run()" title="重做">↷</button>
<span class="divider" />
<button type="button" class="toolbar-btn" :class="{ active: showHtml }" @click="showHtml = !showHtml" title="源代码视图">&lt;/&gt; HTML</button>
</div>
<EditorContent v-show="!showHtml" :editor="editor" class="rich-editor-body" />
<textarea
v-show="showHtml"
:value="modelValue"
class="rich-editor-html"
rows="20"
@input="$emit('update:modelValue', $event.target.value); editor?.commands.setContent($event.target.value, false)"
/>
</div>
</template>
<script setup>
import { EditorContent, useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
import { watch, ref, onBeforeUnmount } from 'vue'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '开始写作...' },
})
const emit = defineEmits(['update:modelValue'])
const showHtml = ref(false)
const editor = useEditor({
content: props.modelValue,
extensions: [
StarterKit,
Link.configure({ openOnClick: false, HTMLAttributes: { rel: 'noopener', target: '_blank' } }),
Image,
],
onUpdate: ({ editor }) => {
emit('update:modelValue', editor.getHTML())
},
})
watch(() => props.modelValue, (val) => {
if (!editor.value) return
const current = editor.value.getHTML()
if (val !== current) editor.value.commands.setContent(val || '', false)
})
onBeforeUnmount(() => editor.value?.destroy())
function btn(active) { return { 'toolbar-btn': true, active } }
function setLink() {
const prev = editor.value.getAttributes('link').href
const url = window.prompt('链接 URL', prev || 'https://')
if (url === null) return
if (url === '') { editor.value.chain().focus().extendMarkRange('link').unsetLink().run(); return }
editor.value.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
function addImage() {
const url = window.prompt('图片 URL可填 /uploads/ 或 /images/ 路径)')
if (url) editor.value.chain().focus().setImage({ src: url }).run()
}
</script>
<style scoped>
.rich-editor {
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
}
.rich-editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px;
border-bottom: 1px solid #e5e7eb;
background: #fafafa;
border-radius: 4px 4px 0 0;
}
.toolbar-btn {
padding: 4px 10px;
font-size: 13px;
border: 1px solid #e5e7eb;
background: #fff;
border-radius: 4px;
cursor: pointer;
color: #374151;
transition: all 0.15s;
}
.toolbar-btn:hover { background: #f3f4f6; border-color: #3a7d44; }
.toolbar-btn.active { background: #3a7d44; color: #fff; border-color: #3a7d44; }
.divider {
width: 1px;
background: #e5e7eb;
margin: 2px 4px;
}
.rich-editor-body {
padding: 12px 14px;
min-height: 400px;
font-size: 14px;
line-height: 1.7;
color: #111827;
}
.rich-editor-body :deep(.ProseMirror) { outline: none; min-height: 380px; }
.rich-editor-body :deep(.ProseMirror:focus) { outline: none; }
.rich-editor-body :deep(h2) { font-size: 20px; font-weight: 600; margin: 18px 0 10px; color: #111827; }
.rich-editor-body :deep(h3) { font-size: 16px; font-weight: 600; margin: 14px 0 8px; color: #111827; }
.rich-editor-body :deep(p) { margin: 8px 0; }
.rich-editor-body :deep(ul), .rich-editor-body :deep(ol) { padding-left: 24px; margin: 8px 0; }
.rich-editor-body :deep(li) { margin: 4px 0; }
.rich-editor-body :deep(blockquote) {
border-left: 3px solid #3a7d44;
padding-left: 12px;
color: #6b7280;
margin: 12px 0;
}
.rich-editor-body :deep(a) { color: #3a7d44; text-decoration: underline; }
.rich-editor-body :deep(img) { max-width: 100%; height: auto; border-radius: 4px; }
.rich-editor-body :deep(strong) { font-weight: 600; }
.rich-editor-html {
width: 100%;
padding: 12px 14px;
border: none;
outline: none;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.6;
resize: vertical;
min-height: 400px;
background: #1f2937;
color: #d1fae5;
border-radius: 0 0 4px 4px;
}
</style>