Web-Design
Friday May 21, 2021 By David Quintanilla
Building A Rich Text Editor (WYSIWYG) From Scratch — Smashing Magazine


About The Creator

Shalabh Vyas is a Entrance-Finish Engineer with the expertise of working by means of your complete product-development lifecycle launching wealthy web-based purposes. …
More about
Shalabh

On this article, we’ll learn to construct a WYSIWYG/Wealthy-Textual content Editor that helps wealthy textual content, photographs, hyperlinks and a few nuanced options from phrase processing apps. We are going to use SlateJS to construct the shell of the editor after which add a toolbar and customized configurations. The code for the applying is available on GitHub for reference.

Lately, the sphere of Content material Creation and Illustration on Digital platforms has seen a large disruption. The widespread success of merchandise like Quip, Google Docs and Dropbox Paper has proven how firms are racing to construct the most effective expertise for content material creators within the enterprise area and looking for progressive methods of breaking the standard moulds of how content material is shared and consumed. Benefiting from the huge outreach of social media platforms, there’s a new wave of impartial content material creators utilizing platforms like Medium to create content material and share it with their viewers.

As so many individuals from totally different professions and backgrounds attempt to create content material on these merchandise, it’s essential that these merchandise present a performant and seamless expertise of content material creation and have groups of designers and engineers who develop some degree of area experience over time on this area. With this text, we attempt to not solely lay the inspiration of constructing an editor but additionally give the readers a glimpse into how little nuggets of functionalities when introduced collectively can create a fantastic consumer expertise for a content material creator.

Understanding The Doc Construction

Earlier than we dive into constructing the editor, let’s take a look at how a doc is structured for a Wealthy Textual content Editor and what are the several types of knowledge constructions concerned.

Doc Nodes

Doc nodes are used to symbolize the contents of the doc. The frequent sorts of nodes {that a} rich-text doc may include are paragraphs, headings, photographs, movies, code-blocks and pull-quotes. A few of these could include different nodes as youngsters inside them (e.g. Paragraph nodes include textual content nodes inside them). Nodes additionally maintain any properties particular to the article they symbolize which might be wanted to render these nodes contained in the editor. (e.g. Picture nodes include a picture src property, Code-blocks could include a language property and so forth).

There are largely two sorts of nodes that symbolize how they need to be rendered –

  • Block Nodes (analogous to HTML idea of Block-level components) which might be every rendered on a brand new line and occupy the accessible width. Block nodes may include different block nodes or inline nodes inside them. An remark right here is that the top-level nodes of a doc would at all times be block nodes.
  • Inline Nodes (analogous to HTML idea of Inline components) that begin rendering on the identical line because the earlier node. There are some variations in how inline components are represented in several enhancing libraries. SlateJS permits for inline components to be nodes themselves. DraftJS, one other well-liked Wealthy Textual content Modifying library, enables you to use the idea of Entities to render inline components. Hyperlinks and Inline Photos are examples of Inline nodes.
  • Void Nodes — SlateJS additionally permits this third class of nodes that we are going to use later on this article to render media.

If you wish to be taught extra about these classes, SlateJS’s documentation on Nodes is an effective place to start out.

Attributes

Much like HTML’s idea of attributes, attributes in a Wealthy Textual content Doc are used to symbolize non-content properties of a node or it’s youngsters. As an illustration, a textual content node can have character-style attributes that inform us whether or not the textual content is daring/italic/underlined and so forth. Though this text represents headings as nodes themselves, one other approach to symbolize them might be that nodes have paragraph-styles (paragraph & h1-h6) as attributes on them.

Beneath picture provides an instance of how a doc’s construction (in JSON) is described at a extra granular degree utilizing nodes and attributes highlighting a number of the components within the construction to the left.

Image showing an example document inside the editor with its structure representation on the left
Instance Doc and its structural illustration. (Large preview)

Among the issues value calling out right here with the construction are:

  • Textual content nodes are represented as {textual content: 'textual content content material'}
  • Properties of the nodes are saved instantly on the node (e.g. url for hyperlinks and caption for photographs)
  • SlateJS-specific illustration of textual content attributes breaks the textual content nodes to be their very own nodes if the character model adjustments. Therefore, the textual content ‘Duis aute irure dolor’ is a textual content node of it’s personal with daring: true set on it. Similar is the case with the italic, underline and code model textual content on this doc.

Places And Choice

When constructing a wealthy textual content editor, it’s essential to have an understanding of how essentially the most granular a part of a doc (say a personality) might be represented with some kind of coordinates. This helps us navigate the doc construction at runtime to grasp the place within the doc hierarchy we’re. Most significantly, location objects give us a approach to symbolize consumer choice which is sort of extensively used to tailor the consumer expertise of the editor in actual time. We are going to use choice to construct our toolbar later on this article. Examples of those might be:

  • Is the consumer’s cursor presently inside a hyperlink, perhaps we should always present them a menu to edit/take away the hyperlink?
  • Has the consumer chosen a picture? Possibly we give them a menu to resize the picture.
  • If the consumer selects sure textual content and hits the DELETE button, we decide what consumer’s chosen textual content was and take away that from the doc.

SlateJS’s doc on Location explains these knowledge constructions extensively however we undergo them right here shortly as we use these phrases at totally different situations within the article and present an instance within the diagram that follows.

  • Path
    Represented by an array of numbers, a path is the best way to get to a node within the doc. As an illustration, a path [2,3] represents the third youngster node of the 2nd node within the doc.
  • Level
    Extra granular location of content material represented by path + offset. As an illustration, a degree of {path: [2,3], offset: 14} represents the 14th character of the third youngster node contained in the 2nd node of the doc.
  • Vary
    A pair of factors (known as anchor and focus) that symbolize a variety of textual content contained in the doc. This idea comes from Net’s Selection API the place anchor is the place consumer’s choice started and focus is the place it ended. A collapsed vary/choice denotes the place anchor and focus factors are the identical (consider a blinking cursor in a textual content enter for example).

For example let’s say that the consumer’s choice in our above doc instance is ipsum:

Image with the text ` ipsum` selected in the editor
Consumer selects the phrase ipsum. (Large preview)

The consumer’s choice might be represented as:

{
  anchor: {path: [2,0], offset: 5}, /*0th textual content node contained in the paragraph node which itself is index 2 within the doc*/
  focus: {path: [2,0], offset: 11}, // area + 'ipsum'
}`

Setting Up The Editor

On this part, we’re going to arrange the applying and get a primary rich-text editor going with SlateJS. The boilerplate utility can be create-react-app with SlateJS dependencies added to it. We’re constructing the UI of the applying utilizing elements from react-bootstrap. Let’s get began!

Create a folder known as wysiwyg-editor and run the under command from contained in the listing to arrange the react app. We then run a yarn begin command that ought to spin up the native net server (port defaulting to 3000) and present you a React welcome display screen.

npx create-react-app .
yarn begin

We then transfer on so as to add the SlateJS dependencies to the applying.

yarn add slate slate-react

slate is SlateJS’s core bundle and slate-react consists of the set of React elements we’ll use to render Slate editors. SlateJS exposes some extra packages organized by performance one would possibly think about including to their editor.

We first create a utils folder that holds any utility modules we create on this utility. We begin with creating an ExampleDocument.js that returns a primary doc construction that accommodates a paragraph with some textual content. This module seems to be like under:

const ExampleDocument = [
  {
    type: "paragraph",
    children: [
      { text: "Hello World! This is my paragraph inside a sample document." },
    ],
  },
];

export default ExampleDocument;

We now add a folder known as elements that may maintain all our React elements and do the next:

  • Add our first React element Editor.js to it. It solely returns a div for now.
  • Replace the App.js element to carry the doc in its state which is initialized to our ExampleDocument above.
  • Render the Editor contained in the app and cross the doc state and an onChange handler right down to the Editor so our doc state is up to date because the consumer updates it.
  • We use React bootstrap’s Nav elements so as to add a navigation bar to the applying as effectively.

App.js element now seems to be like under:

import Editor from './elements/Editor';

operate App() {
  const [document, updateDocument] = useState(ExampleDocument);

  return (
    <>
      <Navbar bg="darkish" variant="darkish">
        <Navbar.Model href="https://smashingmagazine.com/2021/05/building-wysiwyg-editor-javascript-slatejs/#">
          <img
            alt=""
            src="/app-icon.png"
            width="30"
            peak="30"
            className="d-inline-block align-top"
          />{" "}
          WYSIWYG Editor
        </Navbar.Model>
      </Navbar>
      <div className="App">
        <Editor doc={doc} onChange={updateDocument} />
      </div>
    </>
  );

Contained in the Editor element, we then instantiate the SlateJS editor and maintain it inside a useMemo in order that the article doesn’t change in between re-renders.

// dependencies imported as under.
import { withReact } from "slate-react";
import { createEditor } from "slate";

const editor = useMemo(() => withReact(createEditor()), []);

createEditor provides us the SlateJS editor occasion which we use extensively by means of the applying to entry alternatives, run knowledge transformations and so forth. withReact is a SlateJS plugin that provides React and DOM behaviors to the editor object. SlateJS Plugins are Javascript features that obtain the editor object and connect some configuration to it. This permits net builders so as to add configurations to their SlateJS editor occasion in a composable manner.

We now import and render <Slate /> and <Editable /> elements from SlateJS with the doc prop we get from App.js. Slate exposes a bunch of React contexts we use to entry within the utility code. Editable is the element that renders the doc hierarchy for enhancing. Total, the Editor.js module at this stage seems to be like under:

import { Editable, Slate, withReact } from "slate-react";

import { createEditor } from "slate";
import { useMemo } from "react";

export default operate Editor({ doc, onChange }) {
  const editor = useMemo(() => withReact(createEditor()), []);
  return (
    <Slate editor={editor} worth={doc} onChange={onChange}>
      <Editable />
    </Slate>
  );
}

At this level, now we have mandatory React elements added and the editor populated with an instance doc. Our Editor must be now arrange permitting us to kind in and alter the content material in actual time — as within the screencast under.

Primary Editor Setup in motion

Now, let’s transfer on to the following part the place we configure the editor to render character types and paragraph nodes.

CUSTOM TEXT RENDERING AND A TOOLBAR

Paragraph Fashion Nodes

At the moment, our editor makes use of SlateJS’s default rendering for any new node sorts we could add to the doc. On this part, we would like to have the ability to render the heading nodes. To have the ability to do this, we offer a renderElement operate prop to Slate’s elements. This operate will get known as by Slate at runtime when it’s attempting to traverse the doc tree and render every node. The renderElement operate will get three parameters —

  • attributes
    SlateJS particular that should must be utilized to the top-level DOM ingredient being returned from this operate.
  • ingredient
    The node object itself because it exists within the doc construction
  • youngsters
    The youngsters of this node as outlined within the doc construction.

We add our renderElement implementation to a hook known as useEditorConfig the place we’ll add extra editor configurations as we go. We then use the hook on the editor occasion inside Editor.js.

import { DefaultElement } from "slate-react";

export default operate useEditorConfig(editor) {
  return { renderElement };
}

operate renderElement(props) {
  const { ingredient, youngsters, attributes } = props;
  swap (ingredient.kind) {
    case "paragraph":
      return <p {...attributes}>{youngsters}</p>;
    case "h1":
      return <h1 {...attributes}>{youngsters}</h1>;
    case "h2":
      return <h2 {...attributes}>{youngsters}</h2>;
    case "h3":
      return <h3 {...attributes}>{youngsters}</h3>;
    case "h4":
      return <h4 {...attributes}>{youngsters}</h4>;
    default:
      // For the default case, we delegate to Slate's default rendering. 
      return <DefaultElement {...props} />;
  }
}

Since this operate provides us entry to the ingredient (which is the node itself), we are able to customise renderElement to implement a extra custom-made rendering that does extra than simply checking ingredient.kind. As an illustration, you might have a picture node that has a isInline property that we may use to return a distinct DOM construction that helps us render inline photographs as in opposition to block photographs.

We now replace the Editor element to make use of this hook as under:

const { renderElement } = useEditorConfig(editor);

return (
    ...
    <Editable renderElement={renderElement} />
);

With the customized rendering in place, we replace the ExampleDocument to incorporate our new node sorts and confirm that they render appropriately contained in the editor.

const ExampleDocument = [
  {
    type: "h1",
    children: [{ text: "Heading 1" }],
  },
  {
    kind: "h2",
    youngsters: [{ text: "Heading 2" }],
  },
 // ...extra heading nodes
Image showing different headings and paragraph nodes rendered in the editor
Headings and Paragraph nodes within the Editor. (Large preview)

Character Types

Much like renderElement, SlateJS provides out a operate prop known as renderLeaf that can be utilized to customise rendering of the textual content nodes (Leaf referring to textual content nodes that are the leaves/lowest degree nodes of the doc tree). Following the instance of renderElement, we write an implementation for renderLeaf.

export default operate useEditorConfig(editor) {
  return { renderElement, renderLeaf };
}

// ...
operate renderLeaf({ attributes, youngsters, leaf }) {
  let el = <>{youngsters}</>;

  if (leaf.daring) {
    el = <sturdy>{el}</sturdy>;
  }

  if (leaf.code) {
    el = <code>{el}</code>;
  }

  if (leaf.italic) {
    el = <em>{el}</em>;
  }

  if (leaf.underline) {
    el = <u>{el}</u>;
  }

  return <span {...attributes}>{el}</span>;
}

An essential remark of the above implementation is that it permits us to respect HTML semantics for character types. Since renderLeaf provides us entry to the textual content node leaf itself, we are able to customise the operate to implement a extra custom-made rendering. As an illustration, you may need a approach to let customers select a highlightColor for textual content and test that leaf property right here to connect the respective types.

We now replace the Editor element to make use of the above, the ExampleDocument to have a couple of textual content nodes within the paragraph with mixtures of those types and confirm that they’re rendered as anticipated within the Editor with the semantic tags we used.

# src/elements/Editor.js

const { renderElement, renderLeaf } = useEditorConfig(editor);

return (
    ...
    <Editable renderElement={renderElement} renderLeaf={renderLeaf} />
);
# src/utils/ExampleDocument.js

{
    kind: "paragraph",
    youngsters: [
      { text: "Hello World! This is my paragraph inside a sample document." },
      { text: "Bold text.", bold: true, code: true },
      { text: "Italic text.", italic: true },
      { text: "Bold and underlined text.", bold: true, underline: true },
      { text: "variableFoo", code: true },
    ],
  },
Character styles in UI and how they are rendered in DOM tree
Character types in UI and the way they’re rendered in DOM tree. (Large preview)

Including A Toolbar

Let’s start by including a brand new element Toolbar.js to which we add a couple of buttons for character types and a dropdown for paragraph types and we wire these up later within the part.

const PARAGRAPH_STYLES = ["h1", "h2", "h3", "h4", "paragraph", "multiple"];
const CHARACTER_STYLES = ["bold", "italic", "underline", "code"];

export default operate Toolbar({ choice, previousSelection }) {
  return (
    <div className="toolbar">
      {/* Dropdown for paragraph types */}
      <DropdownButton
        className={"block-style-dropdown"}
        disabled={false}
        id="block-style"
        title={getLabelForBlockStyle("paragraph")}
      >
        {PARAGRAPH_STYLES.map((blockType) => (
          <Dropdown.Merchandise eventKey={blockType} key={blockType}>
            {getLabelForBlockStyle(blockType)}
          </Dropdown.Merchandise>
        ))}
      </DropdownButton>
      {/* Buttons for character types */}
      {CHARACTER_STYLES.map((model) => (
        <ToolBarButton
          key={model}
          icon={<i className={`bi ${getIconForButton(model)}`} />}
          isActive={false}
        />
      ))}
    </div>
  );
}

operate ToolBarButton(props) {
  const { icon, isActive, ...otherProps } = props;
  return (
    <Button
      variant="outline-primary"
      className="toolbar-btn"
      energetic={isActive}
      {...otherProps}
    >
      {icon}
    </Button>
  );
}

We summary away the buttons to the ToolbarButton element that could be a wrapper across the React Bootstrap Button element. We then render the toolbar above the Editable inside Editor element and confirm that the toolbar exhibits up within the utility.

Image showing toolbar with buttons rendered above the editor
Toolbar with buttons (Large preview)

Listed here are the three key functionalities we’d like the toolbar to help:

  1. When the consumer’s cursor is in a sure spot within the doc and so they click on one of many character model buttons, we have to toggle the model for the textual content they might kind subsequent.
  2. When the consumer selects a variety of textual content and click on one of many character model buttons, we have to toggle the model for that particular part.
  3. When the consumer selects a variety of textual content, we need to replace the paragraph-style dropdown to replicate the paragraph-type of the choice. In the event that they do choose a distinct worth from the choice, we need to replace the paragraph model of your complete choice to be what they chose.

Let’s take a look at how these functionalities work on the Editor earlier than we begin implementing them.

Character Types toggling conduct

Listening To Choice

Crucial factor the Toolbar wants to have the ability to carry out the above features is the Choice state of the doc. As of writing this text, SlateJS doesn’t expose a onSelectionChange methodology that might give us the most recent choice state of the doc. Nevertheless, as choice adjustments within the editor, SlateJS does name the onChange methodology, even when the doc contents haven’t modified. We use this as a approach to be notified of choice change and retailer it within the Editor element’s state. We summary this to a hook useSelection the place we may do a extra optimum replace of the choice state. That is essential as choice is a property that adjustments very often for a WYSIWYG Editor occasion.

import areEqual from "deep-equal";

export default operate useSelection(editor) {
  const [selection, setSelection] = useState(editor.choice);
  const setSelectionOptimized = useCallback(
    (newSelection) => {
      // do not replace the element state if choice hasn't modified.
      if (areEqual(choice, newSelection)) {
        return;
      }
      setSelection(newSelection);
    },
    [setSelection, selection]
  );

  return [selection, setSelectionOptimized];
}

We use this hook contained in the Editor element as under and cross the choice to the Toolbar element.

const [selection, setSelection] = useSelection(editor);

  const onChangeHandler = useCallback(
    (doc) => {
      onChange(doc);
      setSelection(editor.choice);
    },
    [editor.selection, onChange, setSelection]
  );

  return (
    <Slate editor={editor} worth={doc} onChange={onChangeHandler}>
        <Toolbar choice={choice} />
        ...
Efficiency Consideration

In an utility the place now we have a a lot larger Editor codebase with much more functionalities, you will need to retailer and hearken to choice adjustments in a performant manner (like utilizing some state administration library) as elements listening to choice adjustments are more likely to render too typically. A method to do that is to have optimized selectors on prime of the Choice state that maintain particular choice info. As an illustration, an editor would possibly need to render a picture resizing menu when an Picture is chosen. In such a case, it is likely to be useful to have a selector isImageSelected computed from the editor’s choice state and the Picture menu would re-render solely when this selector’s worth adjustments. Redux’s Reselect is one such library that permits constructing selectors.

We don’t use choice contained in the toolbar till later however passing it down as a prop makes the toolbar re-render every time the choice adjustments on the Editor. We do that as a result of we can not rely solely on the doc content material change to set off a re-render on the hierarchy (App -> Editor -> Toolbar) as customers would possibly simply preserve clicking across the doc thereby altering choice however by no means really altering the doc content material itself.

Toggling Character Types

We now transfer to getting what the energetic character types are from SlateJS and utilizing these contained in the Editor. Let’s add a brand new JS module EditorUtils that may host all of the util features we construct going ahead to get/do stuff with SlateJS. Our first operate within the module is getActiveStyles that provides a Set of energetic types within the editor. We additionally add a operate to toggle a method on the editor operate — toggleStyle:

# src/utils/EditorUtils.js

import { Editor } from "slate";

export operate getActiveStyles(editor) {
  return new Set(Object.keys(Editor.marks(editor) ?? {}));
}

export operate toggleStyle(editor, model) {
  const activeStyles = getActiveStyles(editor);
  if (activeStyles.has(model)) {
    Editor.removeMark(editor, model);
  } else {
    Editor.addMark(editor, model, true);
  }
}

Each the features take the editor object which is the Slate occasion as a parameter as will a whole lot of util features we add later within the article.In Slate terminology, formatting types are known as Marks and we use helper strategies on Editor interface to get, add and take away these marks.We import these util features contained in the Toolbar and wire them to the buttons we added earlier.

# src/elements/Toolbar.js

import { getActiveStyles, toggleStyle } from "../utils/EditorUtils";
import { useEditor } from "slate-react";

export default operate Toolbar({ choice }) {
  const editor = useEditor();

return <div
...
    {CHARACTER_STYLES.map((model) => (
        <ToolBarButton
          key={model}
          characterStyle={model}
          icon={<i className={`bi ${getIconForButton(model)}`} />}
          isActive={getActiveStyles(editor).has(model)}
          onMouseDown={(occasion) => {
            occasion.preventDefault();
            toggleStyle(editor, model);
          }}
        />
      ))}
</div>

useEditor is a Slate hook that provides us entry to the Slate occasion from the context the place it was hooked up by the &lt;Slate> element larger up within the render hierarchy.

One would possibly marvel why we use onMouseDown right here as a substitute of onClick? There’s an open Github Issue about how Slate turns the choice to null when the editor loses focus in any manner. So, if we connect onClick handlers to our toolbar buttons, the choice turns into null and customers lose their cursor place attempting to toggle a method which isn’t a fantastic expertise. We as a substitute toggle the model by attaching a onMouseDown occasion which prevents the choice from getting reset. One other manner to do that is to maintain monitor of the choice ourselves so we all know what the final choice was and use that to toggle the types. We do introduce the idea of previousSelection later within the article however to unravel a distinct drawback.

SlateJS permits us to configure occasion handlers on the Editor. We use that to wire up keyboard shortcuts to toggle the character types. To do this, we add a KeyBindings object inside useEditorConfig the place we expose a onKeyDown occasion handler hooked up to the Editable element. We use the is-hotkey util to find out the important thing mixture and toggle the corresponding model.

# src/hooks/useEditorConfig.js

export default operate useEditorConfig(editor) {
  const onKeyDown = useCallback(
    (occasion) => KeyBindings.onKeyDown(editor, occasion),
    [editor]
  );
  return { renderElement, renderLeaf, onKeyDown };
}

const KeyBindings = {
  onKeyDown: (editor, occasion) => {
    if (isHotkey("mod+b", occasion)) {
      toggleStyle(editor, "daring");
      return;
    }
    if (isHotkey("mod+i", occasion)) {
      toggleStyle(editor, "italic");
      return;
    }
    if (isHotkey("mod+c", occasion)) {
      toggleStyle(editor, "code");
      return;
    }
    if (isHotkey("mod+u", occasion)) {
      toggleStyle(editor, "underline");
      return;
    }
  },
};

# src/elements/Editor.js
...
 <Editable
   renderElement={renderElement}
   renderLeaf={renderLeaf}
   onKeyDown={onKeyDown}
 />
Character types toggled utilizing keyboard shortcuts.

Making Paragraph Fashion Dropdown Work

Let’s transfer on to creating the Paragraph Types dropdown work. Much like how paragraph-style dropdowns work in well-liked Phrase Processing purposes like MS Phrase or Google Docs, we would like types of the highest degree blocks in consumer’s choice to be mirrored within the dropdown. If there’s a single constant model throughout the choice, we replace the dropdown worth to be that. If there are a number of of these, we set the dropdown worth to be ‘A number of’. This conduct should work for each — collapsed and expanded alternatives.

To implement this conduct, we’d like to have the ability to discover the top-level blocks spanning the consumer’s choice. To take action, we use Slate’s Editor.nodes — A helper operate generally used to seek for nodes in a tree filtered by totally different choices.

nodes(
    editor: Editor,
    choices?:  'lowest'
      common?: boolean
      reverse?: boolean
      voids?: boolean
    
  ) => Generator<NodeEntry<T>, void, undefined>

The helper operate takes an Editor occasion and an choices object that could be a approach to filter nodes within the tree because it traverses it. The operate returns a generator of NodeEntry. A NodeEntry in Slate terminology is a tuple of a node and the trail to it — [node, pathToNode]. The choices discovered right here can be found on a lot of the Slate helper features. Let’s undergo what every of these means:

  • at
    This could be a Path/Level/Vary that the helper operate would use to scope down the tree traversal to. This defaults to editor.choice if not supplied. We additionally use the default for our use case under as we’re fascinated about nodes inside consumer’s choice.
  • match
    It is a matching operate one can present that is known as on every node and included if it’s a match. We use this parameter in our implementation under to filter to dam components solely.
  • mode
    Let’s the helper features know if we’re fascinated about all, highest-level or lowest degree nodes at the given location matching match operate. This parameter (set to highest) helps us escape attempting to traverse the tree up ourselves to search out the top-level nodes.
  • common
    Flag to decide on between full or partial matches of the nodes. (GitHub Issue with the proposal for this flag has some examples explaining it)
  • reverse
    If the node search must be within the reverse course of the beginning and finish factors of the situation handed in.
  • voids
    If the search ought to filter to void components solely.

SlateJS exposes a whole lot of helper features that allow you to question for nodes in several methods, traverse the tree, replace the nodes or alternatives in complicated methods. Value digging into a few of these interfaces (listed in the direction of the top of this text) when constructing complicated enhancing functionalities on prime of Slate.

With that background on the helper operate, under is an implementation of getTextBlockStyle.

# src/utils/EditorUtils.js 

export operate getTextBlockStyle(editor) {
  const choice = editor.choice;
  if (choice == null) {
    return null;
  }

  const topLevelBlockNodesInSelection = Editor.nodes(editor, {
    at: editor.choice,
    mode: "highest",
    match: (n) => Editor.isBlock(editor, n),
  });

  let blockType = null;
  let nodeEntry = topLevelBlockNodesInSelection.subsequent();
  whereas (!nodeEntry.performed) {
    const [node, _] = nodeEntry.worth;
    if (blockType == null) {
      blockType = node.kind;
    } else if (blockType !== node.kind) {
      return "a number of";
    }

    nodeEntry = topLevelBlockNodesInSelection.subsequent();
  }

  return blockType;
}
Efficiency Consideration

The present implementation of Editor.nodes finds all of the nodes all through the tree throughout all ranges which might be inside the vary of the at param after which runs match filters on it (test nodeEntries and the filtering later — source). That is okay for smaller paperwork. Nevertheless, for our use case, if the consumer chosen, say 3 headings and a couple of paragraphs (every paragraph containing say 10 textual content nodes), it is going to cycle by means of at the least 25 nodes (3 + 2 + 2*10) and attempt to run filters on them. Since we already know we’re fascinated about top-level nodes solely, we may discover begin and finish indexes of the highest degree blocks from the choice and iterate ourselves. Such a logic would loop by means of solely 3 node entries (2 headings and 1 paragraph). Code for that may look one thing like under:

export operate getTextBlockStyle(editor) {
  const choice = editor.choice;
  if (choice == null) {
    return null;
  }
  // provides the forward-direction factors in case the choice was
  // was backwards.
  const [start, end] = Vary.edges(choice);

  //path[0] provides us the index of the top-level block.
  let startTopLevelBlockIndex = begin.path[0];
  const endTopLevelBlockIndex = finish.path[0];

  let blockType = null;
  whereas (startTopLevelBlockIndex 

As we add extra functionalities to a WYSIWYG Editor and must traverse the doc tree typically, you will need to take into consideration essentially the most performant methods to take action for the use case at hand because the accessible API or helper strategies may not at all times be essentially the most environment friendly manner to take action.

As soon as now we have getTextBlockStyle applied, toggling of the block model is comparatively simple. If the present model will not be what consumer chosen within the dropdown, we toggle the model to that. Whether it is already what consumer chosen, we toggle it to be a paragraph. As a result of we’re representing paragraph types as nodes in our doc construction, toggle a paragraph model primarily means altering the kind property on the node. We use Transforms.setNodes supplied by Slate to replace properties on nodes.

Our toggleBlockType’s implementation is as under:

# src/utils/EditorUtils.js

export operate toggleBlockType(editor, blockType) {
  const currentBlockType = getTextBlockStyle(editor);
  const changeTo = currentBlockType === blockType ? "paragraph" : blockType;
  Transforms.setNodes(
    editor,
    { kind: changeTo },
     // Node filtering choices supported right here too. We use the identical
     // we used with Editor.nodes above.
    { at: editor.choice, match: (n) => Editor.isBlock(editor, n) }
  );
}

Lastly, we replace our Paragraph-Fashion dropdown to make use of these utility features.

#src/elements/Toolbar.js

const onBlockTypeChange = useCallback(
    (targetType) => {
      if (targetType === "a number of") {
        return;
      }
      toggleBlockType(editor, targetType);
    },
    [editor]
  );

  const blockType = getTextBlockStyle(editor);

return (
    <div className="toolbar">
      <DropdownButton
        .....
        disabled={blockType == null}  
        title={getLabelForBlockStyle(blockType ?? "paragraph")}
        onSelect={onBlockTypeChange}
      >
        {PARAGRAPH_STYLES.map((blockType) => (
          <Dropdown.Merchandise eventKey={blockType} key={blockType}>
            {getLabelForBlockStyle(blockType)}
          </Dropdown.Merchandise>
        ))}
      </DropdownButton>
....
);
Choosing a number of block sorts and altering the kind with the dropdown.

On this part, we’re going to add help to indicate, add, take away and alter hyperlinks. We can even add a Hyperlink-Detector performance — fairly much like how Google Docs or MS Phrase that scan the textual content typed by the consumer and checks if there are hyperlinks in there. If there are, they’re transformed into hyperlink objects in order that the consumer doesn’t have to make use of toolbar buttons to do this themselves.

In our editor, we’re going to implement hyperlinks as inline nodes with SlateJS. We replace our editor config to flag hyperlinks as inline nodes for SlateJS and likewise present a element to render so Slate is aware of the right way to render the hyperlink nodes.

# src/hooks/useEditorConfig.js
export default operate useEditorConfig(editor) {
  ...
  editor.isInline = (ingredient) => ["link"].consists of(ingredient.kind);
  return {....}
}

operate renderElement(props) {
  const { ingredient, youngsters, attributes } = props;
  swap (ingredient.kind) {
     ...
    case "hyperlink":
      return <Hyperlink {...props} url={ingredient.url} />;
      ...
  }
}
# src/elements/Hyperlink.js
export default operate Hyperlink({ ingredient, attributes, youngsters }) {
  return (
    <a href={ingredient.url} {...attributes} className={"hyperlink"}>
      {youngsters}
    </a>
  );
}

We then add a hyperlink node to our ExampleDocument and confirm that it renders appropriately (together with a case for character types inside a hyperlink) within the Editor.

# src/utils/ExampleDocument.js
{
    kind: "paragraph",
    youngsters: [
      ...
      { text: "Some text before a link." },
      {
        type: "link",
        url: "https://www.google.com",
        children: [
          { text: "Link text" },
          { text: "Bold text inside link", bold: true },
        ],
      },
     ...
}
Image showing Links rendered in the Editor and DOM tree of the editor
Hyperlinks rendered within the Editor (Large preview)

Let’s add a Hyperlink Button to the toolbar that permits the consumer to do the next:

  • Choosing some textual content and clicking on the button converts that textual content right into a hyperlink
  • Having a blinking cursor (collapsed choice) and clicking the button inserts a brand new hyperlink there
  • If the consumer’s choice is inside a hyperlink, clicking on the button ought to toggle the hyperlink — which means convert the hyperlink again to textual content.

To construct these functionalities, we’d like a manner within the toolbar to know if the consumer’s choice is inside a hyperlink node. We add a util operate that traverses the degrees in upward course from the consumer’s choice to discover a hyperlink node if there’s one, utilizing Editor.above helper operate from SlateJS.

# src/utils/EditorUtils.js

export operate isLinkNodeAtSelection(editor, choice) {
  if (choice == null) {
    return false;
  }

  return (
    Editor.above(editor, {
      at: choice,
      match: (n) => n.kind === "hyperlink",
    }) != null
  );
}

Now, let’s add a button to the toolbar that’s in energetic state if the consumer’s choice is inside a hyperlink node.

# src/elements/Toolbar.js

return (
    <div className="toolbar">
      ...
      {/* Hyperlink Button */}
      <ToolBarButton
        isActive={isLinkNodeAtSelection(editor, editor.choice)}
        label={<i className={`bi ${getIconForButton("hyperlink")}`} />}
      />
    </div>
  );
Hyperlink button in Toolbar turns into energetic if choice is inside a hyperlink.

To toggle hyperlinks within the editor, we add a util operate toggleLinkAtSelection. Let’s first take a look at how the toggle works when you may have some textual content chosen. When the consumer selects some textual content and clicks on the button, we would like solely the chosen textual content to turn out to be a hyperlink. What this inherently means is that we have to break the textual content node that accommodates chosen textual content and extract the chosen textual content into a brand new hyperlink node. The earlier than and after states of those would look one thing like under:

Before and After node structures after a link is inserted
Earlier than and After node constructions after a hyperlink is inserted. (Large preview)

If we had to do that by ourselves, we’d have to determine the vary of choice and create three new nodes (textual content, hyperlink, textual content) that change the unique textual content node. SlateJS has a helper operate known as Transforms.wrapNodes that does precisely this — wrap nodes at a location into a brand new container node. We even have a helper accessible for the reverse of this course of — Transforms.unwrapNodes which we use to take away hyperlinks from chosen textual content and merge that textual content again into the textual content nodes round it. With that, toggleLinkAtSelection has the under implementation to insert a brand new hyperlink at an expanded choice.

# src/utils/EditorUtils.js

export operate toggleLinkAtSelection(editor) {
  if (!isLinkNodeAtSelection(editor, editor.choice)) {
    const isSelectionCollapsed =
      Vary.isCollapsed(editor.choice);
    if (isSelectionCollapsed) {
      Transforms.insertNodes(
        editor,
        {
          kind: "hyperlink",
          url: "https://smashingmagazine.com/2021/05/building-wysiwyg-editor-javascript-slatejs/#",
          youngsters: [{ text: 'link' }],
        },
        { at: editor.choice }
      );
    } else {
      Transforms.wrapNodes(
        editor,
        { kind: "hyperlink", url: "https://smashingmagazine.com/2021/05/building-wysiwyg-editor-javascript-slatejs/#", youngsters: [{ text: '' }] },
        { break up: true, at: editor.choice }
      );
    }
  } else {
    Transforms.unwrapNodes(editor, {
      match: (n) => Component.isElement(n) && n.kind === "hyperlink",
    });
  }
}

If the choice is collapsed, we insert a brand new node there with Transform.insertNodes that inserts the node on the given location within the doc. We wire this operate up with the toolbar button and will now have a manner so as to add/take away hyperlinks from the doc with the assistance of the hyperlink button.

# src/elements/Toolbar.js
      <ToolBarButton
        ...
        isActive={isLinkNodeAtSelection(editor, editor.choice)}       
        onMouseDown={() => toggleLinkAtSelection(editor)}
      />

Thus far, our editor has a manner so as to add and take away hyperlinks however we don’t have a approach to replace the URLs related to these hyperlinks. How about we lengthen the consumer expertise to permit customers to edit it simply with a contextual menu? To allow hyperlink enhancing, we’ll construct a link-editing popover that exhibits up each time the consumer choice is inside a hyperlink and lets them edit and apply the URL to that hyperlink node. Let’s begin with constructing an empty LinkEditor element and rendering it each time the consumer choice is inside a hyperlink.

# src/elements/LinkEditor.js
export default operate LinkEditor() {
  return (
    <Card className={"link-editor"}>
      <Card.Physique></Card.Physique>
    </Card>
  );
}
# src/elements/Editor.js

<div className="editor">
    {isLinkNodeAtSelection(editor, choice) ? <LinkEditor /> : null}
    <Editable
       renderElement={renderElement}
       renderLeaf={renderLeaf}
       onKeyDown={onKeyDown}
    />
</div>

Since we’re rendering the LinkEditor outdoors the editor, we’d like a approach to inform LinkEditor the place the hyperlink is positioned within the DOM tree so it may render itself close to the editor. The best way we do that is use Slate’s React API to search out the DOM node similar to the hyperlink node in choice. And we then use getBoundingClientRect() to search out the hyperlink’s DOM ingredient’s bounds and the editor element’s bounds and compute the prime and left for the hyperlink editor. The code updates to Editor and LinkEditor are as under —

# src/elements/Editor.js 

const editorRef = useRef(null)
<div className="editor" ref={editorRef}>
              {isLinkNodeAtSelection(editor, choice) ? (
                <LinkEditor
                  editorOffsets={
                    editorRef.present != null
                      ? {
                          x: editorRef.present.getBoundingClientRect().x,
                          y: editorRef.present.getBoundingClientRect().y,
                        }
                      : null
                  }
                />
              ) : null}
              <Editable
                renderElement={renderElement}
                ...
# src/elements/LinkEditor.js

import { ReactEditor } from "slate-react";

export default operate LinkEditor({ editorOffsets }) {
  const linkEditorRef = useRef(null);

  const [linkNode, path] = Editor.above(editor, {
    match: (n) => n.kind === "hyperlink",
  });

  useEffect(() => {
    const linkEditorEl = linkEditorRef.present;
    if (linkEditorEl == null) {
      return;
    }

    const linkDOMNode = ReactEditor.toDOMNode(editor, linkNode);
    const {
      x: nodeX,
      peak: nodeHeight,
      y: nodeY,
    } = linkDOMNode.getBoundingClientRect();

    linkEditorEl.model.show = "block";
    linkEditorEl.model.prime = `${nodeY + nodeHeight — editorOffsets.y}px`;
    linkEditorEl.model.left = `${nodeX — editorOffsets.x}px`;
  }, [editor, editorOffsets.x, editorOffsets.y, node]);

  if (editorOffsets == null) {
    return null;
  }

  return <Card ref={linkEditorRef} className={"link-editor"}></Card>;
}

SlateJS internally maintains maps of nodes to their respective DOM components. We entry that map and discover the hyperlink’s DOM ingredient utilizing ReactEditor.toDOMNode.

Choice inside a hyperlink exhibits the hyperlink editor popover.

As seen within the video above, when a hyperlink is inserted and doesn’t have a URL, as a result of the choice is contained in the hyperlink, it opens the hyperlink editor thereby giving the consumer a approach to kind in a URL for the newly inserted hyperlink and therefore closes the loop on the consumer expertise there.

We now add an enter ingredient and a button to the LinkEditor that allow the consumer kind in a URL and apply it to the hyperlink node. We use the isUrl bundle for URL validation.

# src/elements/LinkEditor.js

import isUrl from "is-url";

export default operate LinkEditor({ editorOffsets }) {

const [linkURL, setLinkURL] = useState(linkNode.url);

  // replace state if `linkNode` adjustments 
  useEffect(() => {
    setLinkURL(linkNode.url);
  }, [linkNode]);

  const onLinkURLChange = useCallback(
    (occasion) => setLinkURL(occasion.goal.worth),
    [setLinkURL]
  );

  const onApply = useCallback(
    (occasion) => {
      Transforms.setNodes(editor, { url: linkURL }, { at: path });
    },
    [editor, linkURL, path]
  );

return (
 ...
        <Type.Management
          measurement="sm"
          kind="textual content"
          worth={linkURL}
          onChange={onLinkURLChange}
        />
        <Button
          className={"link-editor-btn"}
          measurement="sm"
          variant="major"
          disabled={!isUrl(linkURL)}
          onClick={onApply}
        >
          Apply
        </Button>
   ...
 );

With the shape components wired up, let’s see if the hyperlink editor works as anticipated.

Editor dropping choice on clicking inside hyperlink editor

As we see right here within the video, when the consumer tries to click on into the enter, the hyperlink editor disappears. It’s because as we render the hyperlink editor outdoors the Editable element, when the consumer clicks on the enter ingredient, SlateJS thinks the editor has misplaced focus and resets the choice to be null which removes the LinkEditor since isLinkActiveAtSelection will not be true anymore. There’s an open GitHub Issue that talks about this Slate conduct. One approach to clear up that is to trace the earlier collection of a consumer because it adjustments and when the editor does lose focus, we may take a look at the earlier choice and nonetheless present a hyperlink editor menu if earlier choice had a hyperlink in it. Let’s replace the useSelection hook to recollect the earlier choice and return that to the Editor element.


# src/hooks/useSelection.js
export default operate useSelection(editor) {
  const [selection, setSelection] = useState(editor.choice);
  const previousSelection = useRef(null);
  const setSelectionOptimized = useCallback(
    (newSelection) => {
      if (areEqual(choice, newSelection)) {
        return;
      }
      previousSelection.present = choice;
      setSelection(newSelection);
    },
    [setSelection, selection]
  );

  return [previousSelection.current, selection, setSelectionOptimized];
}

We then replace the logic within the Editor element to indicate the hyperlink menu even when the earlier choice had a hyperlink in it.

# src/elements/Editor.js


  const [previousSelection, selection, setSelection] = useSelection(editor);

  let selectionForLink = null;
  if (isLinkNodeAtSelection(editor, choice)) {
    selectionForLink = choice;
  } else if (choice == null && isLinkNodeAtSelection(editor, previousSelection)) {
    selectionForLink = previousSelection;
  }

  return (
    ...
            <div className="editor" ref={editorRef}>
              {selectionForLink != null ? (
                <LinkEditor
                  selectionForLink={selectionForLink}
                  editorOffsets={..}
  ...
);

We then replace LinkEditor to make use of selectionForLink to lookup the hyperlink node, render under it and replace it’s URL.

# src/elements/Hyperlink.js
export default operate LinkEditor({ editorOffsets, selectionForLink }) {
  ...
  const [node, path] = Editor.above(editor, {
    at: selectionForLink,
    match: (n) => n.kind === "hyperlink",
  });
  ...
Modifying hyperlink utilizing the LinkEditor element.

Many of the phrase processing purposes establish and convert hyperlinks inside textual content to hyperlink objects. Let’s see how that may work within the editor earlier than we begin constructing it.

Hyperlinks being detected because the consumer sorts them in.

The steps of the logic to allow this conduct can be:

  1. Because the doc adjustments with the consumer typing, discover the final character inserted by the consumer. If that character is an area, we all know there have to be a phrase that may have come earlier than it.
  2. If the final character was area, we mark that as the top boundary of the phrase that got here earlier than it. We then traverse again character by character contained in the textual content node to search out the place that phrase started. Throughout this traversal, now we have to watch out to not go previous the sting of the beginning of the node into the earlier node.
  3. As soon as now we have discovered the beginning and finish boundaries of the phrase earlier than, we test the string of the phrase and see if that was a URL. If it was, we convert it right into a hyperlink node.

Our logic lives in a util operate identifyLinksInTextIfAny that lives in EditorUtils and is known as contained in the onChange in Editor element.

# src/elements/Editor.js

  const onChangeHandler = useCallback(
    (doc) => {
      ...
      identifyLinksInTextIfAny(editor);
    },
    [editor, onChange, setSelection]
  );

Right here is identifyLinksInTextIfAny with the logic for Step 1 applied:

export operate identifyLinksInTextIfAny(editor) {
  // if choice will not be collapsed, we don't proceed with the hyperlink  
  // detection
  if (editor.choice == null || !Vary.isCollapsed(editor.choice)) {
    return;
  }

  const [node, _] = Editor.father or mother(editor, editor.choice);

  // if we're already inside a hyperlink, exit early.
  if (node.kind === "hyperlink") {
    return;
  }

  const [currentNode, currentNodePath] = Editor.node(editor, editor.choice);

  // if we aren't inside a textual content node, exit early.
  if (!Textual content.isText(currentNode)) {
    return;
  }

  let [start] = Vary.edges(editor.choice);
  const cursorPoint = begin;

  const startPointOfLastCharacter = Editor.earlier than(editor, editor.choice, {
    unit: "character",
  });

  const lastCharacter = Editor.string(
    editor,
    Editor.vary(editor, startPointOfLastCharacter, cursorPoint)
  );

  if(lastCharacter !== ' ') {
    return;
  }

There are two SlateJS helper features which make issues simple right here.

  • Editor.before — Offers us the purpose earlier than a sure location. It takes unit as a parameter so we may ask for the character/phrase/block and so forth earlier than the location handed in.
  • Editor.string — Will get the string inside a variety.

For example, the diagram under explains what values of those variables are when the consumer inserts a personality ‘E’ and their cursor is sitting after it.

Diagram explaining where cursorPoint and startPointOfLastCharacter point to after step 1 with an example
cursorPoint and startPointOfLastCharacter after Step 1 with an instance textual content. (Large preview)

If the textual content ’ABCDE’ was the primary textual content node of the primary paragraph within the doc, our level values can be —

cursorPoint = { path: [0,0], offset: 5}
startPointOfLastCharacter = { path: [0,0], offset: 4}

If the final character was an area, we all know the place it began — startPointOfLastCharacter.Let’s transfer to step-2 the place we transfer backwards character-by-character till both we discover one other area or the beginning of the textual content node itself.

...
 
  if (lastCharacter !== " ") {
    return;
  }

  let finish = startPointOfLastCharacter;
  begin = Editor.earlier than(editor, finish, {
    unit: "character",
  });

  const startOfTextNode = Editor.level(editor, currentNodePath, {
    edge: "begin",
  });

  whereas (
    Editor.string(editor, Editor.vary(editor, begin, finish)) !== " " &&
    !Level.isBefore(begin, startOfTextNode)
  ) {
    finish = begin;
    begin = Editor.earlier than(editor, finish, { unit: "character" });
  }

  const lastWordRange = Editor.vary(editor, finish, startPointOfLastCharacter);
  const lastWord = Editor.string(editor, lastWordRange);

Here’s a diagram that exhibits the place these totally different factors level to as soon as we discover the final phrase entered to be ABCDE.

Diagram explaining where different points are after step 2 of link detection with an example
The place totally different factors are after step 2 of hyperlink detection with an instance. (Large preview)

Be aware that begin and finish are the factors earlier than and after the area there. Equally, startPointOfLastCharacter and cursorPoint are the factors earlier than and after the area consumer simply inserted. Therefore [end,startPointOfLastCharacter] provides us the final phrase inserted.

We log the worth of lastWord to the console and confirm the values as we kind.

Console logs verifying final phrase as entered by the consumer after the logic in Step 2.

Now that now we have deduced what the final phrase was that the consumer typed, we confirm that it was a URL certainly and convert that vary right into a hyperlink object. This conversion seems to be much like how the toolbar hyperlink button transformed a consumer’s chosen textual content right into a hyperlink.

if (isUrl(lastWord)) {
    Promise.resolve().then(() => {
      Transforms.wrapNodes(
        editor,
        { kind: "hyperlink", url: lastWord, youngsters: [{ text: lastWord }] },
        { break up: true, at: lastWordRange }
      );
    });
  }

identifyLinksInTextIfAny is known as inside Slate’s onChange so we wouldn’t need to replace the doc construction contained in the onChange. Therefore, we put this replace on our activity queue with a Promise.resolve().then(..) name.

Let’s see the logic come collectively in motion! We confirm if we insert hyperlinks on the finish, within the center or the beginning of a textual content node.

Hyperlinks being detected as consumer is typing them.

With that, now we have wrapped up functionalities for hyperlinks on the editor and transfer on to Photos.

Dealing with Photos

On this part, we deal with including help to render picture nodes, add new photographs and replace picture captions. Photos, in our doc construction, can be represented as Void nodes. Void nodes in SlateJS (analogous to Void elements in HTML spec) are such that their contents usually are not editable textual content. That permits us to render photographs as voids. Due to Slate’s flexibility with rendering, we are able to nonetheless render our personal editable components inside Void components — which we’ll for picture caption-editing. SlateJS has an example which demonstrates how one can embed a whole Wealthy Textual content Editor inside a Void ingredient.

To render photographs, we configure the editor to deal with photographs as Void components and supply a render implementation of how photographs must be rendered. We add a picture to our ExampleDocument and confirm that it renders appropriately with the caption.

# src/hooks/useEditorConfig.js

export default operate useEditorConfig(editor) {
  const { isVoid } = editor;
  editor.isVoid = (ingredient) =>  isVoid(ingredient);
  ;
  ...
}

operate renderElement(props) {
  const { ingredient, youngsters, attributes } = props;
  swap (ingredient.kind) {
    case "picture":
      return <Picture {...props} />;
...
``



``
# src/elements/Picture.js
operate Picture({ attributes, youngsters, ingredient }) {
  return (
    <div contentEditable={false} {...attributes}>
      <div
        className={classNames({
          "image-container": true,
        })}
      >
        <img
          src={String(ingredient.url)}
          alt={ingredient.caption}
          className={"picture"}
        />
        <div className={"image-caption-read-mode"}>{ingredient.caption}</div>
      </div>     
      {youngsters}
    </div>
  );
}

Two issues to recollect when attempting to render void nodes with SlateJS:

  • The basis DOM ingredient ought to have contentEditable={false} set on it in order that SlateJS treats its contents so. With out this, as you work together with the void ingredient, SlateJS could attempt to compute alternatives and so forth. and break consequently.
  • Even when Void nodes don’t have any youngster nodes (like our picture node for instance), we nonetheless must render youngsters and supply an empty textual content node as youngster (see ExampleDocument under) which is handled as a variety level of the Void ingredient by SlateJS

We now replace the ExampleDocument so as to add a picture and confirm that it exhibits up with the caption within the editor.

# src/utils/ExampleDocument.js

const ExampleDocument = [
   ...
   {
    type: "image",
    url: "/photos/puppy.jpg",
    caption: "Puppy",
    // empty text node as child for the Void element.
    children: [{ text: "" }],
  },
];
Image rendered in the Editor
Picture rendered within the Editor. (Large preview)

Now let’s deal with caption-editing. The best way we would like this to be a seamless expertise for the consumer is that once they click on on the caption, we present a textual content enter the place they will edit the caption. In the event that they click on outdoors the enter or hit the RETURN key, we deal with that as a affirmation to use the caption. We then replace the caption on the picture node and swap the caption again to learn mode. Let’s see it in motion so now we have an concept of what we’re constructing.

Picture Caption Modifying in motion.

Let’s replace our Picture element to have a state for caption’s read-edit modes. We replace the native caption state because the consumer updates it and once they click on out (onBlur) or hit RETURN (onKeyDown), we apply the caption to the node and swap to learn mode once more.

const Picture = ({ attributes, youngsters, ingredient }) => {
  const [isEditingCaption, setEditingCaption] = useState(false);
  const [caption, setCaption] = useState(ingredient.caption);
  ...

  const applyCaptionChange = useCallback(
    (captionInput) => {
      const imageNodeEntry = Editor.above(editor, {
        match: (n) => n.kind === "picture",
      });
      if (imageNodeEntry == null) {
        return;
      }

      if (captionInput != null) {
        setCaption(captionInput);
      }

      Transforms.setNodes(
        editor,
        { caption: captionInput },
        { at: imageNodeEntry[1] }
      );
    },
    [editor, setCaption]
  );

  const onCaptionChange = useCallback(
    (occasion) => {
      setCaption(occasion.goal.worth);
    },
    [editor.selection, setCaption]
  );

  const onKeyDown = useCallback(
    (occasion) => {
      if (!isHotkey("enter", occasion)) {
        return;
      }

      applyCaptionChange(occasion.goal.worth);
      setEditingCaption(false);
    },
    [applyCaptionChange, setEditingCaption]
  );

  const onToggleCaptionEditMode = useCallback(
    (occasion) => {
      const wasEditing = isEditingCaption;
      setEditingCaption(!isEditingCaption);
      wasEditing && applyCaptionChange(caption);
    },
    [editor.selection, isEditingCaption, applyCaptionChange, caption]
  );

  return (
        ...
        {isEditingCaption ? (
          <Type.Management
            autoFocus={true}
            className={"image-caption-input"}
            measurement="sm"
            kind="textual content"
            defaultValue={ingredient.caption}
            onKeyDown={onKeyDown}
            onChange={onCaptionChange}
            onBlur={onToggleCaptionEditMode}
          />
        ) : (
          <div
            className={"image-caption-read-mode"}
            onClick={onToggleCaptionEditMode}
          >
            {caption}
          </div>
        )}
      </div>
      ...

With that, the caption enhancing performance is full. We now transfer to including a manner for customers to add photographs to the editor. Let’s add a toolbar button that lets customers choose and add a picture.

# src/elements/Toolbar.js

const onImageSelected = useImageUploadHandler(editor, previousSelection);

return (
    <div className="toolbar">
    ....
   <ToolBarButton
        isActive={false}
        as={"label"}
        htmlFor="image-upload"
        label={
          <>
            <i className={`bi ${getIconForButton("picture")}`} />
            <enter
              kind="file"
              id="image-upload"
              className="image-upload-input"
              settle for="picture/png, picture/jpeg"
              onChange={onImageSelected}
            />
          </>
        }
      />
    </div>

As we work with picture uploads, the code may develop fairly a bit so we transfer the image-upload dealing with to a hook useImageUploadHandler that provides out a callback hooked up to the file-input ingredient. We’ll focus on shortly about why it wants the previousSelection state.

Earlier than we implement useImageUploadHandler, we’ll arrange the server to have the ability to add a picture to. We setup an Categorical server and set up two different packages — cors and multer that deal with file uploads for us.

yarn add specific cors multer

We then add a src/server.js script that configures the Categorical server with cors and multer and exposes an endpoint /add which we’ll add the picture to.

# src/server.js

const storage = multer.diskStorage({
  vacation spot: operate (req, file, cb) {
    cb(null, "./public/pictures/");
  },
  filename: operate (req, file, cb) {
    cb(null, file.originalname);
  },
});

var add = multer({ storage: storage }).single("picture");

app.submit("/add", operate (req, res) {
  add(req, res, operate (err) {
    if (err instanceof multer.MulterError) {
      return res.standing(500).json(err);
    } else if (err) {
      return res.standing(500).json(err);
    }
    return res.standing(200).ship(req.file);
  });
});

app.use(cors());
app.pay attention(port, () => console.log(`Listening on port ${port}`));

Now that now we have the server setup, we are able to deal with dealing with the picture add. When the consumer uploads a picture, it might be a couple of seconds earlier than the picture will get uploaded and now we have a URL for it. Nevertheless, we do what to provide the consumer rapid suggestions that the picture add is in progress in order that they know the picture is being inserted within the editor. Listed here are the steps we implement to make this conduct work –

  1. As soon as the consumer selects a picture, we insert a picture node on the consumer’s cursor place with a flag isUploading set on it so we are able to present the consumer a loading state.
  2. We ship the request to the server to add the picture.
  3. As soon as the request is full and now we have a picture URL, we set that on the picture and take away the loading state.

Let’s start with step one the place we insert the picture node. Now, the tough half right here is we run into the identical situation with choice as with the hyperlink button within the toolbar. As quickly because the consumer clicks on the Picture button within the toolbar, the editor loses focus and the choice turns into null. If we attempt to insert a picture, we don’t know the place the consumer’s cursor was. Monitoring previousSelection provides us that location and we use that to insert the node.

# src/hooks/useImageUploadHandler.js
import { v4 as uuidv4 } from "uuid";

export default operate useImageUploadHandler(editor, previousSelection) {
  return useCallback(
    (occasion) => {
      occasion.preventDefault();
      const recordsdata = occasion.goal.recordsdata;
      if (recordsdata.size === 0) {
        return;
      }
      const file = recordsdata[0];
      const fileName = file.identify;
      const formData = new FormData();
      formData.append("picture", file);

      const id = uuidv4();

      Transforms.insertNodes(
        editor,
        {
          id,
          kind: "picture",
          caption: fileName,
          url: null,
          isUploading: true,
          youngsters: [{ text: "" }],
        },
        { at: previousSelection, choose: true }
      );
    },
    [editor, previousSelection]
  );
}

As we insert the brand new picture node, we additionally assign it an identifier id utilizing the uuid bundle. We’ll focus on in Step (3)’s implementation why we’d like that. We now replace the picture element to make use of the isUploading flag to indicate a loading state.

{!ingredient.isUploading && ingredient.url != null ? (
   <img src={ingredient.url} alt={caption} className={"picture"} />
) : (
   <div className={"image-upload-placeholder"}>
        <Spinner animation="border" variant="darkish" />
   </div>
)}

That completes the implementation of step 1. Let’s confirm that we’re in a position to choose a picture to add, see the picture node getting inserted with a loading indicator the place it was inserted within the doc.

Picture add creating a picture node with loading state.

Shifting to Step (2), we’ll use axois library to ship a request to the server.

export default operate useImageUploadHandler(editor, previousSelection) {
  return useCallback((occasion) => {
    ....
    Transforms.insertNodes(
     …
     {at: previousSelection, choose: true}
    );

    axios
      .submit("/add", formData, {
        headers: {
          "content-type": "multipart/form-data",
        },
      })
      .then((response) => {
           // replace the picture node.
       })
      .catch((error) => {
        // Hearth one other Remodel.setNodes to set an add failed state on the picture
      });
  }, [...]);
}

We confirm that the picture add works and the picture does present up within the public/pictures folder of the app. Now that the picture add is full, we transfer to Step (3) the place we need to set the URL on the picture within the resolve() operate of the axios promise. We may replace the picture with Transforms.setNodes however now we have an issue — we would not have the trail to the newly inserted picture node. Let’s see what our choices are to get to that picture —

  • Can’t we use editor.choice as the choice have to be on the newly inserted picture node? We can not assure this since whereas the picture was importing, the consumer may need clicked some place else and the choice may need modified.
  • How about utilizing previousSelection which we used to insert the picture node within the first place? For a similar cause we are able to’t use editor.choice, we are able to’t use previousSelection since it might have modified too.
  • SlateJS has a History module that tracks all of the adjustments occurring to the doc. We may use this module to go looking the historical past and discover the final inserted picture node. This additionally isn’t utterly dependable if it took longer for the picture to add and the consumer inserted extra photographs in several components of the doc earlier than the primary add accomplished.
  • At the moment, Remodel.insertNodes’s API doesn’t return any details about the inserted nodes. If it may return the paths to the inserted nodes, we may use that to search out the exact picture node we should always replace.

Since not one of the above approaches work, we apply an id to the inserted picture node (in Step (1)) and use the identical id once more to find it when the picture add is full. With that, our code for Step (3) seems to be like under —

axios
        .submit("/add", formData, {
          headers: {
            "content-type": "multipart/form-data",
          },
        })
        .then((response) => {
          const newImageEntry = Editor.nodes(editor, {
            match: (n) => n.id === id,
          });

          if (newImageEntry == null) {
            return;
          }

          Transforms.setNodes(
            editor,
            { isUploading: false, url: `/pictures/${fileName}` },
            { at: newImageEntry[1] }
          );
        })
        .catch((error) => {
          // Hearth one other Remodel.setNodes to set an add failure state
          // on the picture.        
        });

With the implementation of all three steps full, we’re prepared to check the picture add finish to finish.

Picture add working end-to-end

With that, we’ve wrapped up Photos for our editor. At the moment, we present a loading state of the identical measurement regardless of the picture. This might be a jarring expertise for the consumer if the loading state is changed by a drastically smaller or larger picture when the add completes. A superb comply with as much as the add expertise is getting the picture dimensions earlier than the add and exhibiting a placeholder of that measurement in order that transition is seamless. The hook we add above might be prolonged to help different media sorts like video or paperwork and render these sorts of nodes as effectively.

Conclusion

On this article, now we have constructed a WYSIWYG Editor that has a primary set of functionalities and a few micro user-experiences like hyperlink detection, in-place hyperlink enhancing and picture caption enhancing that helped us go deeper with SlateJS and ideas of Wealthy Textual content Modifying basically. If this drawback area surrounding Wealthy Textual content Modifying or Phrase Processing pursuits you, a number of the cool issues to go after might be:

  • Collaboration
  • A richer textual content enhancing expertise that helps textual content alignments, inline photographs, copy-paste, altering font and textual content colours and so forth.
  • Importing from well-liked codecs like Phrase paperwork and Markdown.

If you wish to be taught extra SlateJS, listed below are some hyperlinks that is likely to be useful.

  • SlateJS Examples
    Numerous examples that transcend the fundamentals and construct functionalities which might be often present in Editors like Search & Spotlight, Markdown Preview and Mentions.
  • API Docs
    Reference to a whole lot of helper features uncovered by SlateJS that one would possibly need to preserve useful when attempting to carry out complicated queries/transformations on SlateJS objects.

Lastly, SlateJS’s Slack Channel is a really energetic neighborhood of net builders constructing Wealthy Textual content Modifying purposes utilizing SlateJS and a fantastic place to be taught extra in regards to the library and get assist if wanted.

Smashing Editorial
(vf, il)



Source link