likes
comments
collection
share

产品:实现一个多音字标注的文本编辑框

作者站长头像
站长
· 阅读数 83

产品:我需要一个输入框,可以正常编辑文字,然后可以点击文字标上拼音、轻重音等

接到需求的一瞬间,就想到用富文本编辑器,编写插件来实现,所以尝试了wangEditer,由于wangEditer是基于slate的节点数据结构实现的,思路是通过改写插入文本的方法,将键入的文本替换成自定义的节点元素,但发现在成功输入自定义节点元素之后,如果在文字中间插入新的文本会导致节点结构混乱的问题。

由于开发时间较短,就没有深究,找时间再看看怎么处理这个问题,期待下一篇文章。😄

暂时放弃了富文本插件的方案,于是就有了以下的这个方案

利用input标签产出文本,再维护文本节点列表,每次键入文本,就包装成节点并插入到列表,这样就转变成对于DOM的操作,可以随心所欲了。

产品:实现一个多音字标注的文本编辑框

定义节点结构

根据自己需要定义节点结构,目前需求是先实现多音字标注,并且改变拼音就切换样式,因此暂时这么去定义,轻重音或者其他的标注功能,依葫芦画瓢即可。

  const SPACE = '\u00A0';
// 包装文本
  produceWord(word, type = 'text') {
    const common = {
      id: generateId(), // 文本id uuid
      sId: this.sId, // 段落id
      pinyin: Math.random() > 0.5 ? 'zhan' : '', // 随机插入带pinyin节点,用于调试
      // pinyin: '',
      hasChange: Math.random() > 0.5, // 随机插入已改变pinyin节点,用于调试
      // hasChange: false,
      inputHere: false, // 光标(Input标签)位置
    };

    if (!word) type = 'space'; // 空格
    return {
      ...common,
      type, // 节点类型
      word: word || SPACE, // 文本
    };
  },

创建游离的Input节点

节点上可以挂载需要使用到的数据,比如data-index是为了定位文字索引,进行删除和更新光标位置等。

段落是为了兼容空格换行,每次换行都会插入一个新的段落

<div class="edit-wrapper" @click="onWrapperClick">
      <!-- 初始状态先用textarea承接文本,之后改为自定义的编辑框 -->
      <textarea
        ref="textarea"
        placeholder="请输入"
        v-show="!sentenceList.length"
        @input="onAreaInput"
        v-model="initWord"
      ></textarea>
      <!-- 自定义编辑框 -->
      <div class="word-wrapper">
        <!-- 段落 -->
        <div
          v-for="s in sentenceList"
          :key="s.sId"
          class="s-item"
          data-type="sentence"
          :data-sid="s.sId"
          @click.stop="onSentenceClick(s)"
        >
          <!-- 文字节点 -->
          <span
            :class="['w-item']"
            v-for="(w, index) in s.words"
            :key="w.id"
            data-type="word"
            :data-index="index"
            :data-word="w.word"
          >
            <div :class="['w-wrapper', w.pinyin && 'with-pinyin']">
              <!-- 游离的input标签 -->
              <input
                :ref="w.id"
                v-if="w.inputHere"
                class="cursor"
                @input="onCursorInput"
                @keydown="onInputKeydown"
                v-model="inputWord"
              />
              <!-- 拼音选择框,TODO 可以复用一个tooltip,提高性能 -->
              <a-tooltip
                trigger="click"
                placement="bottom"
                overlayClassName="polyphone-select__tooltip__wrapper"
              >
                <!-- 拼音列表 -->
                <template slot="title">
                  <div class="pinyin-list-wrapper">
                    <div
                      class="pinyin-item"
                      :class="['pinyin-item', p.active && 'active']"
                      v-for="p in pinyinList"
                      :key="p.pinyin"
                      @click="selectPinyin(w, p)"
                    >
                      <span>{{ p.pinyin }}</span>
                      <span v-if="p.isDefault" class="tag"></span>
                    </div>
                    <div class="pinyin-item" key="default" @click="resetPinyin(w)">恢复默认</div>
                  </div>
                </template>
                <!-- 拼音气泡,选择后的状态 -->
                <div :class="['pinyin', w.pinyin && w.hasChange && 'pinyin-bubble']">
                  {{ w.pinyin }}
                </div>
              </a-tooltip>
              <!-- 拼音, 初始状态 -->
              <div
                :data-index="index"
                :class="['w', w.pinyin && 'with-pinyin']"
                @click.stop="onWordClick(w)"
              >
                {{ w.word }}
              </div>
            </div>
          </span>
        </div>
      </div>
    </div>

文本操作

  • 维护一个节点列表
  • 插入或删除时更新列表,并保留当前文本id,在插入节点后更新input位置
  • 当文本节点小于2时(尾部空白节点用于定位尾部光标,不计入节点),删除段落
// 插入文本
  insertWord(words, type) {
    if (!words || !words.length) return;

    // 插入新的段落
    if (!this.sentenceList.length) {
      const sId = generateId();
      this.sentenceList.push({
        sId,
        words: [],
      });
      this.sId = sId;
    }

    if (!this.wordList.length) {
      // 插入文本节点
      this.wordList.splice(0, 0, ...words.map((word) => this.produceWord(word)));
      this.updateCursorToLast();
      return;
    }
    const originId = this.findFocusId();
    this.wordList.splice(
      this.findFocusIndex(),
      0,
      ...words.map((word, index) => this.produceWord(word, type))
    );
    // 更新光标位置
    this.updateCursor(originId);
  },
  // 删除文本
  removeWord(index, count = 1) {
    this.wordList.splice(index, count);
    if (this.wordList.length < 2) {
      this.removeSentence();
    }
  },
  
  // 更新光标位置
      updateCursor(id) {
        this.wordList.forEach((item) => {
          item.inputHere = item.id === id;
        });
        this.$nextTick(() => {
          this.$refs[id] && this.$refs[id][0] && this.$refs[id][0].focus();
        });
      },

覆盖Input部分操作

  • 覆盖回退、换行、空格、上下左右移动光标等操作,替换为对文本节点以及input标签位置的操作
import { generateId } from '@/utils/utils';
import { getTargetElemIndex } from './utils';

export const KEY_HANDLER = {
  8(vm) {
    if (!vm.wordList.length) return;
    if (vm.wordList.length < 2) {
      vm.removeSentence();
      return;
    }
    // 回退键
    const index = vm.findFocusIndex();
    if (index === 0) return;
    vm.removeWord(index - 1);
  },
  13(vm) {
    // 回车键
    const sId = generateId();
    const newS = {
      sId,
      words: [],
    };

    vm.sentenceList.splice(getTargetElemIndex(vm.sentenceList, 'sId', vm.sId) + 1, 0, newS);
    vm.sId = newS.sId;
    vm.insertWord(['']);
    vm.updateCursorToLast();
  },
  32(vm) {
    // 空格键
    vm.insertWord(['']);
  },
  38(vm) {
    // 上
    if (vm.findFocusIndex() < 20) {
      toLastSentence(vm);
      return;
    }
    const index = vm.findFocusIndex();
    vm.updateCursor(vm.wordList[Math.floor(index / 2)].id);
  },
  40(vm) {
    // 下
    if (vm.findFocusIndex() < 20) {
      toNextSentence(vm);
      return;
    }
    const index = vm.findFocusIndex();
    let resultIndex;

    if (index === 0) {
      resultIndex = Math.floor(vm.wordList.length / 2);
    } else {
      resultIndex =
        Math.floor(index * 2) >= vm.wordList.length - 1
          ? vm.wordList.length - 1
          : Math.floor(index * 2);
    }

    vm.updateCursor(vm.wordList[resultIndex].id);
  },
  37(vm) {
    // 左
    if (vm.findFocusIndex() === 0) {
      toLastSentence(vm);
      return;
    }
    const index = vm.findFocusIndex();
    const resultIndex = index - 1 <= 0 ? 0 : index - 1;
    vm.updateCursor(vm.wordList[resultIndex].id);
  },
  39(vm) {
    // 右
    if (vm.findFocusIndex() === vm.wordList.length - 1) {
      const currentSIndex = getTargetElemIndex(vm.sentenceList, 'sId', vm.sId);
      if (currentSIndex < vm.sentenceList.length - 1) {
        vm.sId = vm.sentenceList[currentSIndex + 1].sId;
        vm.updateCursor(vm.wordList[0].id);
      }
      return;
    }
    const index = vm.findFocusIndex();
    const resultIndex = index + 1 >= vm.wordList.length - 1 ? vm.wordList.length - 1 : index + 1;
    vm.updateCursor(vm.wordList[resultIndex].id);
  },
};

function toLastSentence(vm) {
  const currentSIndex = getTargetElemIndex(vm.sentenceList, 'sId', vm.sId);
  if (currentSIndex > 0) {
    vm.sId = vm.sentenceList[currentSIndex - 1].sId;
    vm.updateCursorToLast();
  }
}

function toNextSentence(vm) {
  const currentSIndex = getTargetElemIndex(vm.sentenceList, 'sId', vm.sId);
  if (currentSIndex < vm.sentenceList.length - 1) {
    vm.sId = vm.sentenceList[currentSIndex + 1].sId;
    vm.updateCursorToLast();
  }
}

实现圈选删除

利用Range获取当前圈选的节点,监听全局的keydown回退进行删除

  • 利用getSelection以及range定位当前圈选的开始节点和结束节点,从而定位到需要删除的文本
  • 实现方法有待改进,但基本就是这个思路
onGlobalKeydown(e) {
    if (e.keyCode !== 8) return;
    // 选取与编辑框无交集  退出
    if (
      !window
        .getSelection()
        .getRangeAt(0)
        .intersectsNode(document.querySelector('.edit-wrapper'))
    )
      return;
    if (!window.getSelection().toString()) return;

    const range = window.getSelection().getRangeAt(0);
    const framents = range.cloneContents();
    const type = framents.childNodes[0].dataset.type;
    if (type === 'word') {
      // 单段圈选
      this.removeRange(framents.childNodes);
    } else {
      // 多段圈选
      for (let i = 0; i < framents.childNodes.length; i++) {
        const sentence = framents.childNodes[i];
        this.sId = sentence.dataset.sid;
        this.removeRange(sentence.childNodes);
      }
    }

    window.getSelection().collapseToEnd();
  },
  removeRange(nodes) {
    let start = 0;
    let startFlag = false;
    let count = 0;
    for (let i = 0; i < nodes.length; i++) {
      const element = nodes[i];
      if (!element.querySelector('.w')) continue;
      const inner = element.querySelector('.w').innerHTML;
      if (!inner) continue;
      if (!startFlag) {
        start = element.dataset.index;
        startFlag = true;
      }

      // 最后一个空格不需删除
      if (i === nodes.length - 1 && inner.includes('&nbsp;')) continue;
      count++;
    }
    this.removeWord(start, count);
  },

整体代码

<template>
  <div>
    <a-button>打开编辑</a-button>
    <a-button @click="reset">清空</a-button>
    <div class="edit-wrapper" @click="onWrapperClick">
      <textarea
        ref="textarea"
        placeholder="请输入"
        v-show="!sentenceList.length"
        @input="onAreaInput"
        v-model="initWord"
      ></textarea>
      <div class="word-wrapper">
        <div
          v-for="s in sentenceList"
          :key="s.sId"
          class="s-item"
          data-type="sentence"
          :data-sid="s.sId"
          @click.stop="onSentenceClick(s)"
        >
          <span
            :class="['w-item']"
            v-for="(w, index) in s.words"
            :key="w.id"
            data-type="word"
            :data-index="index"
            :data-word="w.word"
          >
            <div :class="['w-wrapper', w.pinyin && 'with-pinyin']">
              <input
                :ref="w.id"
                v-if="w.inputHere"
                class="cursor"
                @input="onCursorInput"
                @keydown="onInputKeydown"
                v-model="inputWord"
              />

              <template v-if="!w.pinyin">
                <div :class="['pinyin', w.pinyin && w.hasChange && 'pinyin-bubble']">
                  {{ w.pinyin }}
                </div>
              </template>
              <template v-else>
                <a-tooltip
                  trigger="click"
                  placement="bottom"
                  overlayClassName="polyphone-select__tooltip__wrapper"
                  v-model="w.showPinyinList"
                  destroyTooltipOnHide
                >
                  <template slot="title">
                    <div class="pinyin-list-wrapper">
                      <div
                        class="pinyin-item"
                        :class="['pinyin-item', p.active && 'active']"
                        v-for="p in w.pinyinList"
                        :key="p.pinyin"
                        @click="selectPinyin(w, p)"
                      >
                        <span>{{ p.pinyin }}</span>
                        <span v-if="p.isDefault" class="tag"></span>
                      </div>
                      <div class="pinyin-item" key="default" @click="resetPinyin(w)">恢复默认</div>
                    </div>
                  </template>
                  <div :class="['pinyin', w.pinyin && w.hasChange && 'pinyin-bubble']">
                    {{ w.pinyin }}
                  </div>
                </a-tooltip>
              </template>

              <div
                :data-index="index"
                :class="['w', w.pinyin && 'with-pinyin']"
                @click.stop="onWordClick(w)"
              >
                {{ w.word }}
              </div>
            </div>
          </span>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
  // TODO 圈选多行删除

  import { generateId } from '@/utils/utils';
  import { KEY_HANDLER } from './keyHandler';
  import { getLastElem, getTargetElem, getTargetElemIndex } from './utils';
  import usePinyin from './usePinyin';

  const SPACE = '\u00A0';

  export default {
    mixins: [usePinyin],
    data() {
      return {
        sentenceList: [],
        initWord: '',
        inputWord: '',
        sId: null,
      };
    },
    watch: {
      'sentenceList.length'(val) {
        if (!val) {
          this.reset();
        }
      },
    },
    mounted() {
      window.addEventListener('keydown', this.onGlobalKeydown);
    },
    beforeDestroy() {
      window.removeEventListener('keydown', this.onGlobalKeydown);
    },
    computed: {
      wordList() {
        if (!this.sentenceList.length) return null;
        return getTargetElem(this.sentenceList, 'sId', this.sId).words;
      },
    },
    methods: {
      // 重置
      reset() {
        this.initWord = '';
        this.inputWord = '';
        this.sentenceList = [];
        this.$nextTick(() => {
          this.$refs.textarea.focus();
        });
      },
      // 移除段落
      removeSentence() {
        const lastWord = getLastElem(this.wordList);
        if (!lastWord) return;
        const sId = lastWord.sId;

        const currentIndex = getTargetElemIndex(this.sentenceList, 'sId', this.sId);
        let lastIndex;

        if (currentIndex === 0) {
          lastIndex = currentIndex;
        } else {
          lastIndex = currentIndex - 1;
        }

        this.sentenceList = this.sentenceList.filter((s) => s.sId !== sId);
        if (!this.sentenceList.length) return;

        this.sId = this.sentenceList[lastIndex].sId;

        this.updateCursorToLast();
      },
      // 插入文本
      insertWord(words, type) {
        if (!words || !words.length) return;

        if (!this.sentenceList.length) {
          const sId = generateId();
          this.sentenceList.push({
            sId,
            words: [],
          });
          this.sId = sId;
        }

        if (!this.wordList.length) {
          this.wordList.splice(0, 0, ...words.map((word) => this.produceWord(word)));
          this.updateCursorToLast();
          return;
        }
        const originId = this.findFocusId();
        this.wordList.splice(
          this.findFocusIndex(),
          0,
          ...words.map((word, index) => this.produceWord(word, type))
        );

        this.updateCursor(originId);
      },
      // 删除文本
      removeWord(index, count = 1) {
        this.wordList.splice(index, count);
        if (this.wordList.length < 2) {
          this.removeSentence();
        }
      },
      // 包装文本
      produceWord(word, type = 'text') {
        const common = {
          id: generateId(),
          sId: this.sId,
          originPinyin: '',
          // pinyin: Math.random() > 0.5 ? 'zhan' : '',
          pinyin: '',
          hasChange: false,
          inputHere: false,
          showPinyinList: false,
          pinyinList: [
            {
              active: true,
              isDefault: true,
              pinyin: 'hǎo',
            },
            {
              active: false,
              isDefault: false,
              pinyin: 'hào',
            },
            {
              active: false,
              isDefault: false,
              pinyin: 'hao',
            },
          ],
        };

        if (!word) type = 'space';
        return {
          ...common,
          type,
          word: word || SPACE,
        };
      },
      // 查找当前光标索引
      findFocusIndex() {
        const target = this.wordList.findIndex((item) => item.inputHere);
        if (target === -1) return this.wordList.length - 1;
        return target;
      },
      // 查找当前光标所在文本id
      findFocusId() {
        const target = this.wordList.find((item) => item.inputHere);
        return target ? target.id : null;
      },
      // 更新光标位置
      updateCursor(id) {
        this.wordList.forEach((item) => {
          item.inputHere = item.id === id;
        });
        this.$nextTick(() => {
          this.$refs[id] && this.$refs[id][0] && this.$refs[id][0].focus();
        });
      },
      // 更新光标在当前行最后
      updateCursorToLast() {
        this.updateCursor(getLastElem(this.wordList).id);
      },

      // 初始输入时
      onAreaInput() {
        if (this.initWord) {
          const words = this.initWord.split('').concat(['']);
          this.insertWord(words);
          this.initWord = '';
        }
      },
      // 光标输入时
      onCursorInput() {
        if (this.inputWord) {
          const words = this.inputWord.split('');
          this.insertWord(words);
          this.inputWord = '';
        }
      },
      // 覆盖input一些默认行为
      onInputKeydown(e) {
        if (!KEY_HANDLER[e.keyCode]) return;
        KEY_HANDLER[e.keyCode](this);
      },
      onGlobalKeydown(e) {
        if (e.keyCode !== 8) return;
        // 选取与编辑框无交集  退出
        if (
          !window
            .getSelection()
            .getRangeAt(0)
            .intersectsNode(document.querySelector('.edit-wrapper'))
        )
          return;
        if (!window.getSelection().toString()) return;

        const range = window.getSelection().getRangeAt(0);
        const framents = range.cloneContents();
        const type = framents.childNodes[0].dataset.type;
        if (type === 'word') {
          // 单段圈选
          this.removeRange(framents.childNodes);
        } else {
          // 多段圈选
          for (let i = 0; i < framents.childNodes.length; i++) {
            const sentence = framents.childNodes[i];
            this.sId = sentence.dataset.sid;
            this.removeRange(sentence.childNodes);
          }
        }

        window.getSelection().collapseToEnd();
      },
      removeRange(nodes) {
        let start = 0;
        let startFlag = false;
        let count = 0;
        for (let i = 0; i < nodes.length; i++) {
          const element = nodes[i];
          if (!element.querySelector('.w')) continue;
          const inner = element.querySelector('.w').innerHTML;
          if (!inner) continue;
          if (!startFlag) {
            start = element.dataset.index;
            startFlag = true;
          }

          // 最后一个空格不需删除
          if (i === nodes.length - 1 && inner.includes('&nbsp;')) continue;
          count++;
        }
        this.removeWord(start, count);
      },
      // 点击输入框光标移至最后
      onWrapperClick() {
        if (window.getSelection().toString()) return;
        if (!this.sentenceList.length) return;
        if (!this.wordList.length) return;
        this.sId = getLastElem(this.sentenceList).sId;
        this.updateCursorToLast();
      },
      onSentenceClick(s) {
        this.sId = s.sId;

        if (window.getSelection().toString()) return;
        if (!this.sentenceList.length) return;

        this.updateCursorToLast();
      },
      onWordClick(item) {
        this.sId = item.sId;
        this.updateCursor(item.id);
      },
    },
  };
</script>

<style lang="less" scoped>
  .edit-wrapper {
    width: 800px;
    height: 500px;
    border: 1px solid #eee;
    margin-left: 200px;
    text-align: left;
    cursor: text;

    textarea {
      height: 100%;
      width: 100%;
      border: none;
      outline: none;
      resize: none;
      font-size: 16px;
    }

    .word-wrapper {
      font-size: 16px;
      font-weight: 400;
      color: #333333;
      .s-item {
        .w-item {
          position: relative;
          display: inline-flex;
          text-align: center;
          justify-content: flex-end;
          flex-direction: column;
          align-items: center;
          vertical-align: bottom;

          .cursor {
            position: absolute;
            width: 5px;
            height: 21px;
            border: none;
            background: none;
            z-index: 2;
            bottom: 1px;
            left: -3px;
          }
        }

        .w-wrapper {
          position: relative;
          line-height: 22px;
        }
        .with-pinyin {
          padding: 0 3px;

          .pinyin {
            line-height: 22px;
            user-select: none;
            font-size: 14px;
            font-weight: 400;
            color: #a35af0;
            cursor: pointer;
          }

          .pinyin-bubble {
            background: #39d5e1;
            border-radius: 50px;
            color: #fff;
            text-align: center;
            padding: 4px 6px;
          }
        }
      }
    }
  }
</style>

<style lang="less">
  .polyphone-select__tooltip__wrapper {
    max-width: unset;
    .ant-tooltip-inner {
      background: #ffffff;
      box-shadow: 0px 2px 8px 1px rgba(0, 0, 0, 0.08);
      border-radius: 8px;
      padding: 8px 4px;

      .pinyin-list-wrapper {
        .pinyin-item {
          min-width: 90px;
          height: 36px;
          padding: 0 10px;
          background-color: #fff;
          border-radius: 6px;
          border: none;
          display: flex;
          justify-content: center;
          align-items: center;
          font-size: 14px;
          font-weight: 400;
          color: #333;
          position: relative;
          cursor: pointer;
          &:not(:last-child) {
            margin-bottom: 5px;
          }
          &:hover {
            background: #f7f7ff;
            color: #a35af0;
          }
          .tag {
            width: 20px;
            height: 18px;
            background: #a35af0;
            border-radius: 4px;
            font-size: 12px;
            font-weight: 400;
            color: #ffffff;
            line-height: 18px;
            text-align: center;
            position: absolute;
            right: 4px;
            top: 4px;
          }
        }
        .active {
          background: #f7f7ff;
          color: #a35af0;
        }
      }
    }
    .ant-tooltip-arrow {
      display: none;
    }
  }
</style>


总结

  • 思路不复杂,就是通过input产出文本,将文本序列化进行渲染
  • 主要是处理好节点的操作,时间有限,技术有限,以上代码还有很多待优化的地方,之后优化了会继续更新,还望评论区教我,我请你吃42号混凝土🤔
  • 以上
转载自:https://juejin.cn/post/7157155197092003877
评论
请登录