基於slate構建文件編輯器

WindrunnerMax發表於2022-06-26

基於slate構建文件編輯器

slate.js是一個完全可定製的框架,用於構建富文字編輯器,在這裡我們使用slate.js構建專注於文件編輯的富文字編輯器。

描述

GithubEditor DEMO

富文字編輯器是一種可內嵌於瀏覽器,所見即所得的文字編輯器。現在有很多開箱即用的富文字編輯器,例如UEditorWangEditor等,他們的可定製性差一些,但是勝在開箱即用,可以短時間就見到效果。而類似於Draft.jsSlate.js,他們是富文字編輯器的core或者叫做controller,並不是一個完整的功能,這樣就能夠讓我們有非常高的可定製性,當然也就會造成開發所需要的時間比較多。在實際應用或技術選型的時候,還是要多做一些調研,因為在業務上框架沒有絕對的優勢與劣勢,只有合適不合適。

slate的文件中有對於框架的設計原則上的描述,搬運一下:

  • 外掛是一等公民,slate最重要的部分就是外掛是一等公民實體,這意味著你可以完全定製編輯體驗,去建立像Medium或是Dropbox這樣複雜的編輯器,而不必對庫的預設作鬥爭。
  • 精簡的schema核心,slate的核心邏輯對你編輯的資料結構進行的預設非常少,這意味著當你構建複雜用例時,不會被任何的預製內容所阻礙。
  • 巢狀文件模型,slate文件所使用的模型是一個巢狀的,遞迴的樹,就像DOM一樣,這意味著對於高階用例來說,構建像表格或是巢狀引用這樣複雜的元件是可能的,當然你也可以使用單一層次的結構來保持簡單性。
  • DOM相同,slate的資料模型基於DOM,文件是一個巢狀的樹,其使用文字選區selections和範圍ranges,並且公開所有的標準事件處理函式,這意味著像是表格或者是巢狀引用這樣的高階特性是可能的,幾乎所有你在DOM中可以做到的事情,都可以在slate中做到。
  • 直觀的指令,slate文件執行命令commands來進行編輯,它被設計為高階並且非常直觀地進行編輯和閱讀,以便定製功能儘可能地具有表現力,這大大的提高了你理解程式碼的能力。
  • 可協作的資料模型,slate使用的資料模型特別是操作如何應用到文件上,被設計為允許協同編輯在最頂層,所以如果你決定要實現協同編輯,不必去考慮徹底重構。
  • 明確的核心劃分,使用外掛優先的結構和精簡核心,使得核心和定製的邊界非常清晰,這意味著核心的編輯體驗不會被各種邊緣情況所困擾。

前邊提到了slate只是一個core,簡單來說他本身並不提供各種富文字編輯功能,所有的富文字功能都需要自己來通過其提供的API來實現,甚至他的外掛機制也需要通過自己來擴充,所以在外掛的實現方面就需要自己制定一些策略。slate的文件雖然不是特別詳細,但是他的示例是非常豐富的,在文件中也提供了一個演練作為上手的基礎,對於新手還是比較友好的。在這裡我們構建了專注於文件編輯的富文字編輯器,互動與ui方面對於飛書文件的參考比較多,整體來說坑也是比較多的,尤其是在做互動策略方面,不過做好兜底以後實現基本的文件編輯器功能是沒有問題的。在這裡我使用的slate版本為0.80.0,不排除之後的框架策略調整,所以對於版本資訊也需要注意。

外掛策略

上邊我們提到了,slate本身並沒有提供外掛序號產生器制,這方面可以直接在文件的演練部分看出,同時也可以看出slate暴露了一些props使我們可以擴充slate的功能,例如renderElementrenderLeafonKeyDown等等,也可以看出slate維護的資料與渲染是分離的,我們需要做的是維護資料結構以及決定如何渲染某種型別的資料,所以在這裡我們需要基於這些序號產生器制來實現自己的外掛擴充方案。
這是文件中演練最後實現的程式碼,可以簡單瞭解一下slate的控制處理方案,可以看到塊級元素即<CodeElement />的渲染是通過renderElement來完成的,行內元素即bold樣式的渲染是通過renderLeaf來完成的,在onKeyDown中我們可以看到通過監聽鍵盤的輸入,我們對slate維護的資料通過Transforms進行了一些處理,通過匹配Nodeattributes寫入了資料結構,然後通過兩種renderprops將其渲染了出來,所以這就是slate的擴充機制與資料渲染分離結構。

const initialValue = [
  {
    type: 'paragraph',
    children: [{ text: 'A line of text in a paragraph.' }],
  },
]

const App = () => {
  const [editor] = useState(() => withReact(createEditor()))

  const renderElement = useCallback(props => {
    switch (props.element.type) {
      case 'code':
        return <CodeElement {...props} />
      default:
        return <DefaultElement {...props} />
    }
  }, [])

  // Define a leaf rendering function that is memoized with `useCallback`.
  const renderLeaf = useCallback(props => {
    return <Leaf {...props} />
  }, [])

  return (
    <Slate editor={editor} value={initialValue}>
      <Editable
        renderElement={renderElement}
        // Pass in the `renderLeaf` function.
        renderLeaf={renderLeaf}
        onKeyDown={event => {
          if (!event.ctrlKey) {
            return
          }

          switch (event.key) {
            case '`': {
              event.preventDefault()
              const [match] = Editor.nodes(editor, {
                match: n => n.type === 'code',
              })
              Transforms.setNodes(
                editor,
                { type: match ? null : 'code' },
                { match: n => Editor.isBlock(editor, n) }
              )
              break
            }

            case 'b': {
              event.preventDefault()
              Transforms.setNodes(
                editor,
                { bold: true },
                { match: n => Text.isText(n), split: true }
              )
              break
            }
          }
        }}
      />
    </Slate>
  )
}

const Leaf = props => {
  return (
    <span
      {...props.attributes}
      style={{ fontWeight: props.leaf.bold ? 'bold' : 'normal' }}
    >
      {props.children}
    </span>
  )
}

外掛註冊

在上一節我們瞭解了slate的外掛擴充與資料處理方案,那麼我們也可以看到這種最基本的外掛註冊方式還是比較麻煩的,那麼我們就可以自己實現一個外掛的註冊方案,統一封裝一下外掛的註冊形式,用來擴充slate。在這裡外掛註冊時通過slate-plugins.tsx來實現,具體來說,每個外掛都是一個必須返回一個Plugin型別的函式,當然直接定義一個物件也是沒問題的,函式的好處是可以在註冊的時候傳遞引數,所以一般都是直接用函式定義的。

  • key: 表示該外掛的名字,一般不能夠重複。
  • priority: 表示外掛執行的優先順序,通常使用者需要包裹renderLine的元件。
  • command: 註冊該外掛的命令,工具欄點選或者按下快捷鍵需要執行的函式。
  • onKeyDown: 鍵盤事件的處理函式,可以用他來制定回車或者刪除等操作的具體行為等。
  • type: 標記其是block或者是inline
  • match: 只有返回true即匹配到的外掛才會執行。
  • renderLine: 用於block的元件,通常用作在其子元素上包裹一層元件。
  • render: 對於block元件具體渲染的元件由該函式決定,對於inline元件則與blockrenderLine表現相同。
type BasePlugin = {
  key: string;
  priority?: number; // 優先順序越高 在越外層
  command?: CommandFn;
  onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => boolean | void;
};
type ElementPlugin = BasePlugin & {
  type: typeof EDITOR_ELEMENT_TYPE.BLOCK;
  match: (props: RenderElementProps) => boolean;
  renderLine?: (context: ElementContext) => JSX.Element;
  render?: (context: ElementContext) => JSX.Element;
};
type LeafPlugin = BasePlugin & {
  type: typeof EDITOR_ELEMENT_TYPE.INLINE;
  match: (props: RenderLeafProps) => boolean;
  render?: (context: LeafContext) => JSX.Element;
};

在具體的實現上,我們採用了例項化類的方式,當例項化之後我們可以不斷add外掛,因為toolbar等外掛是負責執行命令的,所以需要首先獲取前邊註冊完成的外掛的命令,將其傳入後再註冊到外掛當中,通過這種註冊的機制實現了統一的外掛管理,在apply之後,我們可以將返回的值傳入到<Editable />中,就可以將外掛正常的擴充到slate當中了。

const { renderElement, renderLeaf, onKeyDown, withVoidElements, commands } = useMemo(() => {
  const register = new SlatePlugins(
    ParagraphPlugin(),
    HeadingPlugin(editor),
    BoldPlugin(),
    QuoteBlockPlugin(editor),
    // ...
  );

  const commands = register.getCommands();
  register.add(
    DocToolBarPlugin(editor, props.isRender, commands),
    // ...
  );
return register.apply();
}, [editor, props.isRender]);

型別擴充

slate中預留了比較好的型別擴充機制,可以通過TypeScript中的declare module配合interface來擴充BlockElementTextElement的型別,使實現外掛的attributes有較為嚴格的型別校驗。

// base
export type BaseNode = BlockElement | TextElement;
declare module "slate" {
  interface BlockElement {
    children: BaseNode[];
    [key: string]: unknown;
  }
  interface TextElement {
    text: string;
    [key: string]: unknown;
  }
  interface CustomTypes {
    Editor: BaseEditor & ReactEditor;
    Element: BlockElement;
    Text: TextElement;
  }
}

// plugin
declare module "slate" {
  interface BlockElement {
    type?: { a: string; b: boolean };
  }
  interface TextElement {
    type?: boolean;
  }
}

實現方案

在這裡是具體的外掛實現方案與示例,每個部分都是一種型別的外掛的實現,具體的程式碼都可以在 Github 中找到。在外掛實現方面,整體還是藉助了HTML5的標籤來完成各種樣式,這樣能夠保持文件的標籤語義完整性但是會造成DOM結構巢狀比較深。使用純CSS來完成各種外掛也是沒問題的,而且實現上是更簡單一些的,context提供classList來操作className,只不過純CSS實現樣式的話標籤語義完整性就欠缺一些。這方面主要是個取捨問題,在此處實現的外掛都是藉助HTML5的標籤以及一些自定義的互動策略來完成的,互動的執行上都是通過外掛註冊命令後觸發實現的。

Leaf

leaf型別的外掛是行內的元素,例如加粗、斜體、下劃線、刪除線等等,在實現上只需要注意外掛的命令註冊與在該命令下如何渲染元素即可,下面是bold外掛的實現,主要是註冊了操作attributes的命令,以及使用<strong />作為渲染格式的標籤。

declare module "slate" {
  interface TextElement {
    bold?: boolean;
  }
}

export const boldPluginKey = "bold";
export const BoldPlugin = (): Plugin => {
  return {
    key: boldPluginKey,
    type: EDITOR_ELEMENT_TYPE.INLINE,
    match: props => !!props.leaf[boldPluginKey],
    command: (editor, key) => {
      Transforms.setNodes(
        editor,
        { [key]: true },
        { match: node => Text.isText(node), split: true }
      );
    },
    render: context => <strong>{context.children}</strong>,
  };
};

Element

element型別的外掛是屬於塊級元素,例如標題、段落、對齊等等,簡單來說是作用在行上的元素,在實現上不光要注意命令的註冊和渲染元素,還有注意各種case,尤其是在wrapper巢狀下的情況。在下面的heading示例中,在命令階段處理了是否已經處於heading狀態,如果處於改狀態那就取消heading,生成的id是為了之後作為錨點使用,在處理鍵盤事件的時候,就需要處理一些case,在這裡實現了我們回車的時候不希望在下一行繼承heading格式,以及當游標置於行最前點選刪除則會刪除該行標題格式。

declare module "slate" {
  interface BlockElement {
    heading?: { id: string; type: string };
  }
}

export const headingPluginKey = "heading";
const headingCommand: CommandFn = (editor, key, data) => {
  if (isObject(data) && data.path) {
    if (!isMatchedAttributeNode(editor, `${headingPluginKey}.type`, data.extraKey)) {
      setBlockNode(editor, { [key]: { type: data.extraKey, id: uuid().slice(0, 8) } }, data.path);
    } else {
      setBlockNode(editor, getOmitAttributes([headingPluginKey]), data.path);
    }
  }
};

export const HeadingPlugin = (editor: Editor): Plugin => {
  return {
    key: headingPluginKey,
    type: EDITOR_ELEMENT_TYPE.BLOCK,
    command: headingCommand,
    match: props => !!props.element[headingPluginKey],
    renderLine: context => {
      const heading = context.props.element[headingPluginKey];
      if (!heading) return context.children;
      const id = heading.id;
      switch (heading.type) {
        case "h1":
          return (
            <h1 className="doc-heading" id={id}>
              {context.children}
            </h1>
          );
        case "h2":
          return (
            <h2 className="doc-heading" id={id}>
              {context.children}
            </h2>
          );
        case "h3":
          return (
            <h3 className="doc-heading" id={id}>
              {context.children}
            </h3>
          );
        default:
          return context.children;
      }
    },
    onKeyDown: event => {
      if (
        isMatchedEvent(event, KEYBOARD.BACKSPACE, KEYBOARD.ENTER) &&
        isCollapsed(editor, editor.selection)
      ) {
        const match = getBlockNode(editor, editor.selection);

        if (match) {
          const { block, path } = match;
          if (!block[headingPluginKey]) return void 0;

          if (isSlateElement(block)) {
            if (event.key === KEYBOARD.BACKSPACE && isFocusLineStart(editor, path)) {
              const properties = getOmitAttributes([headingPluginKey]);
              Transforms.setNodes(editor, properties, { at: path });
              event.preventDefault();
            }
            if (event.key === KEYBOARD.ENTER && isFocusLineEnd(editor, path)) {
              const attributes = getBlockAttributes(block, [headingPluginKey]);
              if (isWrappedNode(editor)) {
                // 在`wrap`的情況下插入節點會出現問題 先多插入一個空格再刪除
                Transforms.insertNodes(
                  editor,
                  { ...attributes, children: [{ text: " " }] },
                  { at: editor.selection.focus, select: false }
                );
                Transforms.move(editor, { distance: 1 });
                Promise.resolve().then(() => editor.deleteForward("character"));
              } else {
                Transforms.insertNodes(editor, { ...attributes, children: [{ text: "" }] });
              }
              event.preventDefault();
            }
          }
        }
      }
    },
  };
};

Wrapper

wrapper型別的外掛同樣也是屬於塊級元素,例如引用塊、有序列表、無序列表等,簡單來說是在行上額外巢狀了一行,所以在實現上不光要注意命令的註冊和渲染元素,還有注意各種case,在wrapper下需要注意的case就特別多,所以我們也需要自己實現一些策略來避免這些問題。在下面的quote-block示例中,實現了支援一級塊引用,回車會繼承格式,作為wrapped外掛不能與其他wrapped外掛並行使用,行空且該行為wrapped首行或尾行時回車和刪除會取消該行塊引用格式,游標置於行最前點選刪除且該行為wrapped首行或尾行時則會取消該行塊引用格式。

declare module "slate" {
  interface BlockElement {
    "quote-block"?: boolean;
    "quote-block-item"?: boolean;
  }
}

export const quoteBlockKey = "quote-block";
export const quoteBlockItemKey = "quote-block-item";
const quoteCommand: CommandFn = (editor, key, data) => {
  if (isObject(data) && data.path) {
    if (!isMatchedAttributeNode(editor, quoteBlockKey, true, data.path)) {
      if (!isWrappedNode(editor)) {
        setWrapNodes(editor, { [key]: true }, data.path);
        setBlockNode(editor, { [quoteBlockItemKey]: true });
      }
    } else {
      setUnWrapNodes(editor, quoteBlockKey);
      setBlockNode(editor, getOmitAttributes([quoteBlockItemKey, quoteBlockKey]));
    }
  }
};
export const QuoteBlockPlugin = (editor: Editor): Plugin => {
  return {
    key: quoteBlockKey,
    type: EDITOR_ELEMENT_TYPE.BLOCK,
    match: props => !!props.element[quoteBlockKey],
    renderLine: context => (
      <blockquote className="slate-quote-block">{context.children}</blockquote>
    ),
    command: quoteCommand,
    onKeyDown: event => {
      if (
        isMatchedEvent(event, KEYBOARD.BACKSPACE, KEYBOARD.ENTER) &&
        isCollapsed(editor, editor.selection)
      ) {
        const quoteMatch = getBlockNode(editor, editor.selection, quoteBlockKey);
        const quoteItemMatch = getBlockNode(editor, editor.selection, quoteBlockItemKey);
        if (quoteMatch && !quoteItemMatch) setUnWrapNodes(editor, quoteBlockKey);
        if (!quoteMatch && quoteItemMatch) {
          setBlockNode(editor, getOmitAttributes([quoteBlockItemKey]));
        }
        if (!quoteMatch || !quoteItemMatch) return void 0;

        if (isFocusLineStart(editor, quoteItemMatch.path)) {
          if (
            !isWrappedEdgeNode(editor, editor.selection, quoteBlockKey, quoteBlockItemKey, "or")
          ) {
            if (isMatchedEvent(event, KEYBOARD.BACKSPACE)) {
              editor.deleteBackward("block");
              event.preventDefault();
            }
          } else {
            setUnWrapNodes(editor, quoteBlockKey);
            setBlockNode(editor, getOmitAttributes([quoteBlockItemKey, quoteBlockKey]));
            event.preventDefault();
          }
        }
      }
    },
  };
};

Void

void型別的外掛同樣也是屬於塊級元素,例如分割線、圖片、視訊等,void元素應該是一個空元素,他會有一個空的用於渲染的文字子節點,並且是不可編輯的,所以是一類單獨的節點型別。在下面的dividing-line示例中,主要需要注意分割線的選中以及void節點的定義。

declare module "slate" {
  interface BlockElement {
    "dividing-line"?: boolean;
  }
}

export const dividingLineKey = "dividing-line";

const DividingLine: React.FC = () => {
  const selected = useSelected();
  const focused = useFocused();
  return <div className={cs("dividing-line", focused && selected && "selected")}></div>;
};
export const DividingLinePlugin = (): Plugin => {
  return {
    key: dividingLineKey,
    isVoid: true,
    type: EDITOR_ELEMENT_TYPE.BLOCK,
    command: (editor, key) => {
      Transforms.insertNodes(editor, { [key]: true, children: [{ text: "" }] });
      Transforms.insertNodes(editor, { children: [{ text: "" }] });
    },
    match: props => existKey(props.element, dividingLineKey),
    render: () => <DividingLine></DividingLine>,
  };
};

Toolbar

toolbar型別的外掛是屬於自定義的一類單獨的外掛,主要是用於執行命令,因為我們在外掛定義的時候註冊了命令,那麼也就意味著我們完全可以通過命令來驅動節點的變化,toolbar就是用於執行命令的外掛。在下面的doc-toolbar示例中,我們可以看到如何實現左側的懸浮選單以及命令的執行等。

const DocMenu: React.FC<{
  editor: Editor;
  element: RenderElementProps["element"];
  commands: SlateCommands;
}> = props => {
  const [visible, setVisible] = useState(false);

  const affixStyles = (param: string) => {
    setVisible(false);
    const [key, data] = param.split(".");
    const path = ReactEditor.findPath(props.editor, props.element);
    focusSelection(props.editor, path);
    execCommand(props.editor, props.commands, key, { extraKey: data, path });
  };
  const MenuPopup = (
    <Menu onClickMenuItem={affixStyles} className="doc-menu-popup">
      <Menu.Item key="heading.h1">
        <IconH1 />
        一級標題
      </Menu.Item>
      <Menu.Item key="heading.h2">
        <IconH2 />
        二級標題
      </Menu.Item>
      <Menu.Item key="heading.h3">
        <IconH3 />
        三級標題
      </Menu.Item>
      <Menu.Item key="quote-block">
        <IconQuote />
        塊級引用
      </Menu.Item>
      <Menu.Item key="ordered-list">
        <IconOrderedList />
        有序列表
      </Menu.Item>
      <Menu.Item key="unordered-list">
        <IconUnorderedList />
        無序列表
      </Menu.Item>
      <Menu.Item key="dividing-line">
        <IconEdit />
        分割線
      </Menu.Item>
    </Menu>
  );
  return (
    <Trigger
      popup={() => MenuPopup}
      position="bottom"
      popupVisible={visible}
      onVisibleChange={setVisible}
    >
      <span
        className="doc-icon-plus"
        onMouseDown={e => e.preventDefault()} // prevent toolbar from taking focus away from editor
      >
        <IconPlusCircle />
      </span>
    </Trigger>
  );
};

const NO_DOC_TOOL_BAR = ["quote-block", "ordered-list", "unordered-list", "dividing-line"];
const OFFSET_MAP: Record<string, number> = {
  "quote-block-item": 12,
};
export const DocToolBarPlugin = (
  editor: Editor,
  isRender: boolean,
  commands: SlateCommands
): Plugin => {
  return {
    key: "doc-toolbar",
    priority: 13,
    type: EDITOR_ELEMENT_TYPE.BLOCK,
    match: () => true,
    renderLine: context => {
      if (isRender) return context.children;
      for (const item of NO_DOC_TOOL_BAR) {
        if (context.element[item]) return context.children;
      }
      let offset = 0;
      for (const item of Object.keys(OFFSET_MAP)) {
        if (context.element[item]) {
          offset = OFFSET_MAP[item] || 0;
          break;
        }
      }
      return (
        <Trigger
          popup={() => <DocMenu editor={editor} commands={commands} element={context.element} />}
          position="left"
          popupAlign={{ left: offset }}
          mouseLeaveDelay={200}
          mouseEnterDelay={200}
        >
          <div>{context.children}</div>
        </Trigger>
      );
    },
  };
};

Shortcut

shortcut型別的外掛是屬於自定義的一類單獨的外掛,同樣也是用於快捷鍵執行命令,這也是使用命令驅動的一種實現。在下面的shortcut示例中,我們可以看到如何處理快捷鍵的輸入以及命令的執行等。

const SHORTCUTS: Record<string, string> = {
  "1.": "ordered-list",
  "-": "unordered-list",
  "*": "unordered-list",
  ">": "quote-block",
  "#": "heading.h1",
  "##": "heading.h2",
  "###": "heading.h3",
  "---": "dividing-line",
};

export const ShortCutPlugin = (editor: Editor, commands: SlateCommands): Plugin => {
  return {
    key: "shortcut",
    type: EDITOR_ELEMENT_TYPE.BLOCK,
    match: () => false,
    onKeyDown: event => {
      if (isMatchedEvent(event, KEYBOARD.SPACE) && isCollapsed(editor, editor.selection)) {
        const match = getBlockNode(editor);
        if (match) {
          const { anchor } = editor.selection;
          const { path } = match;
          const start = Editor.start(editor, path);
          const range = { anchor, focus: start };
          const beforeText = Editor.string(editor, range);
          const param = SHORTCUTS[beforeText.trim()];
          if (param) {
            Transforms.select(editor, range);
            Transforms.delete(editor);
            const [key, data] = param.split(".");
            execCommand(editor, commands, key, { extraKey: data, path });
            event.preventDefault();
          }
        }
      }
    },
  };
};

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://docs.slatejs.org/
https://github.com/ianstormtaylor/slate
https://www.slatejs.org/examples/richtext
http://t.zoukankan.com/kagol-p-14820617.html
https://rain120.github.io/athena/zh/slate/Introduction.html
https://www.wangeditor.com/v5/#%E6%8A%80%E6%9C%AF%E8%80%81%E6%97%A7

相關文章