AI-News/frontend/app/components/RichEditor.vue
2025-12-04 10:04:21 +08:00

393 lines
12 KiB
Vue

<template>
<ClientOnly>
<div class="re-container">
<div class="re-toolbar">
<button
v-for="btn in leftButtons"
:key="btn.key"
:title="btn.tip"
class="re-btn"
:class="{ active: btn.isActive?.() }"
@click="btn.action"
>
{{ btn.text }}
</button>
<div class="re-split" />
<button
v-for="btn in midButtons"
:key="btn.key"
:title="btn.tip"
class="re-btn"
:class="{ active: btn.isActive?.() }"
@click="btn.action"
>
{{ btn.text }}
</button>
<div class="re-split" />
<button
v-for="btn in rightButtons"
:key="btn.key"
:title="btn.tip"
class="re-btn"
:class="{ active: btn.isActive?.() }"
@click="btn.action"
>
{{ btn.text }}
</button>
<div class="re-split" />
<div class="re-group">
<span class="re-label">字色</span>
<button
v-for="c in textColors"
:key="'tc-' + c"
class="re-color"
:style="{ background: c }"
@click="setTextColor(c)"
:title="c"
/>
<button class="re-btn" @click="setTextColor(null)">清除</button>
</div>
<div class="re-group">
<span class="re-label">高亮</span>
<button
v-for="c in hlColors"
:key="'hl-' + c"
class="re-color"
:style="{ background: c }"
@click="setHighlight(c)"
:title="c"
/>
<button class="re-btn" @click="setHighlight(null)">清除</button>
</div>
<div class="re-split" />
<div class="re-group">
<span class="re-label">图片</span>
<button class="re-btn" @click="imgSetWidth('25%')">25%</button>
<button class="re-btn" @click="imgSetWidth('50%')">50%</button>
<button class="re-btn" @click="imgSetWidth('75%')">75%</button>
<button class="re-btn" @click="imgSetWidth('100%')">100%</button>
<div class="re-px">
<input
type="number"
min="40"
max="4096"
placeholder="px"
@keydown.stop
@change="e => imgSetWidth(e.target.value ? `${e.target.value}px` : null)"
/>
<span>px</span>
</div>
<div class="re-range" title="10% - 100%">
<input type="range" min="10" max="100" step="1" @input="e => imgSetWidth(`${e.target.value}%`)" />
</div>
<button class="re-btn" @click="imgSetWidth(null)">自适应</button>
</div>
<div class="flex-1" />
<label class="re-upload">
<input type="file" accept="image/*" @change="onPickImage" hidden />
插入图片
</label>
</div>
<div class="re-editor" :class="{ 're-readonly': readonly }">
<EditorContent :editor="editor" />
</div>
</div>
</ClientOnly>
</template>
<script setup>
import { onBeforeUnmount, onMounted, watch } from 'vue'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { StarterKit } from '@tiptap/starter-kit'
import { Underline } from '@tiptap/extension-underline'
import { Link } from '@tiptap/extension-link'
import { Placeholder } from '@tiptap/extension-placeholder'
import { TextAlign } from '@tiptap/extension-text-align'
import { TextStyle } from '@tiptap/extension-text-style'
import { Color } from '@tiptap/extension-color'
import { Highlight } from '@tiptap/extension-highlight'
import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableCell } from '@tiptap/extension-table-cell'
import { Image } from '@tiptap/extension-image'
import { useUpload } from '@/composables/useUpload'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '请输入正文,支持粘贴图片、截图、链接等…' },
readonly: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
const { uploadImage } = useUpload()
let editor = null
onMounted(() => {
editor = new Editor({
content: props.modelValue || '',
editable: !props.readonly,
extensions: [
StarterKit.configure({
link: false,
underline: false,
bulletList: { keepMarks: true },
orderedList: { keepMarks: true },
}),
Underline,
Highlight,
Link.configure({
autolink: true,
openOnClick: true,
linkOnPaste: true,
HTMLAttributes: { rel: 'nofollow', target: '_blank' },
}),
Image.configure({ allowBase64: true }),
Placeholder.configure({ placeholder: props.placeholder }),
TextStyle,
Color,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
onUpdate: ({ editor }) => {
const html = editor.getHTML()
emit('update:modelValue', html)
emit('change', html)
},
editorProps: {
handlePaste(view, event) {
const items = event.clipboardData?.items || []
for (const it of items) {
if (it.type?.startsWith('image/')) {
const file = it.getAsFile()
if (file) {
event.preventDefault()
insertImageFile(file)
return true
}
}
}
return false
},
handleDrop(view, event) {
const file = event.dataTransfer?.files?.[0]
if (file && file.type.startsWith('image/')) {
event.preventDefault()
insertImageFile(file)
return true
}
return false
},
},
})
})
watch(
() => props.readonly,
value => editor?.setEditable(!value),
)
onBeforeUnmount(() => editor?.destroy())
watch(
() => props.modelValue,
(val) => {
if (!editor) return
const current = editor.getHTML()
if (val != null && val !== current) {
editor.commands.setContent(val, false)
}
},
)
async function insertImageFile(file) {
try {
const url = await uploadImage(file)
editor.chain().focus().setImage({ src: url, alt: file.name }).run()
} catch (e) {
console.warn('[RichEditor] image insert failed:', e)
}
}
const textColors = ['#111827', '#1f2937', '#374151', '#4b5563', '#6b7280', '#ef4444', '#f97316', '#f59e0b', '#10b981', '#3b82f6', '#6366f1', '#8b5cf6', '#ec4899']
const hlColors = ['#fff7ed', '#fef3c7', '#fef9c3', '#e0f2fe', '#dcfce7', '#e9d5ff', '#ffe4e6']
function setTextColor(c) {
if (!c) return editor?.chain().focus().unsetColor().run()
editor?.chain().focus().setColor(c).run()
}
function setHighlight(c) {
if (!c) return editor?.chain().focus().unsetHighlight().run()
editor?.chain().focus().toggleHighlight({ color: c }).run()
}
function imgSetWidth(width) {
const isImageSel = editor?.isActive('image') || editor?.state?.selection?.node?.type?.name === 'image'
if (!isImageSel) return
editor?.chain().focus().updateAttributes('image', { width: width || null }).run()
}
const leftButtons = [
{ key: 'undo', text: '↶', tip: '撤销', action: () => editor?.chain().focus().undo().run() },
{ key: 'redo', text: '↷', tip: '重做', action: () => editor?.chain().focus().redo().run() },
{ key: 'hr', text: '—', tip: '分割线', action: () => editor?.chain().focus().setHorizontalRule().run() },
]
const midButtons = [
{ key: 'bold', text: 'B', tip: '加粗', action: () => editor?.chain().focus().toggleBold().run(), isActive: () => editor?.isActive('bold') },
{ key: 'italic', text: 'I', tip: '斜体', action: () => editor?.chain().focus().toggleItalic().run(), isActive: () => editor?.isActive('italic') },
{ key: 'underline', text: 'U', tip: '下划线', action: () => editor?.chain().focus().toggleUnderline().run(), isActive: () => editor?.isActive('underline') },
{ key: 'strike', text: 'S', tip: '删除线', action: () => editor?.chain().focus().toggleStrike().run(), isActive: () => editor?.isActive('strike') },
{ key: 'highlight', text: 'HL', tip: '高亮', action: () => editor?.chain().focus().toggleHighlight().run(), isActive: () => editor?.isActive('highlight') },
{ key: 'h2', text: 'H2', tip: '标题 2', action: () => editor?.chain().focus().toggleHeading({ level: 2 }).run(), isActive: () => editor?.isActive('heading', { level: 2 }) },
{ key: 'h3', text: 'H3', tip: '标题 3', action: () => editor?.chain().focus().toggleHeading({ level: 3 }).run(), isActive: () => editor?.isActive('heading', { level: 3 }) },
{ key: 'ul', text: '• 列表', tip: '无序列表', action: () => editor?.chain().focus().toggleBulletList().run(), isActive: () => editor?.isActive('bulletList') },
{ key: 'ol', text: '1. 列表', tip: '有序列表', action: () => editor?.chain().focus().toggleOrderedList().run(), isActive: () => editor?.isActive('orderedList') },
{ key: 'quote', text: '❝ ❞', tip: '引用', action: () => editor?.chain().focus().toggleBlockquote().run(), isActive: () => editor?.isActive('blockquote') },
{ key: 'code', text: '</>', tip: '代码块', action: () => editor?.chain().focus().toggleCodeBlock().run(), isActive: () => editor?.isActive('codeBlock') },
]
const rightButtons = [
{ key: 'left', text: '⟸', tip: '左对齐', action: () => editor?.chain().focus().setTextAlign('left').run(), isActive: () => editor?.isActive({ textAlign: 'left' }) },
{ key: 'center', text: '⇔', tip: '居中', action: () => editor?.chain().focus().setTextAlign('center').run(), isActive: () => editor?.isActive({ textAlign: 'center' }) },
{ key: 'right', text: '⟹', tip: '右对齐', action: () => editor?.chain().focus().setTextAlign('right').run(), isActive: () => editor?.isActive({ textAlign: 'right' }) },
{ key: 'justify', text: '≋', tip: '两端对齐', action: () => editor?.chain().focus().setTextAlign('justify').run(), isActive: () => editor?.isActive({ textAlign: 'justify' }) },
]
function onPickImage(e) {
const file = e.target.files?.[0]
if (file) insertImageFile(file)
e.target.value = ''
}
</script>
<style scoped>
.re-container {
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
}
.re-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid #eef1f5;
flex-wrap: wrap;
}
.re-btn {
font-size: 12px;
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
}
.re-btn.active {
background: #eef2ff;
border-color: #c7d2fe;
}
.re-split {
width: 1px;
height: 20px;
background: #e5e7eb;
margin: 0 6px;
}
.re-group {
display: flex;
align-items: center;
gap: 6px;
}
.re-label {
font-size: 12px;
color: #64748b;
margin-right: 2px;
}
.re-color {
width: 18px;
height: 18px;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.re-upload {
font-size: 12px;
padding: 6px 10px;
border: 1px dashed #d1d5db;
border-radius: 8px;
cursor: pointer;
color: #374151;
background: #fafafa;
}
.re-editor {
padding: 14px;
}
.re-readonly {
background: #f9fafb;
}
.re-editor :deep(.ProseMirror) {
min-height: 360px;
outline: none;
line-height: 1.75;
font-size: 14px;
}
.re-editor :deep(h2) {
font-size: 1.25rem;
margin: 1em 0 0.5em;
}
.re-editor :deep(h3) {
font-size: 1.125rem;
margin: 0.9em 0 0.4em;
}
.re-editor :deep(blockquote) {
border-left: 3px solid #a5b4fc;
padding: 0.25rem 0.75rem;
color: #4b5563;
background: #f9fafb;
}
.re-editor :deep(pre) {
background: #0f172a;
color: #e5e7eb;
padding: 0.75rem;
border-radius: 8px;
overflow: auto;
}
.re-editor :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
}
.re-editor :deep(th),
.re-editor :deep(td) {
border: 1px solid #e5e7eb;
padding: 6px 8px;
}
.re-editor :deep(img) {
max-width: 100%;
height: auto;
border-radius: 10px;
box-shadow: 0 6px 20px rgba(2, 6, 23, 0.08);
}
.re-px {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.re-px input {
width: 72px;
border: none;
outline: none;
padding: 4px 2px;
background: transparent;
}
.re-range {
padding: 0 6px;
}
.re-range input {
height: 22px;
}
</style>