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


About The Writer

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

On this article, we are going to 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 appliance is available on GitHub for reference.

Lately, the sector of Content material Creation and Illustration on Digital platforms has seen an enormous disruption. The widespread success of merchandise like Quip, Google Docs and Dropbox Paper has proven how corporations are racing to construct the perfect expertise for content material creators within the enterprise area and looking for revolutionary methods of breaking the standard moulds of how content material is shared and consumed. Making the most of the large 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 essential that these merchandise present a performant and seamless expertise of content material creation and have groups of designers and engineers who develop some stage of area experience over time on this house. With this text, we attempt to not solely lay the muse of constructing an editor but additionally give the readers a glimpse into how little nuggets of functionalities when introduced collectively can create an awesome person 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 information constructions concerned.

Doc Nodes

Doc nodes are used to signify the contents of the doc. The frequent varieties 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 thing they signify 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 varieties of nodes that signify 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 out there width. Block nodes may include different block nodes or inline nodes inside them. An commentary 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 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 standard Wealthy Textual content Modifying 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 signify non-content properties of a node or it’s kids. 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 technique to signify them may very well 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 stage utilizing nodes and attributes highlighting a number of 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 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.

Areas 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) might be represented with some type 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 technique to signify person choice which is sort 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 may very well be:

  • Is the person’s cursor at the moment inside a hyperlink, perhaps we must always present them a menu to edit/take away the hyperlink?
  • Has the person chosen a picture? Perhaps 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 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 signify a spread of textual content contained in the doc. This idea comes from Net’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 for example).

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 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}, // house + 'ipsum'
}`

Setting Up The Editor

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

Create a folder known 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 appliance.

yarn add slate slate-react

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

We first create a utils folder that holds any utility modules we create on this software. We begin with creating an ExampleDocument.js that returns a fundamental doc construction that comprises a paragraph with some textual content. This module appears 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 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 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 elements so as to add a navigation bar to the appliance as properly.

App.js element now appears like beneath:

import Editor from './elements/Editor';

perform 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"
            top="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 thing 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 the appliance to entry choices, 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 fix some configuration to it. This enables internet builders so as to add configurations to their SlateJS editor occasion in a composable approach.

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 software code. Editable is the element that renders the doc hierarchy for modifying. General, the Editor.js module at this stage appears like beneath:

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

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

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

At this level, we’ve vital 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 beneath.

Primary Editor Setup in motion

Now, let’s transfer on to the subsequent 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 varieties we could add to the doc. On this part, we wish to have the ability to render the heading nodes. To have the ability to try this, we offer a renderElement perform prop to Slate’s elements. This perform will get known as by Slate at runtime when it’s making an attempt to traverse the doc tree and render every node. The renderElement perform will get three parameters —

  • attributes
    SlateJS particular that should should be utilized to the top-level DOM aspect being returned from this perform.
  • aspect
    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 known as useEditorConfig the place we are going to 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 perform useEditorConfig(editor) {
  return { renderElement };
}

perform renderElement(props) {
  const { aspect, kids, attributes } = props;
  swap (aspect.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 perform offers us entry to the aspect (which is the node itself), we will customise renderElement to implement a extra custom-made rendering that does extra than simply checking aspect.kind. As an illustration, you may have a picture node that has a isInline property that we may use to return a unique DOM construction that helps us render inline photos as in opposition to block photos.

We now replace the Editor element 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 perform 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 stage nodes of the doc tree). Following the instance of renderElement, we write an implementation for renderLeaf.

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

// ...
perform 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 essential commentary 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 will customise the perform to implement a extra custom-made rendering. As an illustration, you may need a technique to let customers select a highlightColor for textual content and verify 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 combos 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",
    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 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 perform 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>
  );
}

perform 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 element that may 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 software.

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

Listed below are the three key functionalities we want the toolbar to assist:

  1. When the person’s cursor is in a sure spot within the doc they usually click on one of many character model buttons, we have to toggle the model for the textual content they could kind subsequent.
  2. When the person selects a spread 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 person selects a spread 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 unique 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 habits

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 technique that might give us the most recent choice state of the doc. Nonetheless, 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 technique 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 perform 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 beneath and move 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 software the place we’ve a a lot larger Editor codebase with much more functionalities, you will need to retailer and take heed to choice adjustments in a performant approach (like utilizing some state administration library) as elements listening to choice adjustments are prone to render too typically. A method to do that is to have optimized selectors on prime of the Choice state that maintain particular choice data. As an illustration, an editor would possibly need to render a picture resizing menu when an Picture is chosen. In such a case, it could 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 maintain 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 perform within the module is getActiveStyles that provides a Set of lively types within the editor. We additionally add a perform to toggle a method on the editor perform — toggleStyle:

# src/utils/EditorUtils.js

import { Editor } from "slate";

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

export perform 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 lots 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 perform 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 greater up within the render hierarchy.

One would possibly surprise why we use onMouseDown right here as an alternative of onClick? There may be an open Github Issue about how Slate turns the choice to null when the editor loses focus in any approach. So, if we connect onClick handlers to our toolbar buttons, the choice turns into null and customers lose their cursor place making an attempt to toggle a method which isn’t an awesome expertise. We as an alternative toggle the model by attaching a onMouseDown occasion which prevents the choice from getting reset. One other approach 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 resolve a unique 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 try 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 perform 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. Just like how paragraph-style dropdowns work in standard Phrase Processing functions like MS Phrase or Google Docs, we wish types of the highest stage blocks in person’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 habits should work for each — collapsed and expanded choices.

To implement this habits, we want 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 perform 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 perform takes an Editor occasion and an choices object that may be a technique to filter nodes within the tree because it traverses it. The perform 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 generally is a Path/Level/Vary that the helper perform 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 all in favour of nodes inside person’s choice.
  • match
    It is a matching perform one can present that is named 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 all in favour of all, highest-level or lowest stage nodes at the given location matching match perform. This parameter (set to highest) helps us escape making an attempt 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 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 lots of helper features that allow you to question for nodes in numerous methods, traverse the tree, replace the nodes or choices in complicated methods. Value digging into a few of these interfaces (listed in direction of the tip of this text) when constructing complicated modifying functionalities on prime of Slate.

With that background on the helper perform, beneath is an implementation of getTextBlockStyle.

# src/utils/EditorUtils.js 

export perform 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 (verify nodeEntries and the filtering later — source). That is okay for smaller paperwork. Nonetheless, for our use case, if the person chosen, say 3 headings and a couple of paragraphs (every paragraph containing say 10 textual content nodes), it’s going to cycle by a minimum of 25 nodes (3 + 2 + 2*10) and attempt to run filters on them. Since we already know we’re all in favour of top-level nodes solely, we may discover begin and finish indexes of the highest stage blocks from the choice and iterate ourselves. Such a logic would loop by solely 3 node entries (2 headings and 1 paragraph). Code for that will look one thing like beneath:

export perform 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 typically, you will need to take into consideration probably the most performant methods to take action for the use case at hand because the out there API or helper strategies won’t at all times be probably the most environment friendly approach to take action.

As soon as we’ve getTextBlockStyle carried out, toggling of the block model is comparatively simple. If the present model is just not what person chosen within the dropdown, we toggle the model 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 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 beneath:

# src/utils/EditorUtils.js

export perform 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 varieties and altering the sort with the dropdown.

On this part, we’re going to add assist to indicate, 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 try 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 element to render so Slate is aware of learn how to render the hyperlink nodes.

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

perform renderElement(props) {
  const { aspect, kids, attributes } = props;
  swap (aspect.kind) {
     ...
    case "hyperlink":
      return <Hyperlink {...props} url={aspect.url} />;
      ...
  }
}
# src/elements/Hyperlink.js
export default perform Hyperlink({ aspect, attributes, kids }) {
  return (
    <a href={aspect.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 want a approach within the toolbar to know if the person’s choice is inside a hyperlink node. We add a util perform 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 perform from SlateJS.

# src/utils/EditorUtils.js

export perform 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/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 lively if choice is inside a hyperlink.

To toggle hyperlinks within the editor, we add a util perform toggleLinkAtSelection. Let’s first take a look at how the toggle works when you have got some textual content chosen. When the person selects some textual content and clicks on the button, we wish 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 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 perform known as Transforms.wrapNodes that does precisely this — wrap nodes at a location into a brand new container node. We even have a helper out there 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 perform 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) => 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 perform up with the toolbar button and may now have a approach 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)}
      />

To date, our editor has a approach so as to add and take away hyperlinks however we don’t have a technique to replace the URLs related to these hyperlinks. How about we lengthen the person expertise to permit customers to edit it simply with a contextual menu? To allow hyperlink modifying, we are going to 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 element and rendering it at any time when the person choice is inside a hyperlink.

# src/elements/LinkEditor.js
export default perform 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 exterior the editor, we want a technique to inform LinkEditor the place the hyperlink is positioned 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 similar to the hyperlink node in choice. And we then use getBoundingClientRect() to search out the hyperlink’s DOM aspect’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 beneath —

# 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 perform 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,
      top: 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 parts. We entry that map and discover the hyperlink’s DOM aspect 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 technique 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 aspect and a button to the LinkEditor that permit the person 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 perform 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="main"
          disabled={!isUrl(linkURL)}
          onClick={onApply}
        >
          Apply
        </Button>
   ...
 );

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

Editor shedding 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’s because as we render the hyperlink editor exterior the Editable element, when the person clicks on the enter aspect, SlateJS thinks the editor has misplaced focus and resets the choice to be null which removes the LinkEditor since isLinkActiveAtSelection is just not true anymore. There may be an open GitHub Issue that talks about this Slate habits. One technique to resolve that is to trace the earlier choice of a person 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 perform 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 search for the hyperlink node, render beneath it and replace it’s URL.

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

A lot of the phrase processing functions determine and convert hyperlinks inside textual content to hyperlink objects. Let’s see how that will 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 habits could 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 that may have come earlier than it.
  2. If the final character was house, 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’ve to watch out to not go previous the sting of the beginning of the node into the earlier node.
  3. As soon as we’ve discovered the beginning and finish boundaries of the phrase earlier than, we verify 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 perform identifyLinksInTextIfAny that lives in EditorUtils and is named 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 carried out:

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

  const [node, _] = Editor.mother or father(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're not 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 could 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 house 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)

Word that begin and finish are the factors earlier than and after the house there. Equally, startPointOfLastCharacter and cursorPoint are the factors earlier than and after the house 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’ve 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 appears 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 is named 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 process 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’ve wrapped up functionalities for hyperlinks on the editor and transfer on to Photographs.

Dealing with Photographs

On this part, we give attention to including assist to render picture nodes, add new photos and replace picture captions. Photographs, in our doc construction, could 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 enables us to render photos as voids. Due to Slate’s flexibility with rendering, we will nonetheless render our personal editable parts inside Void parts — which we are going to for picture caption-editing. SlateJS has an example which demonstrates how one can embed a whole Wealthy Textual content Editor inside a Void aspect.

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

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



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

Two issues to recollect when making an attempt to render void nodes with SlateJS:

  • The basis DOM aspect 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 aspect, SlateJS could attempt to compute choices and many others. and break consequently.
  • 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 aspect 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 give attention to caption-editing. The way in which we wish this to be a seamless expertise for the person is that after 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 exterior 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’ve an thought 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 person updates it and after 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, aspect }) => {
  const [isEditingCaption, setEditingCaption] = useState(false);
  const [caption, setCaption] = useState(aspect.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={aspect.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 approach for customers to add photos 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 aspect. 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 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 are going to add the picture to.

# src/server.js

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

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

app.put up("/add", perform (req, res) {
  add(req, res, perform (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’ve the server setup, we will give attention to dealing with the picture add. When the person uploads a picture, it may very well be a couple of seconds earlier than the picture will get uploaded and we’ve a URL for it. Nonetheless, we do what to present 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 below are the steps we implement to make this habits 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 will 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’ve 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 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 perform 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("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 want that. We now replace the picture element to make use of the isUploading flag to indicate a loading state.

{!aspect.isUploading && aspect.url != null ? (
   <img src={aspect.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 are going to use axois library to ship a request to the server.

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

    axios
      .put up("/add", formData, {
        headers: {
          "content-type": "multipart/form-data",
        },
      })
      .then((response) => {
           // replace the picture node.
       })
      .catch((error) => {
        // Fireplace 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() perform of the axios promise. We may replace the picture with Transforms.setNodes however we’ve an issue — we don’t 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 not assure this since whereas the picture was importing, the person may need clicked elsewhere 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 motive we will’t use editor.choice, we will’t use previousSelection since it could have modified too.
  • SlateJS has a History module that tracks all of the adjustments occurring to the doc. We may use this module to look the historical past and discover the final inserted picture node. This additionally isn’t fully 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, 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 must 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) appears like beneath —

axios
        .put up("/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) => {
          // Fireplace 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 Photographs for our editor. At the moment, we present a loading state of the identical measurement no matter the picture. This may very well be a jarring expertise for the person if the loading state is changed by a drastically smaller or larger picture when the add completes. A great 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 may very well be prolonged to assist different media varieties like video or paperwork and render these varieties of nodes as properly.

Conclusion

On this article, we’ve 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 Modifying normally. If this downside house surrounding Wealthy Textual content Modifying or Phrase Processing pursuits you, a number of the cool issues to go after may very well 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 standard codecs like Phrase paperwork and Markdown.

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

  • SlateJS Examples
    Loads 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 lots of helper features uncovered by SlateJS that one would possibly need to maintain useful when making an attempt to carry out complicated queries/transformations on SlateJS objects.

Lastly, SlateJS’s Slack Channel is a really lively neighborhood of internet builders constructing Wealthy Textual content Modifying functions utilizing SlateJS and an awesome place to be taught extra concerning the library and get assist if wanted.

Smashing Editorial
(vf, il)



Source link