前言
git仓库 需求要一个富文本编辑器,对录音转的文本进行编辑使用@opentiny/fluent-editor库作为编辑器,样式则不同于自带要还原ui
这里使用dom.click()形式去触发原有事件做到自己的结构样式,有着同样的功能
开始
下载@opentiny/fluent-editor并在编辑组件内引入
// 组件内引入
import FluentEditor from '@opentiny/fluent-editor'
import '@opentiny/fluent-editor/style.css'
一,添加dom容器实例fluent-editor
- 准备props组件属性,
textContent展示默认内容,disabled用来禁用输入和隐藏顶部操作栏(是否预览) TOOLBAR_CONFIG变量操作栏配置项 更多点击查看文档
<script setup lang='ts'>
import { ref, onMounted, defineProps, watch} from 'vue'
const props = defineProps({
textContent: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: true,
},
})
const TOOLBAR_CONFIG = [
['undo', 'redo'],
[{ size: ['12px', '14px', '16px', '18px', '20px', '24px', '32px', '36px', '48px', '72px'] }],
['bold', 'italic', 'underline'],
// [{ color: [] }, { background: [] }],
[{ align: [] }, { list: 'ordered' }, { list: 'bullet' }], //, { list: 'check' }
// [{ script: 'sub' }, { script: 'super' }],
// [{ indent: '-1' }, { indent: '+1' }],
// [{ direction: 'rtl' }],
// ['link', 'blockquote', 'code', 'code-block'],
// ['image', 'file', 'better-table'],
// ['emoji', 'video', 'formula', 'screenshot', 'fullscreen'],
]
const componentEl = ref()
const componentToolbarEl = ref()
onMounted(() => {
// 执行初始化时,请确保能获取到 DOM 元素,如果是在 Vue 项目中,需要在 onMounted 事件中执行。
example.value = new FluentEditor(editorRef.value, {
theme: 'snow',
modules: {
toolbar: TOOLBAR_CONFIG,
},
})
example.value.root.innerHTML = props.textContent;
})
</script>
<template>
<div class="editor" ref="componentEl" :class="{ disabled: !props.disabled }">
<div ref="editorRef"></div>
</div>
</template>
二、自定义操作栏样式思路
opentiny/fluent-editor文档没有找到什么api可以动态的设置字体大小、字体加粗斜体、等等方法;
审查元素查,每一个功能的className都是单独的可以dom操作去触发它自身的事件,做到更改字体等等
三、添加自己的状态栏样式
准备actions变量存放状态栏选中状态,用来设置选中样式
创建两个文件options,ts/methods.ts
- options.ts 存放字体加粗选项,排序方式选项,水平对齐选项
- methods.ts 存放对编辑器修改和dom触发事件的函数方法
option,ts
import strongIcon from './icons/加粗icon.png'
import strongIconActive from './icons/actives/加粗icon.png'
import italicIcon from './icons/斜体icon.png'
import italicIconActive from './icons/actives/斜体icon.png'
import underlineIcon from './icons/下划线icon.png'
import underlineIconActive from './icons/actives/下划线icon.png'
export const fontStyleOptions = [
{
value: 'bold',
label: '加粗',
icon1: strongIcon,
icon2: strongIconActive,
},
{
value: 'italic',
label: '斜体',
icon1: italicIcon,
icon2: italicIconActive,
},
{
value: 'underline',
label: '下划线',
icon1: underlineIcon,
icon2: underlineIconActive,
},
]
import orderedIcon from './icons/有序icon.png'
import orderedIconActive from './icons/actives/有序icon.png'
import bulletIcon from './icons/无序icon.png'
import bulletIconActive from './icons/actives/无序icon.png'
export const listingOptions = [
{
value: 'bullet',
label: '无序',
icon1: bulletIcon,
icon2: bulletIconActive,
},
{
value: 'ordered',
label: '有序',
icon1: orderedIcon,
icon2: orderedIconActive,
},
]
import leftIcon from './icons/左对齐icon.png'
import leftIconActive from './icons/actives/左对齐icon.png'
import centerIcon from './icons/居中对齐icon.png'
import centerIconActive from './icons/actives/居中对齐icon.png'
import rightIcon from './icons/右对齐icon.png'
import rightIconActive from './icons/actives/右对齐icon.png'
export const alignOptions = [
{
value: 'left',
label: '左对齐',
icon1: leftIcon,
icon2: leftIconActive,
},
{
value: 'center',
label: '剧中',
icon1: centerIcon,
icon2: centerIconActive,
},
{
value: 'right',
label: '右对齐',
icon1: rightIcon,
icon2: rightIconActive,
},
]
methods.ts
//撤回
export const clickUndo = (event, componentEl, actions) => {
let el = componentEl.querySelector('.ql-undo')
if (el) el.click()
}
// 撤回下一步
export const clickRedo = (event, componentEl, actions) => {
let el = componentEl.querySelector('.ql-redo')
if (el) el.click()
}
// 字体大小切换change
export const fontSizeChange = (event, componentEl, actions, example) => {
let els = [...componentEl.querySelectorAll('.ql-size .ql-picker-options .ql-picker-item')]
let fontSize = actions.fontSize + 'px'
let findEl = els.find(el => {
if (el.dataset.value === fontSize) {
return el
}
})
findEl && findEl.click()
requestAnimationFrame(() => {
try {
example.focus()
} catch (e) {
console.log(e.message)
}
})
}
export const fontSizeAdd = (event, componentEl, actions, example) => {
let els = [...componentEl.querySelectorAll('.ql-size .ql-picker-options .ql-picker-item')]
let fontSize = actions.fontSize + 'px'
let findIndex = els.findIndex(el => {
if (el.dataset.value === fontSize) {
return el
}
})
findIndex++
if (findIndex > els.length - 1) {
findIndex = els.length - 1
}
els[findIndex] && els[findIndex].click()
actions.fontSize = parseInt(els[findIndex].dataset.value)
requestAnimationFrame(() => {
try {
example.focus()
} catch (e) {
console.log(e.message)
}
})
}
export const fontSizeDel = (event, componentEl, actions, example) => {
let els = [...componentEl.querySelectorAll('.ql-size .ql-picker-options .ql-picker-item')]
let fontSize = actions.fontSize + 'px'
let findIndex = els.findIndex(el => {
if (el.dataset.value === fontSize) {
return el
}
})
findIndex--
if (findIndex < 0) {
findIndex = 0
}
els[findIndex] && els[findIndex].click()
actions.fontSize = parseInt(els[findIndex].dataset.value)
requestAnimationFrame(() => {
try {
example.focus()
} catch (e) {
console.log(e.message)
}
})
}
// 加粗,斜体,下划线
export const fontStyleChange = (event, componentEl, actions, example, record) => {
let elMap = {
bold: componentEl.querySelector('.ql-bold'),
italic: componentEl.querySelector('.ql-italic'),
underline: componentEl.querySelector('.ql-underline'),
}
let clickType = record.value
elMap[clickType].click()
if (actions.fontStyle.includes(clickType)) {
let k = actions.fontStyle.indexOf(clickType)
actions.fontStyle.splice(k, 1)
} else {
actions.fontStyle.push(clickType)
}
}
// 列表操作
export const listingChange = (event, componentEl, actions, example, record) => {
let elMap = {
bullet: componentEl.querySelector('.ql-list[value="bullet"]'),
ordered: componentEl.querySelector('.ql-list[value="ordered"]'),
}
let clickType = record.value
elMap[clickType].click()
actions.listing = clickType
}
// 水平对齐
export const alignChange = (event, componentEl, actions, example, record) => {
let options = componentEl.querySelectorAll('.ql-align .ql-picker-item')
let elMap = {
left: options[0],
center: options[1],
right: options[2],
}
let clickType = record.value
elMap[clickType].click()
actions.align = clickType
}
// selectionChange
export const selectionChange = (componentEl, actions, [ range, oldRange, source ]) => {
// 根据不同行 (p) 的对其方式设置选中状态
let alignOptions = [...componentEl.querySelectorAll('.ql-align .ql-picker-item')]
let alignNumMap = ['left', 'center', 'right']
let align = alignOptions.findIndex(el => {
let classList = el.className.split(' ')
if (classList.includes('ql-selected')) return el
})
actions.align = alignNumMap[align]
//列表回显
let listingOptions = [...componentEl.querySelectorAll('.ql-list')]
let listingInfo = listingOptions.find(el => {
let classList = el.className.split(' ')
if (classList.includes('ql-active')) return el
})
if (listingInfo) {
actions.listing = listingInfo.value
} else {
actions.listing = null
}
// 字体加粗 斜体 下划线回显
let fontStyleOption = [componentEl.querySelector('.ql-bold'), componentEl.querySelector('.ql-italic'), componentEl.querySelector('.ql-underline')]
actions.fontStyle = fontStyleOption.map(el => {
let classList = el.className.split(' ')
if (classList.includes('ql-active')) return el.getAttribute('aria-label')
})
}
// 禁用状态(阅读)
export const disabledInput = (props,componentEl) =>{
if(componentEl.value){
let editorInput = componentEl.value.querySelector('.ql-editor');
if(editorInput){
if(props.disabled){
editorInput.setAttribute('contenteditable',true)
}else{
editorInput.removeAttribute('contenteditable')
}
}
}
}
四、组件和methods中方法的使用
clickRedo(a,b,c) a为dom事件对象,b是组件的元素,c为actions存放状态对象变量
<script setup lang="ts">
import FluentEditor from '@opentiny/fluent-editor'
import '@opentiny/fluent-editor/style.css'
import { fontStyleOptions, listingOptions, alignOptions } from './options'
import { clickRedo, clickUndo, fontSizeChange, fontSizeAdd, fontSizeDel, fontStyleChange, listingChange, alignChange, selectionChange, disabledInput } from './methods'
import { ref, onMounted, defineProps, watch} from 'vue'
const props = defineProps({
textContent: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: true,
},
})
watch(
() => props.disabled,
() => {
disabledInput(props, componentEl)
},
)
let editorRef = ref()
let example = ref()
const TOOLBAR_CONFIG = [
['undo', 'redo'],
[{ size: ['12px', '14px', '16px', '18px', '20px', '24px', '32px', '36px', '48px', '72px'] }],
['bold', 'italic', 'underline'],
// [{ color: [] }, { background: [] }],
[{ align: [] }, { list: 'ordered' }, { list: 'bullet' }], //, { list: 'check' }
// [{ script: 'sub' }, { script: 'super' }],
// [{ indent: '-1' }, { indent: '+1' }],
// [{ direction: 'rtl' }],
// ['link', 'blockquote', 'code', 'code-block'],
// ['image', 'file', 'better-table'],
// ['emoji', 'video', 'formula', 'screenshot', 'fullscreen'],
]
const componentEl = ref()
const componentToolbarEl = ref()
onMounted(() => {
// 执行初始化时,请确保能获取到 DOM 元素,如果是在 Vue 项目中,需要在 onMounted 事件中执行。
example.value = new FluentEditor(editorRef.value, {
theme: 'snow',
modules: {
toolbar: TOOLBAR_CONFIG,
},
})
fontSizeOptions.value = TOOLBAR_CONFIG[1][0].size.map(item => {
return parseInt(item)
})
actions.value.fontSize = fontSizeOptions.value[1]
example.value.on('selection-change', (...args) => {
selectionChange(componentEl.value, actions.value, args )
})
disabledInput(props, componentEl)
example.value.root.innerHTML = props.textContent;
})
const fontSizeOptions = ref([])
const actions = ref({
do: 'undo',
fontSize: '12',
fontStyle: [],
listing: null, // ordered bullet
align: 'left',
})
const getHTML = () => {
return example.value.root.innerHTML
}
defineExpose({
name: 'editor',
getHTML,
})
</script>
<template>
<div class="editor" ref="componentEl" :class="{ disabled: !props.disabled }">
<div class="editor-ql-toolbar" ref="componentToolbarEl">
<div class="editor-toolbar-item">
<div class="editor-undo" @click.stop="clickUndo($event, componentEl, actions)">
<img src="./icons/go_undo.png" alt="" />
</div>
<div class="editor-redo" @click.stop="clickRedo($event, componentEl, actions)">
<img src="./icons/go_redo.png" alt="" />
</div>
</div>
<div class="editor-toolbar-line"></div>
<div class="editor-toolbar-item" @click.stop.prevent>
<el-select
@change="fontSizeChange($event, componentEl, actions, example)"
v-model="actions.fontSize"
style="min-width: 60px; max-height: 26px; height: 26px"
placeholder=""
>
<el-option :value="op" v-for="op in fontSizeOptions">{{ op }}px</el-option>
</el-select>
<div class="editor-font-add" @click="fontSizeAdd($event, componentEl, actions)">
<img src="./icons/font-add.png" alt="" />
</div>
<div class="editor-font-del" @click="fontSizeDel($event, componentEl, actions)">
<img src="./icons/font-del.png" alt="" />
</div>
</div>
<div class="editor-toolbar-line"></div>
<div class="editor-toolbar-item">
<div class="fontStyle-item" v-for="ic in fontStyleOptions" @click="fontStyleChange($event, componentEl, actions, example, ic)">
<img :src="actions.fontStyle.includes(ic.value) ? ic.icon2 : ic.icon1" alt="" />
</div>
</div>
<div class="editor-toolbar-line"></div>
<div class="editor-toolbar-item">
<div class="listing-item" v-for="ic in listingOptions" @click="listingChange($event, componentEl, actions, example, ic)">
<img :src="actions.listing === ic.value ? ic.icon2 : ic.icon1" alt="" />
</div>
</div>
<div class="editor-toolbar-line"></div>
<div class="editor-toolbar-item">
<div class="align-item" v-for="ic in alignOptions" @click="alignChange($event, componentEl, actions, example, ic)">
<img :src="actions.align === ic.value ? ic.icon2 : ic.icon1" alt="" />
</div>
</div>
</div>
<div ref="editorRef"></div>
</div>
</template>
<style scoped lang="less">
.editor {
background: rgba(245, 246, 247, 1);
border-radius: 10px;
padding: 22px 20px;
:deep(.ql-toolbar) {
border: none;
background-color: transparent !important;
display: none;
.ql-picker {
min-width: 60px;
height: 26px;
background-color: white;
border: 1px solid rgba(228, 235, 242, 1);
border-radius: 6px;
.ql-picker-label {
padding: 0 10px;
&:before {
width: 100%;
padding: 0;
height: 26px;
line-height: 26px;
}
svg {
right: 10px;
}
}
.ql-picker-options {
width: 100%;
margin-left: 0;
}
}
}
.ql-container {
border: none;
background-color: transparent;
}
:deep(.ql-editor) {
padding-left: 0;
padding-right: 0;
transition: all 0.2s linear;
}
}
.editor-ql-toolbar {
display: flex;
align-items: center;
height: 50px;
overflow: hidden;
transition: all 0.2s linear;
border-bottom: 1px solid rgba(216, 216, 216, 1) !important;
padding-bottom: 20px;
.editor-toolbar-item {
display: flex;
height: 26px;
align-items: center;
.editor-undo,
.editor-redo {
width: 14px;
height: 14px;
cursor: pointer;
display: flex;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.editor-redo {
margin-left: 14px;
}
.editor-font-add,
.editor-font-del {
min-width: 15px;
width: 15px;
max-width: 15px;
min-height: 27px;
max-height: 27px;
height: 27px;
cursor: pointer;
transform: translateY(-2px);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.editor-font-add {
margin-left: 14px;
margin-right: 10px;
}
:deep(.el-select) {
.el-select__wrapper {
height: 26px !important;
min-height: 26px !important;
}
}
.fontStyle-item {
width: 10px;
height: 14px;
min-width: 10px;
min-height: 14px;
max-width: 10px;
max-height: 14px;
display: flex;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.fontStyle-item + .fontStyle-item {
margin-left: 30px;
}
.listing-item {
width: 17px;
height: 15px;
min-width: 17px;
min-height: 15px;
max-width: 17px;
max-height: 15px;
display: flex;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.listing-item + .listing-item {
margin-left: 24px;
}
.align-item {
width: 16px;
height: 15px;
min-width: 16px;
min-height: 15px;
max-width: 16px;
max-height: 15px;
display: flex;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.align-item + .align-item {
margin-left: 24px;
}
}
.editor-toolbar-line {
margin: 0 24px;
height: 20px;
width: 2px;
background-color: rgba(216, 216, 216, 1);
}
}
.disabled {
.editor-ql-toolbar {
height: 0 !important;
padding-bottom: 0 !important;
border-color: transparent !important;
}
:deep(.ql-editor) {
padding-top: 0 !important;
}
}
</style>
五、编辑器切换不同行状态栏选中状态更改
可以看到【四】selectionChange方法的调用