393 lines
12 KiB
Vue
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>
|