Files
guanwang/app/components/RichTextEditor.vue

184 lines
9.9 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { TextAlign } from '@tiptap/extension-text-align'
import { TextStyleKit } from '@tiptap/extension-text-style'
import Image from '@tiptap/extension-image'
import StarterKit from '@tiptap/starter-kit'
import { EditorContent, useEditor } from '@tiptap/vue-3'
import { toEditorHtml } from '~/utils/rich-text'
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const linkMessage = ref('')
const imageInput = ref<HTMLInputElement>()
const imageMessage = ref('')
const imageBusy = ref(false)
const editor = useEditor({
content: toEditorHtml(props.modelValue),
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
link: {
openOnClick: false,
autolink: true,
HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' },
},
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyleKit,
Image.configure({ allowBase64: false }),
],
editorProps: {
attributes: {
class: 'rich-text-surface',
'aria-label': '正文编辑区域',
spellcheck: 'true',
},
},
onUpdate: ({ editor: instance }) => {
emit('update:modelValue', instance.isEmpty ? '' : instance.getHTML())
},
})
watch(() => props.modelValue, (value) => {
if (!editor.value) return
const next = toEditorHtml(value)
if (editor.value.getHTML() !== next) editor.value.commands.setContent(next, { emitUpdate: false })
})
function selectBlock(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value === 'paragraph') editor.value?.chain().focus().setParagraph().run()
else editor.value?.chain().focus().toggleHeading({ level: Number(value) as 2 | 3 }).run()
}
function selectFontFamily(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value) editor.value?.chain().focus().setFontFamily(value).run()
else editor.value?.chain().focus().unsetFontFamily().run()
}
function selectFontSize(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value) editor.value?.chain().focus().setFontSize(value).run()
else editor.value?.chain().focus().unsetFontSize().run()
}
function selectLineHeight(event: Event) {
const value = (event.target as HTMLSelectElement).value
if (value) editor.value?.chain().focus().setLineHeight(value).run()
else editor.value?.chain().focus().unsetLineHeight().run()
}
function setColor(event: Event, background = false) {
const value = (event.target as HTMLInputElement).value
const chain = editor.value?.chain().focus()
if (background) chain?.setBackgroundColor(value).run()
else chain?.setColor(value).run()
}
function setLink() {
const previous = editor.value?.getAttributes('link').href || ''
const href = window.prompt('请输入链接地址http、https、mailto 或 tel', previous)
if (href === null) return
if (!href.trim()) {
editor.value?.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
if (!/^(https?:\/\/|mailto:|tel:)/i.test(href.trim())) {
linkMessage.value = '链接需以 http://、https://、mailto: 或 tel: 开头'
return
}
linkMessage.value = ''
editor.value?.chain().focus().extendMarkRange('link').setLink({ href: href.trim(), target: '_blank', rel: 'noopener noreferrer' }).run()
}
function insertImageUrl() {
const src = window.prompt('请输入图片地址(建议使用素材库中的图片地址)')
if (!src?.trim()) return
if (!/^https?:\/\//i.test(src.trim()) && !src.trim().startsWith('/')) {
imageMessage.value = '图片地址需以 http://、https:// 或 / 开头'
return
}
imageMessage.value = ''
editor.value?.chain().focus().setImage({ src: src.trim() }).run()
}
async function uploadImage(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
imageBusy.value = true
imageMessage.value = ''
try {
const body = new FormData()
body.append('file', file)
const result = await $fetch<{ url: string }>('/api/admin/media', { method: 'POST', body })
editor.value?.chain().focus().setImage({ src: result.url, alt: file.name }).run()
} catch (error: any) {
imageMessage.value = error?.data?.statusMessage || '图片上传失败,请检查图片格式和大小'
} finally {
imageBusy.value = false
;(event.target as HTMLInputElement).value = ''
}
}
</script>
<template>
<div class="rich-text-editor">
<div v-if="editor" class="rich-text-toolbar" role="toolbar" aria-label="正文排版工具栏">
<select aria-label="段落样式" title="段落样式" @change="selectBlock">
<option value="paragraph">正文</option>
<option value="2">二级标题</option>
<option value="3">三级标题</option>
</select>
<select aria-label="字体" title="字体" @change="selectFontFamily">
<option value="">默认字体</option>
<option value="Noto Serif SC, serif">宋体风格</option>
<option value="Noto Sans SC, sans-serif">黑体风格</option>
<option value="Arial, sans-serif">Arial</option>
</select>
<select aria-label="字号" title="字号" @change="selectFontSize">
<option value="">默认字号</option>
<option v-for="size in [14,16,18,20,24,28,32]" :key="size" :value="`${size}px`">{{ size }} px</option>
</select>
<select aria-label="行距" title="行距" @change="selectLineHeight">
<option value="">默认行距</option>
<option value="1.5">1.5 </option>
<option value="1.75">1.75 </option>
<option value="2">2 </option>
<option value="2.25">2.25 </option>
</select>
<span class="toolbar-divider" />
<button type="button" :class="{ active: editor.isActive('bold') }" title="加粗" aria-label="加粗" @click="editor.chain().focus().toggleBold().run()"><b>B</b></button>
<button type="button" :class="{ active: editor.isActive('italic') }" title="斜体" aria-label="斜体" @click="editor.chain().focus().toggleItalic().run()"><i>I</i></button>
<button type="button" :class="{ active: editor.isActive('underline') }" title="下划线" aria-label="下划线" @click="editor.chain().focus().toggleUnderline().run()"><u>U</u></button>
<button type="button" :class="{ active: editor.isActive('strike') }" title="删除线" aria-label="删除线" @click="editor.chain().focus().toggleStrike().run()"><s>S</s></button>
<label class="toolbar-color" title="文字颜色">A<input type="color" value="#17364e" aria-label="文字颜色" @input="setColor($event)"></label>
<label class="toolbar-color highlight" title="背景颜色">A<input type="color" value="#fff1c9" aria-label="背景颜色" @input="setColor($event, true)"></label>
<span class="toolbar-divider" />
<button type="button" :class="{ active: editor.isActive({ textAlign: 'left' }) }" title="左对齐" aria-label="左对齐" @click="editor.chain().focus().setTextAlign('left').run()"></button>
<button type="button" :class="{ active: editor.isActive({ textAlign: 'center' }) }" title="居中对齐" aria-label="居中对齐" @click="editor.chain().focus().setTextAlign('center').run()"></button>
<button type="button" :class="{ active: editor.isActive({ textAlign: 'right' }) }" title="右对齐" aria-label="右对齐" @click="editor.chain().focus().setTextAlign('right').run()"></button>
<button type="button" :class="{ active: editor.isActive({ textAlign: 'justify' }) }" title="两端对齐" aria-label="两端对齐" @click="editor.chain().focus().setTextAlign('justify').run()"></button>
<button type="button" :class="{ active: editor.isActive('bulletList') }" title="项目符号" aria-label="项目符号" @click="editor.chain().focus().toggleBulletList().run()"> 列表</button>
<button type="button" :class="{ active: editor.isActive('orderedList') }" title="编号列表" aria-label="编号列表" @click="editor.chain().focus().toggleOrderedList().run()">1. 列表</button>
<button type="button" :class="{ active: editor.isActive('blockquote') }" title="引用" aria-label="引用" @click="editor.chain().focus().toggleBlockquote().run()">引用</button>
<button type="button" :class="{ active: editor.isActive('link') }" title="添加或编辑链接" aria-label="添加或编辑链接" @click="setLink">链接</button>
<button type="button" title="插入图片地址" aria-label="插入图片地址" @click="insertImageUrl">图片地址</button>
<button type="button" title="上传图片" aria-label="上传图片" :disabled="imageBusy" @click="imageInput?.click()">{{ imageBusy ? '上传中' : '上传图片' }}</button>
<input ref="imageInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change="uploadImage">
<button type="button" :class="{ active: editor.isActive('codeBlock') }" title="代码块" aria-label="代码块" @click="editor.chain().focus().toggleCodeBlock().run()">代码</button>
<button type="button" title="分隔线" aria-label="分隔线" @click="editor.chain().focus().setHorizontalRule().run()">分隔线</button>
<button type="button" title="清除格式" aria-label="清除格式" @click="editor.chain().focus().clearNodes().unsetAllMarks().run()">清除格式</button>
<button type="button" title="撤销" aria-label="撤销" :disabled="!editor.can().undo()" @click="editor.chain().focus().undo().run()"></button>
<button type="button" title="重做" aria-label="重做" :disabled="!editor.can().redo()" @click="editor.chain().focus().redo().run()"></button>
</div>
<EditorContent :editor="editor" />
<p v-if="linkMessage" class="rich-text-message">{{ linkMessage }}</p>
<p v-if="imageMessage" class="rich-text-message">{{ imageMessage }}</p>
<div class="rich-text-footer"><span>支持从 Word 粘贴发布时会自动清理不安全代码</span><span>{{ modelValue.length }} 字符</span></div>
</div>
</template>