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 way of the whole 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, photos, 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 sector 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 corporations are racing to construct the very best expertise for content material creators within the enterprise area and looking for modern methods of breaking the normal moulds of how content material is shared and consumed. Benefiting from the huge outreach of social media platforms, there’s a new wave of unbiased 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 necessary 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 in addition give the readers a glimpse into how little nuggets of functionalities when introduced collectively can create a fantastic person expertise for a content material creator.

Understanding The Doc Construction

Earlier than we dive into constructing the editor, let’s have a look at how a doc is structured for a Wealthy Textual content Editor and what are the various kinds of information constructions concerned.

Doc Nodes

Doc nodes are used to symbolize the contents of the doc. The widespread forms of nodes {that a} rich-text doc may include are paragraphs, headings, photos, movies, code-blocks and pull-quotes. A few of these could include different nodes as kids inside them (e.g. Paragraph nodes include textual content nodes inside them). Nodes additionally maintain any properties particular to the item they symbolize which can 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 forms of nodes that symbolize how they need to be rendered –

  • Block Nodes (analogous to HTML idea of Block-level parts) which can be every rendered on a brand new line and occupy the obtainable 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 all the time be block nodes.
  • Inline Nodes (analogous to HTML idea of Inline parts) that begin rendering on the identical line because the earlier node. There are some variations in how inline parts are represented in numerous modifying libraries. SlateJS permits for inline parts to be nodes themselves. DraftJS, one other well-liked Wealthy Textual content Enhancing library, helps you to use the idea of Entities to render inline parts. Hyperlinks and Inline Photographs are examples of Inline nodes.
  • Void Nodes — SlateJS additionally permits this third class of nodes that we’ll 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 begin.

Attributes

Just 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 kids. As an example, 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 option to symbolize them might be that nodes have paragraph-styles (paragraph & h1-h6) as attributes on them.

Beneath picture offers an instance of how a doc’s construction (in JSON) is described at a extra granular degree utilizing nodes and attributes highlighting among the parts 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 photos)
  • SlateJS-specific illustration of textual content attributes breaks the textual content nodes to be their very own nodes if the character fashion 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 fashion textual content on this doc.

Places And Choice

When constructing a wealthy textual content editor, it’s essential to have an understanding of how probably the most granular a part of a doc (say a personality) could be represented with some form of coordinates. This helps us navigate the doc construction at runtime to know the place within the doc hierarchy we’re. Most significantly, location objects give us a option to symbolize person choice which is kind of extensively used to tailor the person 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 person’s cursor at the moment inside a hyperlink, perhaps we should always present them a menu to edit/take away the hyperlink?
  • Has the person chosen a picture? Possibly we give them a menu to resize the picture.
  • If the person selects sure textual content and hits the DELETE button, we decide what person’s chosen textual content was and take away that from the doc.

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

  • Path
    Represented by an array of numbers, a path is the way in which to get to a node within the doc. As an example, 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 example, 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 (referred to as anchor and focus) that symbolize a spread of textual content contained in the doc. This idea comes from Internet’s Selection API the place anchor is the place person’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 as an illustration).

For instance let’s say that the person’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 person’s choice could 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 fundamental 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 parts from react-bootstrap. Let’s get began!

Create a folder referred to as wysiwyg-editor and run the beneath 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 internet server (port defaulting to 3000) and present you a React welcome display.

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 parts we’ll use to render Slate editors. SlateJS exposes some extra packages organized by performance one may take into account 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 fundamental doc construction that comprises a paragraph with some textual content. This module seems to be like beneath:

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

export default ExampleDocument;

We now add a folder referred to as parts that may maintain all our React parts and do the next:

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

App.js part now seems to be like beneath:

import Editor from './parts/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 part, we then instantiate the SlateJS editor and maintain it inside a useMemo in order that the item doesn’t change in between re-renders.

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

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

createEditor offers us the SlateJS editor occasion which we use extensively by way of the applying to entry alternatives, run information 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 fasten some configuration to it. This permits internet builders so as to add configurations to their SlateJS editor occasion in a composable method.

We now import and render <Slate /> and <Editable /> parts 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 part that renders the doc hierarchy for modifying. General, the Editor.js module at this stage seems to be like beneath:

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, we now have mandatory React parts added and the editor populated with an instance doc. Our Editor ought to be now arrange permitting us to kind in and alter the content material in actual time — as within the screencast beneath.

Fundamental 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 Model Nodes

At the moment, our editor makes use of SlateJS’s default rendering for any new node varieties 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 parts. This operate will get referred to 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 should 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
  • kids
    The youngsters of this node as outlined within the doc construction.

We add our renderElement implementation to a hook referred to 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, kids, attributes } = props;
  swap (ingredient.kind) {
    case "paragraph":
      return <p {...attributes}>{kids}</p>;
    case "h1":
      return <h1 {...attributes}>{kids}</h1>;
    case "h2":
      return <h2 {...attributes}>{kids}</h2>;
    case "h3":
      return <h3 {...attributes}>{kids}</h3>;
    case "h4":
      return <h4 {...attributes}>{kids}</h4>;
    default:
      // For the default case, we delegate to Slate's default rendering. 
      return <DefaultElement {...props} />;
  }
}

Since this operate offers us entry to the ingredient (which is the node itself), we are able to customise renderElement to implement a extra personalized rendering that does extra than simply checking ingredient.kind. As an example, you may have a picture node that has a isInline property that we may use to return a special DOM construction that helps us render inline photos as in opposition to block photos.

We now replace the Editor part to make use of this hook as beneath:

const { renderElement } = useEditorConfig(editor);

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

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

const ExampleDocument = [
  {
    type: "h1",
    children: [{ text: "Heading 1" }],
  },
  {
    kind: "h2",
    kids: [{ 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

Just like renderElement, SlateJS offers out a operate prop referred to 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, kids, leaf }) {
  let el = <>{kids}</>;

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

  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 necessary remark of the above implementation is that it permits us to respect HTML semantics for character types. Since renderLeaf offers us entry to the textual content node leaf itself, we are able to customise the operate to implement a extra personalized rendering. As an example, you might need a option to let customers select a highlightColor for textual content and examine that leaf property right here to connect the respective types.

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

# src/parts/Editor.js

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

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

{
    kind: "paragraph",
    kids: [
      { 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 part Toolbar.js to which we add just a few 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((fashion) => (
        <ToolBarButton
          key={fashion}
          icon={<i className={`bi ${getIconForButton(fashion)}`} />}
          isActive={false}
        />
      ))}
    </div>
  );
}

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

We summary away the buttons to the ToolbarButton part that could be a wrapper across the React Bootstrap Button part. We then render the toolbar above the Editable inside Editor part 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 person’s cursor is in a sure spot within the doc and so they click on one of many character fashion buttons, we have to toggle the fashion for the textual content they might kind subsequent.
  2. When the person selects a spread of textual content and click on one of many character fashion buttons, we have to toggle the fashion for that particular part.
  3. When the person selects a spread of textual content, we wish to replace the paragraph-style dropdown to replicate the paragraph-type of the choice. In the event that they do choose a special worth from the choice, we wish to replace the paragraph fashion of the whole choice to be what they chose.

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

Character Types toggling conduct

Listening To Choice

A very powerful 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 technique that might give us the newest choice state of the doc. Nevertheless, as choice adjustments within the editor, SlateJS does name the onChange technique, even when the doc contents haven’t modified. We use this as a option to be notified of choice change and retailer it within the Editor part’s state. We summary this to a hook useSelection the place we may do a extra optimum replace of the choice state. That is necessary 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 part 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 part as beneath and move the choice to the Toolbar part.

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 we now have a a lot greater Editor codebase with much more functionalities, it is very important retailer and hearken to choice adjustments in a performant method (like utilizing some state administration library) as parts listening to choice adjustments are more likely to render too usually. A technique to do that is to have optimized selectors on prime of the Choice state that maintain particular choice info. As an example, an editor may wish to render a picture resizing menu when an Picture is chosen. In such a case, it is perhaps 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’t rely solely on the doc content material change to set off a re-render on the hierarchy (App -> Editor -> Toolbar) as customers may simply hold clicking across the doc thereby altering choice however by no means truly altering the doc content material itself.

Toggling Character Types

We now transfer to getting what the lively 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 offers a Set of lively types within the editor. We additionally add a operate to toggle a mode 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, fashion) {
  const activeStyles = getActiveStyles(editor);
  if (activeStyles.has(fashion)) {
    Editor.removeMark(editor, fashion);
  } else {
    Editor.addMark(editor, fashion, true);
  }
}

Each the features take the editor object which is the Slate occasion as a parameter as will a variety of util features we add later within the article.In Slate terminology, formatting types are referred to 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/parts/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((fashion) => (
        <ToolBarButton
          key={fashion}
          characterStyle={fashion}
          icon={<i className={`bi ${getIconForButton(fashion)}`} />}
          isActive={getActiveStyles(editor).has(fashion)}
          onMouseDown={(occasion) => {
            occasion.preventDefault();
            toggleStyle(editor, fashion);
          }}
        />
      ))}
</div>

useEditor is a Slate hook that offers us entry to the Slate occasion from the context the place it was connected by the &lt;Slate> part greater up within the render hierarchy.

One may 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 method. 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 mode which isn’t a fantastic expertise. We as a substitute toggle the fashion by attaching a onMouseDown occasion which prevents the choice from getting reset. One other method to do that is to maintain observe 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 resolve a special downside.

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 connected to the Editable part. We use the is-hotkey util to find out the important thing mixture and toggle the corresponding fashion.

# 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/parts/Editor.js
...
 <Editable
   renderElement={renderElement}
   renderLeaf={renderLeaf}
   onKeyDown={onKeyDown}
 />
Character types toggled utilizing keyboard shortcuts.

Making Paragraph Model Dropdown Work

Let’s transfer on to creating the Paragraph Types dropdown work. Just 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 person’s choice to be mirrored within the dropdown. If there’s a single constant fashion 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 person’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?:  Span
      match?: NodeMatch<T>
      mode?: 'all' 
  ) => Generator<NodeEntry<T>, void, undefined>

The helper operate takes an Editor occasion and an choices object that could be a option 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 many 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 beneath as we’re keen on nodes inside person’s choice.
  • match
    This can be a matching operate one can present that known as on every node and included if it’s a match. We use this parameter in our implementation beneath to filter to dam parts solely.
  • mode
    Let’s the helper features know if we’re keen on 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 ought to be within the reverse path of the beginning and finish factors of the situation handed in.
  • voids
    If the search ought to filter to void parts solely.

SlateJS exposes a variety of helper features that allow you to question for nodes in numerous methods, traverse the tree, replace the nodes or alternatives in advanced methods. Price digging into a few of these interfaces (listed in direction of the tip of this text) when constructing advanced modifying functionalities on prime of Slate.

With that background on the helper operate, beneath 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.achieved) {
    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 can be inside the vary of the at param after which runs match filters on it (examine nodeEntries and the filtering later — source). That is okay for smaller paperwork. Nevertheless, for our use case, if the person chosen, say 3 headings and a pair of paragraphs (every paragraph containing say 10 textual content nodes), it’ll cycle by way of no less than 25 nodes (3 + 2 + 2*10) and attempt to run filters on them. Since we already know we’re keen on 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 way of solely 3 node entries (2 headings and 1 paragraph). Code for that may look one thing like beneath:

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

  //path[0] offers 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 have to traverse the doc tree usually, it is very important take into consideration probably the most performant methods to take action for the use case at hand because the obtainable API or helper strategies may not all the time be probably the most environment friendly method to take action.

As soon as we now have getTextBlockStyle applied, toggling of the block fashion is comparatively simple. If the present fashion isn’t what person chosen within the dropdown, we toggle the fashion to that. Whether it is already what person 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 fashion 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 beneath:

# 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-Model dropdown to make use of these utility features.

#src/parts/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 varieties and altering the kind with the dropdown.

On this part, we’re going to add help to point out, add, take away and alter hyperlinks. We can even add a Hyperlink-Detector performance — fairly just like how Google Docs or MS Phrase that scan the textual content typed by the person and checks if there are hyperlinks in there. If there are, they’re transformed into hyperlink objects in order that the person 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 in addition present a part to render so Slate is aware of the 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, kids, attributes } = props;
  swap (ingredient.kind) {
     ...
    case "hyperlink":
      return <Hyperlink {...props} url={ingredient.url} />;
      ...
  }
}
# src/parts/Hyperlink.js
export default operate Hyperlink({ ingredient, attributes, kids }) {
  return (
    <a href={ingredient.url} {...attributes} className={"hyperlink"}>
      {kids}
    </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",
    kids: [
      ...
      { 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 person 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 person’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 method within the toolbar to know if the person’s choice is inside a hyperlink node. We add a util operate that traverses the degrees in upward path from the person’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 lively state if the person’s choice is inside a hyperlink node.

# src/parts/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 lively if choice is inside a hyperlink.

To toggle hyperlinks within the editor, we add a util operate toggleLinkAtSelection. Let’s first have a look at how the toggle works when you’ve some textual content chosen. When the person selects some textual content and clicks on the button, we would like solely the chosen textual content to turn into a hyperlink. What this inherently means is that we have to break the textual content node that comprises 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 beneath:

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 exchange the unique textual content node. SlateJS has a helper operate referred to as Transforms.wrapNodes that does precisely this — wrap nodes at a location into a brand new container node. We even have a helper obtainable 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 beneath 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/#",
          kids: [{ text: 'link' }],
        },
        { at: editor.choice }
      );
    } else {
      Transforms.wrapNodes(
        editor,
        { kind: "hyperlink", url: "https://smashingmagazine.com/2021/05/building-wysiwyg-editor-javascript-slatejs/#", kids: [{ text: '' }] },
        { cut up: true, at: editor.choice }
      );
    }
  } else {
    Transforms.unwrapNodes(editor, {
      match: (n) => Ingredient.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 may now have a method so as to add/take away hyperlinks from the doc with the assistance of the hyperlink button.

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

To date, our editor has a method so as to add and take away hyperlinks however we don’t have a option to replace the URLs related to these hyperlinks. How about we prolong the person expertise to permit customers to edit it simply with a contextual menu? To allow hyperlink modifying, we’ll construct a link-editing popover that exhibits up at any time when the person 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 part and rendering it at any time when the person choice is inside a hyperlink.

# src/parts/LinkEditor.js
export default operate LinkEditor() {
  return (
    <Card className={"link-editor"}>
      <Card.Physique></Card.Physique>
    </Card>
  );
}
# src/parts/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 option to inform LinkEditor the place the hyperlink is situated within the DOM tree so it may render itself close to the editor. The way in which we do that is use Slate’s React API to search out the DOM node akin to the hyperlink node in choice. And we then use getBoundingClientRect() to search out the hyperlink’s DOM ingredient’s bounds and the editor part’s bounds and compute the prime and left for the hyperlink editor. The code updates to Editor and LinkEditor are as beneath —

# src/parts/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/parts/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.fashion.show = "block";
    linkEditorEl.fashion.prime = `${nodeY + nodeHeight — editorOffsets.y}px`;
    linkEditorEl.fashion.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 parts. 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 person a option to kind in a URL for the newly inserted hyperlink and therefore closes the loop on the person expertise there.

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

# src/parts/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 (
 ...
        <Kind.Management
          dimension="sm"
          kind="textual content"
          worth={linkURL}
          onChange={onLinkURLChange}
        />
        <Button
          className={"link-editor-btn"}
          dimension="sm"
          variant="major"
          disabled={!isUrl(linkURL)}
          onClick={onApply}
        >
          Apply
        </Button>
   ...
 );

With the shape parts 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 person tries to click on into the enter, the hyperlink editor disappears. It is because as we render the hyperlink editor outdoors the Editable part, when the person 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 isn’t true anymore. There’s an open GitHub Issue that talks about this Slate conduct. One option to clear up that is to trace the earlier collection of a person because it adjustments and when the editor does lose focus, we may have 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 part.


# 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 part to point out the hyperlink menu even when the earlier choice had a hyperlink in it.

# src/parts/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 beneath it and replace it’s URL.

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

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 person varieties them in.

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

  1. Because the doc adjustments with the person typing, discover the final character inserted by the person. If that character is an area, we all know there should be a phrase which may have come earlier than it.
  2. If the final character was area, we mark that as the tip 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, we now have to watch out to not go previous the sting of the beginning of the node into the earlier node.
  3. As soon as we now have discovered the beginning and finish boundaries of the phrase earlier than, we examine 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 known as contained in the onChange in Editor part.

# src/parts/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 isn't collapsed, we don't proceed with the hyperlink  
  // detection
  if (editor.choice == null || !Vary.isCollapsed(editor.choice)) {
    return;
  }

  const [node, _] = Editor.dad or mum(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 — Provides us the purpose earlier than a sure location. It takes unit as a parameter so we may ask for the character/phrase/block and many others earlier than the location handed in.
  • Editor.string — Will get the string inside a spread.

For instance, the diagram beneath explains what values of those variables are when the person 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 person simply inserted. Therefore [end,startPointOfLastCharacter] offers 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 person after the logic in Step 2.

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

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

identifyLinksInTextIfAny known as inside Slate’s onChange so we wouldn’t wish 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 person is typing them.

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

Dealing with Photographs

On this part, we concentrate on including help to render picture nodes, add new photos and replace picture captions. Photographs, 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 are usually not editable textual content. That permits us to render photos as voids. Due to Slate’s flexibility with rendering, we are able to nonetheless render our personal editable parts inside Void parts — which we’ll for picture caption-editing. SlateJS has an example which demonstrates how one can embed a complete Wealthy Textual content Editor inside a Void ingredient.

To render photos, we configure the editor to deal with photos as Void parts and supply a render implementation of how photos ought to 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) => ;
  ...
}

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



``
# src/parts/Picture.js
operate Picture({ attributes, kids, 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>     
      {kids}
    </div>
  );
}

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

  • The foundation 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 many others. and break in consequence.
  • Even when Void nodes don’t have any youngster nodes (like our picture node for instance), we nonetheless have to render kids and supply an empty textual content node as youngster (see ExampleDocument beneath) 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 concentrate on caption-editing. The way in which we would like this to be a seamless expertise for the person is that once they click on on the caption, we present a textual content enter the place they’ll 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 we now have an thought of what we’re constructing.

Picture Caption Enhancing in motion.

Let’s replace our Picture part to have a state for caption’s read-edit modes. We replace the native caption state because the person 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, kids, 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 ? (
          <Kind.Management
            autoFocus={true}
            className={"image-caption-input"}
            dimension="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 modifying performance is full. We now transfer to including a method for customers to add photos to the editor. Let’s add a toolbar button that lets customers choose and add a picture.

# src/parts/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 offers out a callback connected to the file-input ingredient. We’ll talk about 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 Specific server and set up two different packages — cors and multer that deal with file uploads for us.

yarn add categorical cors multer

We then add a src/server.js script that configures the Specific 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/images/");
  },
  filename: operate (req, file, cb) {
    cb(null, file.originalname);
  },
});

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

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.hear(port, () => console.log(`Listening on port ${port}`));

Now that we now have the server setup, we are able to concentrate on dealing with the picture add. When the person uploads a picture, it might be just a few seconds earlier than the picture will get uploaded and we now have a URL for it. Nevertheless, we do what to provide the person 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 person selects a picture, we insert a picture node on the person’s cursor place with a flag isUploading set on it so we are able to present the person a loading state.
  2. We ship the request to the server to add the picture.
  3. As soon as the request is full and we now 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 difficult half right here is we run into the identical difficulty with choice as with the hyperlink button within the toolbar. As quickly because the person 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 person’s cursor was. Monitoring previousSelection offers 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.title;
      const formData = new FormData();
      formData.append("photograph", file);

      const id = uuidv4();

      Transforms.insertNodes(
        editor,
        {
          id,
          kind: "picture",
          caption: fileName,
          url: null,
          isUploading: true,
          kids: [{ 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 talk about in Step (3)’s implementation why we’d like that. We now replace the picture part to make use of the isUploading flag to point out 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 capable of 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.

Transferring 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 Rework.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/images folder of the app. Now that the picture add is full, we transfer to Step (3) the place we wish to set the URL on the picture within the resolve() operate of the axios promise. We may replace the picture with Transforms.setNodes however we now have an issue — we should 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 should be on the newly inserted picture node? We can’t assure this since whereas the picture was importing, the person might need clicked some other place and the choice might need modified.
  • How about utilizing previousSelection which we used to insert the picture node within the first place? For a similar purpose we are able to’t use editor.choice, we are able to’t use previousSelection since it could have modified too.
  • SlateJS has a History module that tracks all of the adjustments taking place to the doc. We may use this module to look 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 person inserted extra photos in numerous elements of the doc earlier than the primary add accomplished.
  • At the moment, Rework.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 beneath —

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: `/images/${fileName}` },
            { at: newImageEntry[1] }
          );
        })
        .catch((error) => {
          // Hearth one other Rework.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 Photographs for our editor. At the moment, we present a loading state of the identical dimension regardless of the picture. This might be a jarring expertise for the person if the loading state is changed by a drastically smaller or greater 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 dimension in order that transition is seamless. The hook we add above might be prolonged to help different media varieties like video or paperwork and render these forms of nodes as properly.

Conclusion

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

  • Collaboration
  • A richer textual content modifying expertise that helps textual content alignments, inline photos, copy-paste, altering font and textual content colours and many others.
  • Importing from well-liked codecs like Phrase paperwork and Markdown.

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

  • SlateJS Examples
    Quite a lot of examples that transcend the fundamentals and construct functionalities which can be normally present in Editors like Search & Spotlight, Markdown Preview and Mentions.
  • API Docs
    Reference to a variety of helper features uncovered by SlateJS that one may wish to hold useful when attempting to carry out advanced queries/transformations on SlateJS objects.

Lastly, SlateJS’s Slack Channel is a really lively group of internet builders constructing Wealthy Textual content Enhancing purposes utilizing SlateJS and a fantastic place to be taught extra concerning the library and get assist if wanted.

Smashing Editorial
(vf, il)



Source link