富文本编辑器@opentiny/fluent-editor自定义样式

119 阅读6分钟

前言

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

  1. 准备props组件属性,textContent展示默认内容,disabled用来禁用输入和隐藏顶部操作栏(是否预览)
  2. 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方法的调用