184 lines
9.9 KiB
Vue
184 lines
9.9 KiB
Vue
<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>
|