<input id="ohw05"></input>
  • <table id="ohw05"><menu id="ohw05"></menu></table>
  • <var id="ohw05"></var>
  • <code id="ohw05"><cite id="ohw05"></cite></code>
    <label id="ohw05"></label>
    <var id="ohw05"></var>
  • 基于slate構建文檔編輯器

    基于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
    
    posted @ 2022-06-26 09:46  WindrunnerMax  閱讀(114)  評論(2編輯  收藏  舉報
    国产美女a做受大片观看