从零开始的富文本

115 阅读4分钟

怎样算一个好的富文本呢? 为什么要从零写一个富文本呢?

公司采购了一款 FroalaEditor 富文本工具 但该工具同时存在一些不可修改的问题 加之不好维护 不易扩展 官方文档也不全

有误项目上线已经很久了 临时换框架是不现实的

有时间摸鱼的我不如从零学习写一款富文本组件 用来替换该库

那么怎么实现一个简易的富文本呢?

首先想到的是 document.execCommand

但该命令已经被官方废弃 虽然仍可以使用 说不定什么时候就噶了

document.execCommand

已弃用:  不再推荐使用该特性。虽然一些浏览器仍然支持它,但也许已从相关的 web 标准中移除,也许正准备移除或出于兼容性而保留。请尽量不要使用该特性,并更新现有的代码;参见本页面底部的兼容性表格以指导你作出决定。请注意,该特性随时可能无法正常工作。

当一个 HTML 文档切换到设计模式时,document暴露 execCommand 方法,该方法允许运行命令来操纵可编辑内容区域的元素。

大多数命令影响document的 selection(粗体,斜体等),当其他命令插入新元素(添加链接)或影响整行(缩进)。当使用contentEditable时,调用 execCommand() 将影响当前活动的可编辑元素。

那么不用execCommand 怎么写一个简易富文本呢?

下面是一个简单的思路

image.png

一个加粗功能的流程如下:

选中文本 -> 点击加粗按钮 -> 文本加粗 / 文本取消加粗

在html中 我们获取文本时 需要使用到

window.getSelection().getRangeAt(0)

上面这句话的含意 及 获取最后一个选区

其中包含一些内容

image.png

startContainer 即 文本内容 这四个字所在的位置

text 是无法增加样式的 只有 HtmlElement才能使用 style 属性 所以 需要将该文本内容追加到一个 HtmlElement中

    export function blocks<K extends keyof HTMLElementTagNameMap>(tag: K): HTMLElement {
      const block = document.createElement(tag);
      const range = window.getSelection().getRangeAt(0);
      const old = range.extractContents();
      range.deleteContents();
      block.appendChild(old);
      range.insertNode(block);
      return block;
    }

这样就在 文本内容 的位置加入了一个 span 标签

image.png

接着在对 创建的 block 块 进行额外的样式操作即可!

我们获取还需要个设置样式的函数

    export function setStyle<K extends keyof HTMLElementTagNameMap>(
      style: { [id: string]: string | number },
      target?: HTMLElement,
      tag?: K,
    ) {
      if (target) {
        Object.keys(style).forEach((key) => {
          target.style[key] = style[key];
        });
        return;
      }

      const block = blocks(tag ?? 'span');
      Object.keys(style).forEach((key) => {
        block.style[key] = style[key];
      });
    }

整体代码如下:

    /**
     * 生成一个包装块
     *
     * 将选中内容放置进入包装块 并移除界面上旧的内容
     *
     * @param tag
     */
    export function blocks<K extends keyof HTMLElementTagNameMap>(tag: K): HTMLElement {
      const block = document.createElement(tag);
      const range = window.getSelection().getRangeAt(0);
      const old = range.extractContents();
      range.deleteContents();
      block.appendChild(old);
      range.insertNode(block);
      return block;
    }

    export function createTable(row: number, col: number): HTMLTableElement {
      const table = document.createElement('table');

      const trFrag = document.createDocumentFragment();
      new Array(row).fill(1).forEach(() => {
        const tr = document.createElement('tr');
        const tdFrag = document.createDocumentFragment();
        new Array(col).fill(1).forEach(() => {
          const td = document.createElement('td');
          const br = document.createElement('br');
          td.appendChild(br);
          tdFrag.appendChild(td);
        });
        tr.appendChild(tdFrag);
        trFrag.appendChild(tr);
      });
      table.appendChild(trFrag);

      return table;
    }

    export function createImage(src: string): HTMLImageElement {
      const img = document.createElement('img');
      img.src = src;
      img.width = 100;
      return img;
    }

    export function createLink(src: string): HTMLAnchorElement {
      const content = document.createTextNode('link');
      const anchor = document.createElement('a');
      anchor.href = src;
      anchor.target = '_black';
      anchor.appendChild(content);
      return anchor;
    }

    export function getStyle(tag: HTMLElement): CSSStyleDeclaration {
      return window.getComputedStyle(tag);
    }

    export function getTag<K extends keyof HTMLElementTagNameMap>(tag: K): HTMLElement {
      let node = window.getSelection().getRangeAt(0).startContainer;

      while (
        !(node instanceof HTMLElement) ||
        (node instanceof HTMLElement && node.tagName !== tag.toUpperCase() && node.parentNode)
      ) {
        node = node.parentNode;
      }

      return node;
    }

    // 设置样式
    export function setStyle<K extends keyof HTMLElementTagNameMap>(
      style: { [id: string]: string | number },
      target?: HTMLElement,
      tag?: K,
    ) {
      if (target) {
        Object.keys(style).forEach((key) => {
          target.style[key] = style[key];
        });
        return;
      }

      const block = blocks(tag ?? 'span');
      Object.keys(style).forEach((key) => {
        block.style[key] = style[key];
      });
    }

调用组件:

    import { FC, useMemo, useRef } from 'react';
    import { blocks, createImage, createLink, createTable, getStyle, getTag, setStyle } from '../../helper';

    import './index.scss';

    interface ITool {
      name: string;
      callback: () => void;
    }

    export const App: FC = () => {
      const ref = useRef<HTMLDivElement>(null);

      const toolbars = useMemo<ITool[]>(() => {
        return [
          {
            name: '加粗',
            callback: () => {
              setStyle({ 'font-weight': 'bold' });
            },
          },
          {
            name: '斜体',
            callback: () => {
              setStyle({ 'font-style': 'italic' });
            },
          },
          {
            name: '下划线',
            callback: () => {
              setStyle({ 'text-decoration': 'underline' });
            },
          },
          {
            name: '删除线',
            callback: () => {
              setStyle({ 'text-decoration': 'line-through' });
            },
          },
          {
            name: '插入表格',
            callback: () => {
              const table = createTable(2, 2);
              window.getSelection().getRangeAt(0).insertNode(table);
            },
          },
          {
            name: '插入图片',
            callback: () => {
              const img = createImage(
                'https://i0.wp.com/wx4.sinaimg.cn/large/0072Vf1pgy1foxkioq4i5j31hc0u0e1o.jpg',
              );
              window.getSelection().getRangeAt(0).insertNode(img);
            },
          },
          {
            name: '插入链接',
            callback: () => {
              const img = createLink(
                'https://img0.baidu.com/it/u=3512817427,2177083968&fm=253&fmt=auto&app=138&f=JPEG?w=650&h=431',
              );
              window.getSelection().getRangeAt(0).insertNode(img);
            },
          },
          {
            name: '换行',
            callback: () => {
              const br = document.createElement('br');
              const p = document.createElement('p');
              p.appendChild(br);
              const selection = window.getSelection();
              const range = selection.getRangeAt(0);
              const parentNode = range.startContainer.parentNode;
              range.deleteContents();

              if (parentNode === ref.current) {
                range.setEndAfter(parentNode);
                range.insertNode(p);
              } else {
                range.setStartAfter(parentNode);
                range.insertNode(p);
              }

              range.collapse(true);
            },
          },
          {
            name: '字号',
            callback: () => {
              setStyle({ 'font-size': '30px' });
            },
          },
          {
            name: '字前景色',
            callback: () => {
              setStyle({ color: '#eeeeee' });
            },
          },
          {
            name: '字背景色',
            callback: () => {
              setStyle({ 'background-color': 'orange' });
            },
          },
          {
            name: '字体',
            callback: () => {
              setStyle({ 'font-family': 'STSong' });
            },
          },
          {
            name: '右对齐',
            callback: () => {
              setStyle({ 'text-align': 'right' }, getTag('p'));
            },
          },
          {
            name: '左对齐',
            callback: () => {
              setStyle({ 'text-align': 'left' }, getTag('p'));
            },
          },
          {
            name: '居中对齐',
            callback: () => {
              setStyle({ 'text-align': 'center' }, getTag('p'));
            },
          },
          {
            name: '增加缩进',
            callback: () => {
              const data = getStyle(getTag('p')).marginLeft.match(/\d+/gi);
              setStyle({ 'margin-left': `${Number(data[0]) + 20}px` }, getTag('p'));
            },
          },
          {
            name: '减少缩进',
            callback: () => {
              const tag = getTag('p');
              const data = getStyle(tag).marginLeft.match(/\d+/gi);
              setStyle({ 'margin-left': `${Math.max(Number(data[0]) - 20, 0)}px` }, tag);
            },
          },
          {
            name: '行高',
            callback: () => {
              setStyle({ 'line-height': '3' }, getTag('p'));
            },
          },
          {
            name: '块',
            callback: () => {
              blocks('span');
            },
          },
        ];
      }, []);

      return (
        <div className="editor">
          <div className="toolbar">
            {toolbars.map((tool, index) => (
              <button key={index} onClick={tool.callback}>
                {tool.name}
              </button>
            ))}
          </div>
          <div
            className="editor-content"
            contentEditable
            ref={ref}
            dangerouslySetInnerHTML={{ __html: '<p>placeholder</p>' }}
          />
        </div>
      );
    };

体验 demo 点击 : demo-rich.netlify.app/