Web-Design
Friday May 28, 2021 By David Quintanilla
Adding A Commenting System To A WYSIWYG Editor — 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’ll be re-using the foundational WYSIWYG Editor constructed within the first article to construct a commenting system for a WYSIWYG Editor that permits customers to pick out textual content inside a doc and share their feedback on it. We’ll even be bringing in RecoilJS for state administration within the UI software. (The code for the system we construct right here is accessible on a Github repository for reference.)

In recent times, we’ve seen Collaboration penetrate loads of digital workflows and use-cases throughout many professions. Simply inside the Design and Software program Engineering neighborhood, we see designers collaborate on design artifacts utilizing instruments like Figma, groups doing Dash and Mission Planning utilizing instruments like Mural and interviews being performed utilizing CoderPad. All these instruments are always aiming to bridge the hole between a web-based and a bodily world expertise of executing these workflows and making the collaboration expertise as wealthy and seamless as potential.

For almost all of the Collaboration Instruments like these, the power to share opinions with each other and have discussions about the identical content material is a must have. A Commenting System that permits collaborators to annotate components of a doc and have conversations about them is on the coronary heart of this idea. Together with constructing one for textual content in a WYSIWYG Editor, the article tries to have interaction the readers into how we attempt to weigh the professionals and cons and try to discover a steadiness between software complexity and person expertise on the subject of constructing options for WYSIWYG Editors or Phrase Processors usually.

In an effort to discover a method to characterize feedback in a wealthy textual content doc’s information construction, let’s take a look at just a few eventualities underneath which feedback may very well be created inside an editor.

  • Feedback created over textual content that has no kinds on it (primary situation);
  • Feedback created over textual content which may be daring/italic/underlined, and so forth;
  • Feedback that overlap one another indirectly (partial overlap the place two feedback share just a few phrases or fully-contained the place one remark’s textual content is totally contained inside textual content of one other remark);
  • Feedback created over textual content inside a hyperlink (particular as a result of hyperlinks are nodes themselves in our doc construction);
  • Feedback that span a number of paragraphs (particular as a result of paragraphs are nodes in our doc construction and feedback are utilized to textual content nodes that are paragraph’s youngsters).

Trying on the above use-cases, it looks as if feedback in the best way they’ll come up in a wealthy textual content doc are similar to character kinds (daring, italics and so forth). They will overlap with one another, go over textual content in different varieties of nodes like hyperlinks and even span a number of dad or mum nodes like paragraphs.

For that reason, we use the identical methodology to characterize feedback as we do for character kinds, i.e. “Marks” (as they’re so referred to as in SlateJS terminology). Marks are simply common properties on nodes — speciality being that Slate’s API round marks (Editor.addMark and Editor.removeMark) handles altering of the node hierarchy as a number of marks get utilized to the identical vary of textual content. That is extraordinarily helpful to us as we take care of loads of totally different combos of overlapping feedback.

Each time a person selects a variety of textual content and tries to insert a remark, technically, they’re beginning a brand new remark thread for that textual content vary. As a result of we might enable them to insert a remark and later replies to that remark, we deal with this occasion as a brand new remark thread insertion within the doc.

The best way we characterize remark threads as marks is that every remark thread is represented by a mark named as commentThread_threadID the place threadID is a novel ID we assign to every remark thread. So, if the identical vary of textual content has two remark threads over it, it might have two properties set to the truecommentThread_thread1 and commentThread_thread2. That is the place remark threads are similar to character kinds since if the identical textual content was daring and italic, it might have each the properties set to truedaring and italic.

Earlier than we dive into really setting this construction up, it’s price how the textual content nodes change as remark threads get utilized to them. The best way this works (because it does with any mark) is that when a mark property is being set on the chosen textual content, Slate’s Editor.addMark API would cut up the textual content node(s) if wanted such that within the ensuing construction, textual content nodes are arrange in a approach that every textual content node has the very same worth of the mark.

To know this higher, check out the next three examples that present the before-and-after state of the textual content nodes as soon as a remark thread is inserted on the chosen textual content:

Illustration showing how text node is split with a basic comment thread insertion
A textual content node getting cut up into three as a remark thread mark is inserted in the course of the textual content. (Large preview)
Illustration showing how text node is split in case of a partial overlap of comment threads
Including a remark thread over ‘textual content has’ creates two new textual content nodes. (Large preview)
Illustration showing how text node is split in case of a partial overlap of comment threads with links
Including a remark thread over ‘has hyperlink’ splits the textual content node contained in the hyperlink too. (Large preview)

Now that we all know how we’re going to characterize feedback within the doc construction, let’s go forward and add just a few to the instance doc from the first article and configure the editor to truly present them as highlighted. Since we may have loads of utility capabilities to take care of feedback on this article, we create a EditorCommentUtils module that can home all these utils. To begin with, we create a perform that creates a mark for a given remark thread ID. We then use that to insert just a few remark threads in our ExampleDocument.

# src/utils/EditorCommentUtils.js

const COMMENT_THREAD_PREFIX = "commentThread_";

export perform getMarkForCommentThreadID(threadID) {
  return `${COMMENT_THREAD_PREFIX}${threadID}`;
}

Under picture underlines in pink the ranges of textual content that we’ve as instance remark threads added within the subsequent code snippet. Observe that the textual content ‘Richard McClintock’ has two remark threads that overlap one another. Particularly, this can be a case of 1 remark thread being totally contained inside one other.

Picture showing which text ranges in the document are going to be commented upon - one of them being fully contained in another.
Textual content ranges that might be commented upon underlined in pink. (Large preview)
# src/utils/ExampleDocument.js
import { getMarkForCommentThreadID } from "../utils/EditorCommentUtils";
import { v4 as uuid } from "uuid";

const exampleOverlappingCommentThreadID = uuid();

const ExampleDocument = [
   ...
   {
        text: "Lorem ipsum",
        [getMarkForCommentThreadID(uuid())]: true,
   },
   ...
   {
        textual content: "Richard McClintock",
        // word the 2 remark threads right here.
        [getMarkForCommentThreadID(uuid())]: true,
        [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true,
   },
   {
        textual content: ", a Latin scholar",
        [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true,
   },
   ...
];

We concentrate on the UI facet of issues of a Commenting System on this article so we assign them IDs within the instance doc immediately utilizing the npm package deal uuid. Very seemingly that in a manufacturing model of an editor, these IDs are created by a backend service.

We now concentrate on tweaking the editor to point out these textual content nodes as highlighted. In an effort to do this, when rendering textual content nodes, we want a method to inform if it has remark threads on it. We add a util getCommentThreadsOnTextNode for that. We construct on the StyledText element that we created within the first article to deal with the case the place it could be attempting to render a textual content node with feedback on. Since we’ve some extra performance coming that might be added to commented textual content nodes later, we create a element CommentedText that renders the commented textual content. StyledText will verify if the textual content node it’s attempting to render has any feedback on it. If it does, it renders CommentedText. It makes use of a util getCommentThreadsOnTextNode to infer that.

# src/utils/EditorCommentUtils.js

export perform getCommentThreadsOnTextNode(textNode) {
  return new Set(
     // As a result of marks are simply properties on nodes,
    // we will merely use Object.keys() right here.
    Object.keys(textNode)
      .filter(isCommentThreadIDMark)
      .map(getCommentThreadIDFromMark)
  );
}

export perform getCommentThreadIDFromMark(mark) {
  if (!isCommentThreadIDMark(mark)) {
    throw new Error("Anticipated mark to be of a remark thread");
  }
  return mark.substitute(COMMENT_THREAD_PREFIX, "");
}

perform isCommentThreadIDMark(mayBeCommentThread) {
  return mayBeCommentThread.indexOf(COMMENT_THREAD_PREFIX) === 0;
}

The first article constructed a element StyledText that renders textual content nodes (dealing with character kinds and so forth). We lengthen that element to make use of the above util and render a CommentedText element if the node has feedback on it.

# src/elements/StyledText.js

import { getCommentThreadsOnTextNode } from "../utils/EditorCommentUtils";

export default perform StyledText({ attributes, youngsters, leaf }) {
  ...

  const commentThreads = getCommentThreadsOnTextNode(leaf);

  if (commentThreads.dimension > 0) {
    return (
      <CommentedText
      {...attributes}
     // We use commentThreads and textNode props later within the article.
      commentThreads={commentThreads}
      textNode={leaf}
      >
        {youngsters}
      </CommentedText>
    );
  }

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

Under is the implementation of CommentedText that renders the textual content node and attaches the CSS that exhibits it as highlighted.

# src/elements/CommentedText.js

import "./CommentedText.css";

import classNames from "classnames";

export default perform CommentedText(props) {
  const { commentThreads, ...otherProps } = props;
  return (
    <span
      {...otherProps}
      className={classNames({
        remark: true,
      })}
    >
      {props.youngsters}
    </span>
  );
}

# src/elements/CommentedText.css

.remark {
  background-color: #feeab5;
}

With the entire above code coming collectively, we now see textual content nodes with remark threads highlighted within the editor.

Commented text nodes appears as highlighted after comment threads have been inserted
Commented textual content nodes seem as highlighted after remark threads have been inserted. (Large preview)

Observe: The customers at present can not inform if sure textual content has overlapping feedback on it. The whole highlighted textual content vary appears like a single remark thread. We tackle that later within the article the place we introduce the idea of energetic remark thread which lets customers choose a selected remark thread and have the ability to see its vary within the editor.

Earlier than we add the performance that permits a person to insert new feedback, we first setup a UI state to carry our remark threads. On this article, we use RecoilJS as our state administration library to retailer remark threads, feedback contained contained in the threads and different metadata like creation time, standing, remark creator and so forth. Let’s add Recoil to our software:

> yarn add recoil

We use Recoil atoms to retailer these two information buildings. Should you’re not conversant in Recoil, atoms are what maintain the appliance state. For various items of software state, you’d often wish to arrange totally different atoms. Atom Family is a set of atoms — it may be considered a Map from a novel key figuring out the atom to the atoms themselves. It’s price going by core concepts of Recoil at this level and familiarizing ourselves with them.

For our use case, we retailer remark threads as an Atom household after which wrap our software in a RecoilRoot element. RecoilRoot is utilized to offer the context wherein the atom values are going for use. We create a separate module CommentState that holds our Recoil atom definitions as we add extra atom definitions later within the article.

# src/utils/CommentState.js

import { atom, atomFamily } from "recoil";

export const commentThreadsState = atomFamily({
  key: "commentThreads",
  default: [],
});

export const commentThreadIDsState = atom({
  key: "commentThreadIDs",
  default: new Set([]),
});

Value calling out few issues about these atom definitions:

  • Every atom/atom household is uniquely recognized by a key and may be arrange with a default worth.
  • As we construct additional on this article, we’re going to want a method to iterate over all of the remark threads which might principally imply needing a method to iterate over commentThreadsState atom household. On the time of writing this text, the best way to try this with Recoil is to arrange one other atom that holds all of the IDs of the atom household. We do this with commentThreadIDsState above. Each these atoms must be saved in sync every time we add/delete remark threads.

We add a RecoilRoot wrapper in our root App element so we will use these atoms later. Recoil’s documentation additionally gives a useful Debugger element that we take as it’s and drop into our editor. This element will depart console.debug logs to our Dev console as Recoil atoms are up to date in real-time.

# src/elements/App.js

import { RecoilRoot } from "recoil";

export default perform App() {
  ...

  return (
    <RecoilRoot>
      >
         ...
        <Editor doc={doc} onChange={updateDocument} />
    
    </RecoilRoot>
  );
}
# src/elements/Editor.js

export default perform Editor({ ... }): JSX.Ingredient {
  .....

  return (
    <>
      <Slate>
         .....
      </Slate>
      <DebugObserver />
   </>
);

perform DebugObserver(): React.Node {
   // see API hyperlink above for implementation.
}

We additionally want to want so as to add code that initializes our atoms with the remark threads that exist already on the doc (those we added to our instance doc within the earlier part, as an example). We do this at a later level after we construct the Feedback Sidebar that should learn all of the remark threads in a doc.

At this level, we load our software, be sure that there aren’t any errors pointing to our Recoil setup and transfer ahead.

On this part, we add a button to the toolbar that lets the person add feedback (viz. create a brand new remark thread) for the chosen textual content vary. When the person selects a textual content vary and clicks on this button, we have to do the under:

  1. Assign a novel ID to the brand new remark thread being inserted.
  2. Add a brand new mark to Slate doc construction with the ID so the person sees that textual content highlighted.
  3. Add the brand new remark thread to Recoil atoms we created within the earlier part.

Let’s add a util perform to EditorCommentUtils that does #1 and #2.

# src/utils/EditorCommentUtils.js

import { Editor } from "slate";
import { v4 as uuidv4 } from "uuid";

export perform insertCommentThread(editor, addCommentThreadToState) {
    const threadID = uuidv4();
    const newCommentThread = {
        // feedback as added can be appended to the thread right here.
        feedback: [],
        creationTime: new Date(),
        // Newly created remark threads are OPEN. We take care of statuses
        // later within the article.
        standing: "open",
    };
    addCommentThreadToState(threadID, newCommentThread);
    Editor.addMark(editor, getMarkForCommentThreadID(threadID), true);
    return threadID;
}

Through the use of the idea of marks to retailer every remark thread as its personal mark, we’re in a position to merely use the Editor.addMark API so as to add a brand new remark thread on the textual content vary chosen. This name alone handles all of the totally different instances of including feedback — a few of which we described within the earlier part — partially overlapping feedback, feedback inside/overlapping hyperlinks, feedback over daring/italic textual content, feedback spanning paragraphs and so forth. This API name adjusts the node hierarchy to create as many new textual content nodes as wanted to deal with these instances.

addCommentThreadToState is a callback perform that handles step #3 — including the brand new remark thread to Recoil atom . We implement that subsequent as a customized callback hook in order that it’s re-usable. This callback wants so as to add the brand new remark thread to each the atoms — commentThreadsState and commentThreadIDsState. To have the ability to do that, we use the useRecoilCallback hook. This hook can be utilized to assemble a callback which will get just a few issues that can be utilized to learn/set atom information. The one we’re fascinated by proper now’s the set perform which can be utilized to replace an atom worth as set(atom, newValueOrUpdaterFunction).

# src/hooks/useAddCommentThreadToState.js

import {
  commentThreadIDsState,
  commentThreadsState,
} from "../utils/CommentState";

import { useRecoilCallback } from "recoil";

export default perform useAddCommentThreadToState() {
  return useRecoilCallback(
    ({ set }) => (id, threadData) => {
      set(commentThreadIDsState, (ids) => new Set([...Array.from(ids), id]));
      set(commentThreadsState(id), threadData);
    },
    []
  );
}

The primary name to set provides the brand new ID to the present set of remark thread IDs and returns the brand new Set(which turns into the brand new worth of the atom).

Within the second name, we get the atom for the ID from the atom household — commentThreadsState as commentThreadsState(id) after which set the threadData to be its worth. atomFamilyName(atomID) is how Recoil lets us entry an atom from its atom household utilizing the distinctive key. Loosely talking, lets say that if commentThreadsState was a javascript Map, this name is principally — commentThreadsState.set(id, threadData).

Now that we’ve all this code setup to deal with insertion of a brand new remark thread to the doc and Recoil atoms, lets add a button to our toolbar and wire it up with the decision to those capabilities.

# src/elements/Toolbar.js

import { insertCommentThread } from "../utils/EditorCommentUtils";
import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState";

export default perform Toolbar({ choice, previousSelection }) {
  const editor = useEditor();
  ...

  const addCommentThread = useAddCommentThreadToState();

  const onInsertComment = useCallback(() => {
    const newCommentThreadID = insertCommentThread(editor, addCommentThread);
  }, [editor, addCommentThread]);
 
return (
    <div className="toolbar">
       ...
      <ToolBarButton
        isActive={false}
        label={<i className={`bi ${getIconForButton("remark")}`} />}
        onMouseDown={onInsertComment}
      />
    </div>
  );
}

Observe: We use onMouseDown and never onClick which might have made the editor lose focus and choice to turn into null. We’ve mentioned that in somewhat extra element within the hyperlink insertion part of the first article.

Within the under instance, we see the insertion in motion for a easy remark thread and an overlapping remark thread with hyperlinks. Discover how we get updates from Recoil Debugger confirming our state is getting up to date appropriately. We additionally confirm that new textual content nodes are created as threads are being added to the doc.

Inserting a remark thread splits the textual content node making the commented textual content its personal node.
Extra textual content nodes get created as we add overlapping feedback.

Earlier than we proceed with including extra options to our commenting system, we have to make some selections round how we’re going to take care of overlapping feedback and their totally different combos within the editor. To see why we want that, let’s take a sneak peek into how a Remark Popover works — a performance we are going to construct later within the article. When a person clicks on a sure textual content with remark thread(s) on it, we ‘choose’ a remark thread and present a popover the place the person can add feedback to that thread.

When the person clicks on a textual content node with overlapping feedback, the editor must resolve which remark thread to pick out.

As you possibly can inform from the above video, the phrase ‘designers’ is now a part of three remark threads. So we’ve two remark threads that overlap with one another over a phrase. And each these remark threads (#1 and #2) are totally contained inside an extended remark thread textual content vary (#3). This raises just a few questions:

  1. Which remark thread ought to we choose and present when the person clicks on the phrase ‘designers’?
  2. Based mostly on how we resolve to sort out the above query, would we ever have a case of overlap the place clicking on any phrase would by no means activate a sure remark thread and the thread can’t be accessed in any respect?

This suggests within the case of overlapping feedback, a very powerful factor to contemplate is — as soon as the person has inserted a remark thread, would there be a approach for them to have the ability to choose that remark thread sooner or later by clicking on some textual content inside it? If not, we most likely don’t wish to enable them to insert it within the first place. To make sure this precept is revered most of the time in our editor, we introduce two guidelines relating to overlapping feedback and implement them in our editor.

Earlier than we outline these guidelines, it’s price calling out that totally different editors and phrase processors have totally different approaches on the subject of overlapping feedback. To maintain issues easy, some editors don’t enable overlapping feedback by any means. In our case, we attempt to discover a center floor by not permitting too sophisticated instances of overlaps however nonetheless permitting overlapping feedback in order that customers may have a richer Collaboration and Overview expertise.

This rule helps us reply the query #1 from above as to which remark thread to pick out if a person clicks on a textual content node that has a number of remark threads on it. The rule is:

“If the person clicks on textual content that has a number of remark threads on it, we discover the remark thread of the shortest textual content vary and choose that.”

Intuitively, it is smart to do that in order that the person all the time has a method to get to the innermost remark thread that’s totally contained inside one other remark thread. For different situations (partial overlap or no-overlap), there ought to be some textual content that has just one remark thread on it so it ought to be simple to make use of that textual content to be able to choose that remark thread. It’s the case of a full (or a dense) overlap of threads and why we want this rule.

Let’s take a look at a quite advanced case of overlap that permits us to make use of this rule and ‘do the appropriate factor’ when choosing the remark thread.

Example showing three comment threads overlapping each other in a way that the only way to select a comment thread is using the shortest length rule.
Following the Shortest Remark Thread Rule, clicking on ‘B’ selects remark thread #1. (Large preview)

Within the above instance, the person inserts the next remark threads in that order:

  1. Remark Thread #1 over character ‘B’ (size = 1).
  2. Remark Thread #2 over ‘AB’ (size = 2).
  3. Remark Thread #3 over ‘BC’ (size = 2).

On the finish of those insertions, due to the best way Slate splits the textual content nodes with marks, we may have three textual content nodes — one for every character. Now, if the person clicks on ‘B’, going by the shortest size rule, we choose thread #1 as it’s the shortest of the three in size. If we don’t do this, we wouldn’t have a method to choose Remark Thread #1 ever since it is just one-character in size and in addition part of two different threads.

Though this rule makes it simple to floor shorter-length remark threads, we may run into conditions the place longer remark threads turn into inaccessible since all of the characters contained in them are a part of another shorter remark thread. Let’s take a look at an instance for that.

Let’s assume we’ve 100 characters (say, character ‘A’ typed 100 occasions that’s) and the person inserts remark threads within the following order:

  1. Remark Thread # 1 of vary 20,80
  2. Remark Thread # 2 of vary 0,50
  3. Remark Thread # 3 of vary 51,100
Example showing shortest length rule making a comment thread non-selectable as all of its text is covered by shorter comment threads.
All textual content underneath Remark Thread #1 can also be a part of another remark thread shorter than #1. (Large preview)

As you possibly can see within the above instance, if we comply with the rule we simply described right here, clicking on any character between #20 and #80, would all the time choose threads #2 or #3 since they’re shorter than #1 and therefore #1 wouldn’t be selectable. One other situation the place this rule can depart us undecided as to which remark thread to pick out is when there are multiple remark threads of the identical shortest size on a textual content node.

For such mixture of overlapping feedback and plenty of different such combos that one may consider the place following this rule makes a sure remark thread inaccessible by clicking on textual content, we construct a Feedback Sidebar later on this article which supplies person a view of all of the remark threads current within the doc to allow them to click on on these threads within the sidebar and activate them within the editor to see the vary of the remark. We nonetheless would wish to have this rule and implement it because it ought to cowl loads of overlap eventualities aside from the less-likely examples we cited above. We put in all this effort round this rule primarily as a result of seeing highlighted textual content within the editor and clicking on it to remark is a extra intuitive approach of accessing a touch upon textual content than merely utilizing a listing of feedback within the sidebar.

Insertion Rule

The rule is:

“If the textual content person has chosen and is attempting to touch upon is already totally coated by remark thread(s), don’t enable that insertion.”

That is so as a result of if we did enable this insertion, every character in that vary would find yourself having at the very least two remark threads (one current and one other the brand new one we simply allowed) making it tough for us to find out which one to pick out when the person clicks on that character later.

this rule, one may marvel why we want it within the first place if we have already got the Shortest Remark Vary Rule that permits us to pick out the smallest textual content vary. Why not enable all combos of overlaps if we will use the primary rule to infer the appropriate remark thread to point out? As among the examples we’ve mentioned earlier, the primary rule works for lots of eventualities however not all of them. With the Insertion Rule, we attempt to decrease the variety of eventualities the place the primary rule can not assist us and we’ve to fallback on the Sidebar as the one approach for the person to entry that remark thread. Insertion Rule additionally prevents exact-overlaps of remark threads. This rule is usually carried out by loads of in style editors.

Under is an instance the place if this rule didn’t exist, we might enable the Remark Thread #3 after which on account of the primary rule, #3 wouldn’t be accessible since it might turn into the longest in size.

Insertion Rule not permitting a 3rd remark thread whose whole textual content vary is roofed by two different remark threads.

Observe: Having this rule doesn’t imply we might by no means have totally contained overlapping feedback. The tough factor about overlapping feedback is that regardless of the foundations, the order wherein feedback are inserted can nonetheless depart us in a state we didn’t need the overlap to be in. Referring again to our instance of the feedback on the phrase ‘designers’ earlier, the longest remark thread inserted there was the final one to be added so the Insertion Rule would enable it and we find yourself with a completely contained scenario — #1 and #2 contained inside #3. That’s superb as a result of the Shortest Remark Vary Rule would assist us on the market.

We’ll implement the Shortest Remark Vary Rule within the next section the place we implement choosing of remark threads. Since we now have a toolbar button to insert feedback, we will implement the Insertion Rule straight away by checking the rule when the person has some textual content chosen. If the rule just isn’t happy, we might disable the Remark button so customers can not insert a brand new remark thread on the chosen textual content. Let’s get began!

# src/utils/EditorCommentUtils.js

export perform shouldAllowNewCommentThreadAtSelection(editor, choice) {
  if (choice == null || Vary.isCollapsed(choice)) {
    return false;
  }

  const textNodeIterator = Editor.nodes(editor, {
    at: choice,
    mode: "lowest",
  });

  let nextTextNodeEntry = textNodeIterator.subsequent().worth;
  const textNodeEntriesInSelection = [];
  whereas (nextTextNodeEntry != null) {
    textNodeEntriesInSelection.push(nextTextNodeEntry);
    nextTextNodeEntry = textNodeIterator.subsequent().worth;
  }

  if (textNodeEntriesInSelection.size === 0) {
    return false;
  }

  return textNodeEntriesInSelection.some(
    ([textNode]) => getCommentThreadsOnTextNode(textNode).dimension === 0
  );
}

The logic on this perform is comparatively easy.

  • If the person’s choice is a blinking caret, we don’t enable inserting a remark there as no textual content has been chosen.
  • If the person’s choice just isn’t a collapsed one, we discover all of the textual content nodes within the choice. Observe using the mode: lowest within the name to Editor.nodes (a helper perform by SlateJS) that helps us choose all of the textual content nodes since textual content nodes are actually the leaves of the doc tree.
  • If there’s at the very least one textual content node that has no remark threads on it, we might enable the insertion. We use the util getCommentThreadsOnTextNode we wrote earlier right here.

We now use this util perform contained in the toolbar to regulate the disabled state of the button.

# src/elements/Toolbar.js

export default perform Toolbar({ choice, previousSelection }) {
  const editor = useEditor();
  ....

  return (
   <div className="toolbar">
     ....
    <ToolBarButton
        isActive={false}
        disabled={!shouldAllowNewCommentThreadAtSelection(
          editor,
          choice
        )}
        label={<i className={`bi ${getIconForButton("remark")}`} />}
        onMouseDown={onInsertComment}
      />
  </div>
);

Let’s take a look at the implementation of the rule by recreating our instance above.

Insertion button within the toolbar disabled as person tries to insert remark over textual content vary already totally coated by different feedback.

A superb person expertise element to name out right here is that whereas we disable the toolbar button if the person has chosen your complete line of textual content right here, it doesn’t full the expertise for the person. The person might not totally perceive why the button is disabled and is more likely to get confused that we’re not responding to their intent to insert a remark thread there. We tackle this later as Remark Popovers are constructed such that even when the toolbar button is disabled, the popover for one of many remark threads would present up and the person would nonetheless have the ability to depart feedback.

Let’s additionally take a look at a case the place there’s some uncommented textual content node and the rule permits inserting a brand new remark thread.

Insertion Rule permitting insertion of remark thread when there’s some uncommented textual content inside person’s choice.

On this part, we allow the function the place the person clicks on a commented textual content node and we use the Shortest Remark Vary Rule to find out which remark thread ought to be chosen. The steps within the course of are:

  1. Discover the shortest remark thread on the commented textual content node that person clicks on.
  2. Set that remark thread to be the energetic remark thread. (We create a brand new Recoil atom which would be the supply of fact for this.)
  3. The commented textual content nodes would take heed to the Recoil state and if they’re a part of the energetic remark thread, they’d spotlight themselves in a different way. That approach, when the person clicks on the remark thread, your complete textual content vary stands out as all of the textual content nodes will replace their spotlight colour.

Let’s begin with Step #1 which is principally implementing the Shortest Remark Vary Rule. The purpose right here is to seek out the remark thread of the shortest vary on the textual content node on which the person clicked. To seek out the shortest size thread, we have to compute the size of all of the remark threads at that textual content node. Steps to do that are:

  1. Get all of the remark threads on the textual content node in query.
  2. Traverse in both course from that textual content node and preserve updating the thread lengths being tracked.
  3. Cease the traversal in a course after we’ve reached one of many under edges:
    • An uncommented textual content node (implying we’ve reached furthermost begin/finish fringe of all of the remark threads we’re monitoring).
    • A textual content node the place all of the remark threads we’re monitoring have reached an edge (begin/finish).
    • There aren’t any extra textual content nodes to traverse in that course (implying we’ve both reached the beginning or the top of the doc or a non-text node).

For the reason that traversals in ahead and reverse course are functionally the identical, we’re going to put in writing a helper perform updateCommentThreadLengthMap that principally takes a textual content node iterator. It is going to preserve calling the iterator and preserve updating the monitoring thread lengths. We’ll name this perform twice — as soon as for ahead and as soon as for backward course. Let’s write our foremost utility perform that can use this helper perform.

# src/utils/EditorCommentUtils.js

export perform getSmallestCommentThreadAtTextNode(editor, textNode) {

  const commentThreads = getCommentThreadsOnTextNode(textNode);
  const commentThreadsAsArray = [...commentThreads];

  let shortestCommentThreadID = commentThreadsAsArray[0];

  const reverseTextNodeIterator = (slateEditor, nodePath) =>
    Editor.earlier(slateEditor, {
      at: nodePath,
      mode: "lowest",
      match: Textual content.isText,
    });

  const forwardTextNodeIterator = (slateEditor, nodePath) =>
    Editor.subsequent(slateEditor, {
      at: nodePath,
      mode: "lowest",
      match: Textual content.isText,
    });

  if (commentThreads.dimension > 1) {

    // The map right here tracks the lengths of the remark threads.
    // We initialize the lengths with size of present textual content node
    // since all of the remark threads span over the present textual content node
    // as a minimum.
    const commentThreadsLengthByID = new Map(
      commentThreadsAsArray.map((id) => [id, textNode.text.length])
    );


    // traverse within the reverse course and replace the map
    updateCommentThreadLengthMap(
      editor,
      commentThreads,
      reverseTextNodeIterator,
      commentThreadsLengthByID
    );

    // traverse within the ahead course and replace the map
    updateCommentThreadLengthMap(
      editor,
      commentThreads,
      forwardTextNodeIterator,
      commentThreadsLengthByID
    );

    let minLength = Quantity.POSITIVE_INFINITY;


    // Discover the thread with the shortest size.
    for (let [threadID, length] of commentThreadsLengthByID) {
      if (size < minLength) {
        shortestCommentThreadID = threadID;
        minLength = size;
      }
    }
  }

  return shortestCommentThreadID;
}

The steps we listed out are all coated within the above code. The feedback ought to assist comply with how the logic flows there.

One factor price calling out is how we created the traversal capabilities. We wish to give a traversal perform to updateCommentThreadLengthMap such that it could actually name it whereas it’s iterating textual content node’s path and simply get the earlier/subsequent textual content node. To try this, Slate’s traversal utilities Editor.earlier and Editor.subsequent (outlined within the Editor interface) are very useful. Our iterators reverseTextNodeIterator and forwardTextNodeIterator name these helpers with two choices mode: lowest and the match perform Textual content.isText so we all know we’re getting a textual content node from the traversal, if there’s one.

Now we implement updateCommentThreadLengthMap which traverses utilizing these iterators and updates the lengths we’re monitoring.

# src/utils/EditorCommentUtils.js

perform updateCommentThreadLengthMap(
  editor,
  commentThreads,
  nodeIterator,
  map
) {
  let nextNodeEntry = nodeIterator(editor);

  whereas (nextNodeEntry != null) {
    const nextNode = nextNodeEntry[0];
    const commentThreadsOnNextNode = getCommentThreadsOnTextNode(nextNode);

    const intersection = [...commentThreadsOnNextNode].filter((x) =>
      commentThreads.has(x)
    );

     // All remark threads we're in search of have already ended which means
    // reached an uncommented textual content node OR a commented textual content node which
    // has not one of the remark threads we care about.
    if (intersection.size === 0) {
      break;
    }


    // replace thread lengths for remark threads we did discover on this
    // textual content node.
    for (let i = 0; i < intersection.size; i++) {
      map.set(intersection[i], map.get(intersection[i]) + nextNode.textual content.size);
    }


    // name the iterator to get the following textual content node to contemplate
    nextNodeEntry = nodeIterator(editor, nextNodeEntry[1]);
  }

  return map;
}

One may marvel why will we wait till the intersection turns into 0 to cease iterating in a sure course. Why can’t we simply cease if we’re reached the sting of at the very least one remark thread — that might suggest we’ve reached the shortest size in that course, proper? The explanation we will’t do that’s that we all know {that a} remark thread can span over a number of textual content nodes and we wouldn’t know which of these textual content nodes did the person click on on and we began our traversal from. We wouldn’t know the vary of all remark threads in query with out totally traversing to the farthest edges of the union of the textual content ranges of the remark threads in each the instructions.

Try the under instance the place we’ve two remark threads ‘A’ and ‘B’ overlapping one another indirectly ensuing into three textual content nodes 1,2 and three — #2 being the textual content node with the overlap.

Example of multiple comment threads overlapping on a text node.
Two remark threads overlapping over the phrase ‘textual content’. (Large preview)

On this instance, let’s assume we don’t await intersection to turn into 0 and simply cease after we attain the sting of a remark thread. Now, if the person clicked on #2 and we begin traversal in reverse course, we’d cease at first of textual content node #2 itself since that’s the beginning of the remark thread A. In consequence, we would not compute the remark thread lengths appropriately for A & B. With the implementation above traversing the farthest edges (textual content nodes 1,2, and three), we must always get B because the shortest remark thread as anticipated.

To see the implementation visually, under is a walkthrough with a slideshow of the iterations. We’ve got two remark threads A and B that overlap one another over textual content node #3 and the person clicks on the overlapping textual content node #3.

Slideshow exhibiting iterations within the implementation of Shortest Remark Thread Rule.

Steps 2 & 3: Sustaining State Of The Chosen Remark Thread And Highlighting It

Now that we’ve the logic for the rule totally carried out, let’s replace the editor code to make use of it. For that, we first create a Recoil atom that’ll retailer the energetic remark thread ID for us. We then replace the CommentedText element to make use of our rule’s implementation.

# src/utils/CommentState.js

import { atom } from "recoil";

export const activeCommentThreadIDAtom = atom({
  key: "activeCommentThreadID",
  default: null,
});


# src/elements/CommentedText.js

import { activeCommentThreadIDAtom } from "../utils/CommentState";
import classNames from "classnames";
import { getSmallestCommentThreadAtTextNode } from "../utils/EditorCommentUtils";
import { useRecoilState } from "recoil";

export default perform CommentedText(props) {
 ....
const { commentThreads, textNode, ...otherProps } = props;
const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState(
    activeCommentThreadIDAtom
  );

  const onClick = () => {
    setActiveCommentThreadID(
      getSmallestCommentThreadAtTextNode(editor, textNode)
    );
  };

  return (
    <span
      {...otherProps}
      className={classNames({
        remark: true,
        // a unique background colour therapy if this textual content node's
        // remark threads do include the remark thread energetic on the
        // doc proper now.   
        "is-active": commentThreads.has(activeCommentThreadID),
      })}
      onClick={onClick}
    >
      {props.youngsters}
    &gl;/span>
  );
}

This element makes use of useRecoilState that permits a element to subscribe to and in addition have the ability to set the worth of Recoil atom. We’d like the subscriber to know if this textual content node is a part of the energetic remark thread so it could actually model itself in a different way. Try the screenshot under the place the remark thread within the center is energetic and we will see its vary clearly.

Example showing how text node(s) under selected comment thread jump out.
Textual content node(s) underneath chosen remark thread change in model and bounce out. (Large preview)

Now that we’ve all of the code in to make collection of remark threads work, let’s see it in motion. To check our traversal code effectively, we take a look at some easy instances of overlap and a few edge instances like:

  • Clicking on a commented textual content node at first/finish of the editor.
  • Clicking on a commented textual content node with remark threads spanning a number of paragraphs.
  • Clicking on a commented textual content node proper earlier than a picture node.
  • Clicking on a commented textual content node overlapping hyperlinks.
Deciding on shortest remark thread for various overlap combos.

As we now have a Recoil atom to trace the energetic remark thread ID, one tiny element to maintain is setting the newly created remark thread to be the energetic one when the person makes use of the toolbar button to insert a brand new remark thread. This allows us, within the subsequent part, to point out the remark thread popover instantly on insertion so the person can begin including feedback straight away.

# src/elements/Toolbar.js

import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState";
import { useSetRecoilState } from "recoil";

export default perform Toolbar({ choice, previousSelection }) {
  ...
  const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom);
 .....
  const onInsertComment = useCallback(() => {
    const newCommentThreadID = insertCommentThread(editor, addCommentThread);
    setActiveCommentThreadID(newCommentThreadID);
  }, [editor, addCommentThread, setActiveCommentThreadID]);

 return <div className="toolbar">
              ....
           </div>;
};

Observe: Using useSetRecoilState right here (a Recoil hook that exposes a setter for the atom however doesn’t subscribe the element to its worth) is what we want for the toolbar on this case.

On this part, we construct a Remark Popover that makes use of the idea of chosen/energetic remark thread and exhibits a popover that lets the person add feedback to that remark thread. Earlier than we construct it, let’s take a fast take a look at the way it capabilities.

Preview of the Remark Popover Function.

When attempting to render a Remark Popover near the remark thread that’s energetic, we run into among the issues that we did within the first article with a Hyperlink Editor Menu. At this level, it’s inspired to learn by the part within the first article that builds a Hyperlink Editor and the choice points we run into with that.

Let’s first work on rendering an empty popover element in the appropriate place primarily based on the what energetic remark thread is. The best way popover would work is:

  • Remark Thread Popover is rendered solely when there’s an energetic remark thread ID. To get that info, we take heed to the Recoil atom we created within the earlier part.
  • When it does render, we discover the textual content node on the editor’s choice and render the popover near it.
  • When the person clicks wherever exterior the popover, we set the energetic remark thread to be null thereby de-activating the remark thread and in addition making the popover disappear.
# src/elements/CommentThreadPopover.js

import NodePopover from "./NodePopover";
import { getFirstTextNodeAtSelection } from "../utils/EditorUtils";
import { useEditor } from "slate-react";
import { useSetRecoilState} from "recoil";

import {activeCommentThreadIDAtom} from "../utils/CommentState";

export default perform CommentThreadPopover({ editorOffsets, choice, threadID }) {
  const editor = useEditor();
  const textNode = getFirstTextNodeAtSelection(editor, choice);
  const setActiveCommentThreadID = useSetRecoilState(
    activeCommentThreadIDAtom
  );

  const onClickOutside = useCallback(
    () => {},
    []
  );

  return (
    <NodePopover
      editorOffsets={editorOffsets}
      isBodyFullWidth={true}
      node={textNode}
      className={"comment-thread-popover"}
      onClickOutside={onClickOutside}
    >
      {`Remark Thread Popover for threadID:${threadID}`}
    </NodePopover>
  );
}

Couple of issues that ought to be referred to as out for this implementation of the popover element:

  • It takes the editorOffsets and the choice from the Editor element the place it might be rendered. editorOffsets are the bounds of the Editor element so we may compute the place of the popover and choice may very well be present or earlier choice in case the person used a toolbar button inflicting choice to turn into null. The part on the Hyperlink Editor from the primary article linked above goes by these intimately.
  • For the reason that LinkEditor from the primary article and the CommentThreadPopover right here, each render a popover round a textual content node, we’ve moved that frequent logic right into a element NodePopover that handles rendering of the element aligned to the textual content node in query. Its implementation particulars are what LinkEditor element had within the first article.
  • NodePopover takes a onClickOutside methodology as a prop that is named if the person clicks someplace exterior the popover. We implement this by attaching mousedown occasion listener to the doc — as defined intimately in this Smashing article on this concept.
  • getFirstTextNodeAtSelection will get the primary textual content node contained in the person’s choice which we use to render the popover in opposition to. The implementation of this perform makes use of Slate’s helpers to seek out the textual content node.
# src/utils/EditorUtils.js

export perform getFirstTextNodeAtSelection(editor, choice) {
  const selectionForNode = choice ?? editor.choice;

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

  const textNodeEntry = Editor.nodes(editor, {
    at: selectionForNode,
    mode: "lowest",
    match: Textual content.isText,
  }).subsequent().worth;

  return textNodeEntry != null ? textNodeEntry[0] : null;
}

Let’s implement the onClickOutside callback that ought to clear the energetic remark thread. Nevertheless, we’ve to account for the situation when the remark thread popover is open and a sure thread is energetic and the person occurs to click on on one other remark thread. In that case, we don’t need the onClickOutside to reset the energetic remark thread for the reason that click on occasion on the opposite CommentedText element ought to set the opposite remark thread to turn into energetic. We don’t wish to intrude with that within the popover.

The best way we do that’s that’s we discover the Slate Node closest to the DOM node the place the clicking occasion occurred. If that Slate node is a textual content node and has feedback on it, we skip resetting the energetic remark thread Recoil atom. Let’s implement it!

# src/elements/CommentThreadPopover.js

const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom);

const onClickOutside = useCallback(
    (occasion) => {
      const slateDOMNode = occasion.goal.hasAttribute("data-slate-node")
        ? occasion.goal
        : occasion.goal.closest('[data-slate-node]');

      // The clicking occasion was someplace exterior the Slate hierarchy.
      if (slateDOMNode == null) {
        setActiveCommentThreadID(null);
        return;
      }

      const slateNode = ReactEditor.toSlateNode(editor, slateDOMNode);

      // Click on is on one other commented textual content node => do nothing.
      if (
        Textual content.isText(slateNode) &&
        getCommentThreadsOnTextNode(slateNode).dimension > 0
      ) {
        return;
      }

      setActiveCommentThreadID(null);
    },
    [editor, setActiveCommentThreadID]
  );

Slate has a helper methodology toSlateNode that returns the Slate node that maps to a DOM node or its closest ancestor if itself isn’t a Slate Node. The present implementation of this helper throws an error if it could actually’t discover a Slate node as an alternative of returning null. We deal with that above by checking the null case ourselves which is a really seemingly situation if the person clicks someplace exterior the editor the place Slate nodes don’t exist.

We will now replace the Editor element to take heed to the activeCommentThreadIDAtom and render the popover solely when a remark thread is energetic.

# src/elements/Editor.js

import { useRecoilValue } from "recoil";
import { activeCommentThreadIDAtom } from "../utils/CommentState";

export default perform Editor({ doc, onChange }): JSX.Ingredient {

  const activeCommentThreadID = useRecoilValue(activeCommentThreadIDAtom);
  // This hook is described intimately within the first article
  const [previousSelection, selection, setSelection] = useSelection(editor);

  return (
    <>
               ...
              <div className="editor" ref={editorRef}>
                 ...
                {activeCommentThreadID != null ? (
                  <CommentThreadPopover
                    editorOffsets={editorOffsets}
                    choice={choice ?? previousSelection}
                    threadID={activeCommentThreadID}
                  />
                ) : null}
             </div>
               ...
    </>
  );
}

Let’s confirm that the popover hundreds on the proper place for the appropriate remark thread and does clear the energetic remark thread after we click on exterior.

Remark Thread Popover appropriately hundreds for the chosen remark thread.

We now transfer on to enabling customers so as to add feedback to a remark thread and seeing all of the feedback of that thread within the popover. We’re going to use the Recoil atom household — commentThreadsState we created earlier within the article for this.

The feedback in a remark thread are saved on the feedback array. To allow including a brand new remark, we render a Type enter that permits the person to enter a brand new remark. Whereas the person is typing out the remark, we keep that in an area state variable — commentText. On the clicking of the button, we append the remark textual content as the brand new remark to the feedback array.

# src/elements/CommentThreadPopover.js

import { commentThreadsState } from "../utils/CommentState";
import { useRecoilState } from "recoil";

import Button from "react-bootstrap/Button";
import Type from "react-bootstrap/Type";

export default perform CommentThreadPopover({
  editorOffsets,
  choice,
  threadID,
}) {

  const [threadData, setCommentThreadData] = useRecoilState(
    commentThreadsState(threadID)
  );

  const [commentText, setCommentText] = useState("");

  const onClick = useCallback(() => {
    setCommentThreadData((threadData) => ({
      ...threadData,
      feedback: [
        ...threadData.comments,
        // append comment to the comments on the thread.
        { text: commentText, author: "Jane Doe", creationTime: new Date() },
      ],
    }));
    // clear the enter
    setCommentText("");
  }, [commentText, setCommentThreadData]);

  const onCommentTextChange = useCallback(
    (occasion) => setCommentText(occasion.goal.worth),
    [setCommentText]
  );

  return (
    <NodePopover
      ...
    >
      <div className={"comment-input-wrapper"}>
        <Type.Management
          bsPrefix={"comment-input form-control"}
          placeholder={"Sort a remark"}
          kind="textual content"
          worth={commentText}
          onChange={onCommentTextChange}
        />
        <Button
          dimension="sm"
          variant="main"
          disabled={commentText.size === 0}
          onClick={onClick}
        >
          Remark
        </Button>
      </div>
    </NodePopover>
  );
}

Observe: Though we render an enter for the person to kind in remark, we don’t essentially let it take focus when the popover mounts. This can be a Consumer Expertise choice that would range from one editor to a different. Some editors don’t let customers edit the textual content whereas the remark thread popover is open. In our case, we would like to have the ability to let the person edit the commented textual content once they click on on it.

Value calling out how we entry the particular remark thread’s information from the Recoil atom household — by calling out the atom as — commentThreadsState(threadID). This provides us the worth of the atom and a setter to replace simply that atom within the household. If the feedback are being lazy loaded from the server, Recoil additionally gives a useRecoilStateLoadable hook that returns a Loadable object which tells us in regards to the loading state of the atom’s information. Whether it is nonetheless loading, we will select to point out a loading state within the popover.

Now, we entry the threadData and render the record of feedback. Every remark is rendered by the CommentRow element.

# src/elements/CommentThreadPopover.js

return (
    <NodePopover
      ...
    >
      <div className={"comment-list"}>
        {threadData.feedback.map((remark, index) => (
          <CommentRow key={`comment_${index}`} remark={remark} />
        ))}
      </div>
      ...
    </NodePopover>
);

Under is the implementation of CommentRow that renders the remark textual content and different metadata like creator title and creation time. We use the date-fns module to point out a formatted creation time.

# src/elements/CommentRow.js

import { format } from "date-fns";

export default perform CommentRow({
  remark: { creator, textual content, creationTime },
}) {
  return (
    <div className={"comment-row"}>
      <div className="comment-author-photo">
        <i className="bi bi-person-circle comment-author-photo"></i>
      </div>
      <div>
        <span className="comment-author-name">{creator}</span>
        <span className="comment-creation-time">
          {format(creationTime, "eee MM/dd H:mm")}
        </span>
        <div className="comment-text">{textual content}</div>
      </div>
    </div>
  );
}

We’ve extracted this to be its personal element as we re-use it later after we implement the Remark Sidebar.

At this level, our Remark Popover has all of the code it wants to permit inserting new feedback and updating the Recoil state for a similar. Let’s confirm that. On the browser console, utilizing the Recoil Debug Observer we added earlier, we’re in a position to confirm that the Recoil atom for the remark thread is getting up to date appropriately as we add new feedback to the thread.

Remark Thread Popover hundreds on choosing a remark thread.

Earlier within the article, we’ve referred to as out why often, it could so occur that the foundations we carried out stop a sure remark thread to not be accessible by clicking on its textual content node(s) alone — relying upon the mix of overlap. For such instances, we want a Feedback Sidebar that lets the person get to any and all remark threads within the doc.

A Feedback Sidebar can also be an excellent addition that weaves right into a Suggestion & Overview workflow the place a reviewer can navigate by all of the remark threads one after the opposite in a sweep and have the ability to depart feedback/replies wherever they really feel the necessity to. Earlier than we begin implementing the sidebar, there’s one unfinished activity we maintain under.

When the doc is loaded within the editor, we have to scan the doc to seek out all of the remark threads and add them to the Recoil atoms we created above as a part of the initialization course of. Let’s write a utility perform in EditorCommentUtils that scans the textual content nodes, finds all of the remark threads and provides them to the Recoil atom.

# src/utils/EditorCommentUtils.js

export async perform initializeStateWithAllCommentThreads(
  editor,
  addCommentThread
) {
  const textNodesWithComments = Editor.nodes(editor, {
    at: [],
    mode: "lowest",
    match: (n) => Textual content.isText(n) && getCommentThreadsOnTextNode(n).dimension > 0,
  });

  const commentThreads = new Set();

  let textNodeEntry = textNodesWithComments.subsequent().worth;
  whereas (textNodeEntry != null) {
    [...getCommentThreadsOnTextNode(textNodeEntry[0])].forEach((threadID) => {
      commentThreads.add(threadID);
    });
    textNodeEntry = textNodesWithComments.subsequent().worth;
  }

  Array.from(commentThreads).forEach((id) =>
    addCommentThread(id, {
      feedback: [
        {
          author: "Jane Doe",
          text: "Comment Thread Loaded from Server",
          creationTime: new Date(),
        },
      ],
      standing: "open",
    })
  );
}
Syncing with Backend Storage and Efficiency Consideration

For the context of the article, as we’re purely targeted on the UI implementation, we simply initialize them with some information that lets us verify the initialization code is working.

Within the real-world utilization of the Commenting System, remark threads are more likely to be saved individually from the doc contents themselves. In such a case, the above code would must be up to date to make an API name that fetches all of the metadata and feedback on all of the remark thread IDs in commentThreads. As soon as the remark threads are loaded, they’re more likely to be up to date as a number of customers add extra feedback to them in actual time, change their standing and so forth. The manufacturing model of the Commenting System would wish to construction the Recoil storage in a approach that we will preserve syncing it with the server. Should you select to make use of Recoil for state administration, there are some examples on the Atom Results API (experimental as of writing this text) that do one thing comparable.

If a doc is actually lengthy and has loads of customers collaborating on it on loads of remark threads, we would must optimize the initialization code to solely load remark threads for the primary few pages of the doc. Alternatively, we might select to solely load the lightweight metadata of all of the remark threads as an alternative of your complete record of feedback which is probably going the heavier a part of the payload.

Now, let’s transfer on to calling this perform when the Editor element mounts with the doc so the Recoil state is appropriately initialized.

# src/elements/Editor.js

import { initializeStateWithAllCommentThreads } from "../utils/EditorCommentUtils";
import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState";
 
export default perform Editor({ doc, onChange }): JSX.Ingredient {
   ...
  const addCommentThread = useAddCommentThreadToState();

  useEffect(() => {
    initializeStateWithAllCommentThreads(editor, addCommentThread);
  }, [editor, addCommentThread]);

  return (
     <>
       ...
     </>
  );
}

We use the identical customized hook — useAddCommentThreadToState that we used with the Toolbar Remark Button implementation so as to add new remark threads. Since we’ve the popover working, we will click on on one in every of pre-existing remark threads within the doc and confirm that it exhibits the info we used to initialize the thread above.

Clicking on a pre-existing comment thread loads the popover with their comments correctly.
Clicking on a pre-existing remark thread hundreds the popover with their feedback appropriately. (Large preview)

Now that our state is appropriately initialized, we will begin implementing the sidebar. All our remark threads within the UI are saved within the Recoil atom household — commentThreadsState. As highlighted earlier, the best way we iterate by all of the gadgets in a Recoil atom household is by monitoring the atom keys/ids in one other atom. We’ve been doing that with commentThreadIDsState. Let’s add the CommentSidebar element that iterates by the set of ids on this atom and renders a CommentThread element for every.

# src/elements/CommentsSidebar.js

import "./CommentSidebar.css";

import {commentThreadIDsState,} from "../utils/CommentState";
import { useRecoilValue } from "recoil";

export default perform CommentsSidebar(params) {
  const allCommentThreadIDs = useRecoilValue(commentThreadIDsState);

  return (
    <Card className={"comments-sidebar"}>
      <Card.Header>Feedback</Card.Header>
      <Card.Physique>
        {Array.from(allCommentThreadIDs).map((id) => (
          <Row key={id}>
            <Col>
              <CommentThread id={id} />
            </Col>
          </Row>
        ))}
      </Card.Physique>
    </Card>
  );
}

Now, we implement the CommentThread element that listens to the Recoil atom within the household akin to the remark thread it’s rendering. This manner, because the person provides extra feedback on the thread within the editor or adjustments some other metadata, we will replace the sidebar to mirror that.

Because the sidebar may develop to be actually large for a doc with loads of feedback, we cover all feedback however the first one after we render the sidebar. The person can use the ‘Present/Disguise Replies’ button to point out/cover your complete thread of feedback.

# src/elements/CommentSidebar.js

perform CommentThread({ id }) {
  const { feedback } = useRecoilValue(commentThreadsState(id));

  const [shouldShowReplies, setShouldShowReplies] = useState(false);
  const onBtnClick = useCallback(() => {
    setShouldShowReplies(!shouldShowReplies);
  }, [shouldShowReplies, setShouldShowReplies]);

  if (feedback.size === 0) {
    return null;
  }

  const [firstComment, ...otherComments] = feedback;
  return (
    <Card
      physique={true}
      className={classNames({
        "comment-thread-container": true,
      })}
    >
      <CommentRow remark={firstComment} showConnector={false} />
      {shouldShowReplies
        ? otherComments.map((remark, index) => (
            <CommentRow key={`comment-${index}`} remark={remark} showConnector={true} />
          ))
        : null}
      {feedback.size > 1 ? (
        <Button
          className={"show-replies-btn"}
          dimension="sm"
          variant="outline-primary"
          onClick={onBtnClick}
        >
          {shouldShowReplies ? "Disguise Replies" : "Present Replies"}
        </Button>
      ) : null}
    </Card>
  );
}

We’ve reused the CommentRow element from the popover though we added a design therapy utilizing showConnector prop that principally makes all of the feedback look related with a thread within the sidebar.

Now, we render the CommentSidebar within the Editor and confirm that it exhibits all of the threads we’ve within the doc and appropriately updates as we add new threads or new feedback to current threads.

# src/elements/Editor.js

return (
    <>
      <Slate ... >
       .....
        <div className={"sidebar-wrapper"}>
          <CommentsSidebar />
            </div>
      </Slate>
    </>
);
Feedback Sidebar with all of the remark threads within the doc.

We now transfer on to implementing a well-liked Feedback Sidebar interplay present in editors:

Clicking on a remark thread within the sidebar ought to choose/activate that remark thread. We additionally add a differential design therapy to focus on a remark thread within the sidebar if it’s energetic within the editor. To have the ability to achieve this, we use the Recoil atom — activeCommentThreadIDAtom. Let’s replace the CommentThread element to help this.

# src/elements/CommentsSidebar.js

perform CommentThread({ id }) {
 
const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState(
    activeCommentThreadIDAtom
  );

const onClick = useCallback(() => {   
    setActiveCommentThreadID(id);
  }, [id, setActiveCommentThreadID]);

  ...

  return (
    <Card
      physique={true}
      className={classNames({
        "comment-thread-container": true,
        "is-active": activeCommentThreadID === id,      
      })}
      onClick={onClick}
    >
    ....
   </Card>
);
Clicking on a remark thread in Feedback Sidebar selects it within the editor and highlights its vary.

If we glance intently, we’ve a bug in our implementation of sync-ing the energetic remark thread with the sidebar. As we click on on totally different remark threads within the sidebar, the right remark thread is certainly highlighted within the editor. Nevertheless, the Remark Popover doesn’t really transfer to the modified energetic remark thread. It stays the place it was first rendered. If we take a look at the implementation of the Remark Popover, it renders itself in opposition to the primary textual content node within the editor’s choice. At that time within the implementation, the one method to choose a remark thread was to click on on a textual content node so we may conveniently depend on the editor’s choice because it was up to date by Slate on account of the clicking occasion. Within the above onClick occasion, we don’t replace the choice however merely replace the Recoil atom worth inflicting Slate’s choice to stay unchanged and therefore the Remark Popover doesn’t transfer.

An answer to this drawback is to replace the editor’s choice together with updating the Recoil atom when the person clicks on the remark thread within the sidebar. The steps do that are:

  1. Discover all textual content nodes which have this remark thread on them that we’re going to set as the brand new energetic thread.
  2. Kind these textual content nodes within the order wherein they seem within the doc (We use Slate’s Path.compare API for this).
  3. Compute a variety vary that spans from the beginning of the primary textual content node to the top of the final textual content node.
  4. Set the choice vary to be the editor’s new choice (utilizing Slate’s Transforms.select API).

If we simply wished to repair the bug, we may simply discover the primary textual content node in Step #1 that has the remark thread and set that to be the editor’s choice. Nevertheless, it looks like a cleaner strategy to pick out your complete remark vary as we actually are choosing the remark thread.

Let’s replace the onClick callback implementation to incorporate the steps above.

const onClick = useCallback(() => {

    const textNodesWithThread = Editor.nodes(editor, {
      at: [],
      mode: "lowest",
      match: (n) => Textual content.isText(n) && getCommentThreadsOnTextNode(n).has(id),
    });

    let textNodeEntry = textNodesWithThread.subsequent().worth;
    const allTextNodePaths = [];

    whereas (textNodeEntry != null) {
      allTextNodePaths.push(textNodeEntry[1]);
      textNodeEntry = textNodesWithThread.subsequent().worth;
    }

    // kind the textual content nodes
    allTextNodePaths.kind((p1, p2) => Path.examine(p1, p2));

    // set the choice on the editor
    Transforms.choose(editor, {
      anchor: Editor.level(editor, allTextNodePaths[0], { edge: "begin" }),
      focus: Editor.level(
        editor,
        allTextNodePaths[allTextNodePaths.length - 1],
        { edge: "finish" }
      ),
    });

   // Replace the Recoil atom worth.
    setActiveCommentThreadID(id);
  }, [editor, id, setActiveCommentThreadID]);

Observe: allTextNodePaths comprises the trail to all of the textual content nodes. We use the Editor.point API to get the beginning and finish factors at that path. The first article goes by Slate’s Location ideas. They’re additionally well-documented on Slate’s documentation.

Let’s confirm that this implementation does repair the bug and the Remark Popover strikes to the energetic remark thread appropriately. This time, we additionally take a look at with a case of overlapping threads to ensure it doesn’t break there.

Clicking on a remark thread in Feedback Sidebar selects it and hundreds Remark Thread Popover.

With the bug repair, we’ve enabled one other sidebar interplay that we haven’t mentioned but. If we’ve a extremely lengthy doc and the person clicks on a remark thread within the sidebar that’s exterior the viewport, we’d wish to scroll to that a part of the doc so the person can concentrate on the remark thread within the editor. By setting the choice above utilizing Slate’s API, we get that at no cost. Let’s see it in motion under.

Doc scrolls to the remark thread appropriately when clicked on within the Feedback Sidebar.

With that, we wrap our implementation of the sidebar. In the direction of the top of the article, we record out some good function additions and enhancements we will do to the Feedback Sidebar that assist elevate the Commenting and Overview expertise on the editor.

Resolving And Re-Opening Feedback

On this part, we concentrate on enabling customers to mark remark threads as ‘Resolved’ or have the ability to re-open them for dialogue if wanted. From an implementation element perspective, that is the standing metadata on a remark thread that we modify because the person performs this motion. From a person’s perspective, this can be a very helpful function because it offers them a method to affirm that the dialogue about one thing on the doc has concluded or must be re-opened as a result of there are some updates/new views, and so forth.

To allow toggling the standing, we add a button to the CommentPopover that permits the person to toggle between the 2 statuses: open and resolved.

# src/elements/CommentThreadPopover.js

export default perform CommentThreadPopover({
  editorOffsets,
  choice,
  threadID,
}) {
  …
  const [threadData, setCommentThreadData] = useRecoilState(
    commentThreadsState(threadID)
  );

  ...

  const onToggleStatus = useCallback(() => {
    const currentStatus = threadData.standing;
    setCommentThreadData((threadData) => ({
      ...threadData,
      standing: currentStatus === "open" ? "resolved" : "open",
    }));
  }, [setCommentThreadData, threadData.status]);

  return (
    <NodePopover
      ...
      header={
        <Header
          standing={threadData.standing}
          shouldAllowStatusChange={threadData.feedback.size > 0}
          onToggleStatus={onToggleStatus}
        />
      }
    >
      <div className={"comment-list"}>
          ...
      </div>
    </NodePopover>
  );
}

perform Header({ onToggleStatus, shouldAllowStatusChange, standing }) {
  return (
    <div className={"comment-thread-popover-header"}>
      {shouldAllowStatusChange && standing != null ? (
        <Button dimension="sm" variant="main" onClick={onToggleStatus}>
          {standing === "open" ? "Resolve" : "Re-Open"}
        </Button>
      ) : null}
    </div>
  );
}

Earlier than we take a look at this, let’s additionally give the Feedback Sidebar a differential design therapy for resolved feedback in order that the person can simply detect which remark threads are un-resolved or open and concentrate on these in the event that they wish to.

# src/elements/CommentsSidebar.js

perform CommentThread({ id }) {
  ...
  const { feedback, standing } = useRecoilValue(commentThreadsState(id));
 
 ...
  return (
    <Card
      physique={true}
      className={classNames({
        "comment-thread-container": true,
        "is-resolved": standing === "resolved",
        "is-active": activeCommentThreadID === id,
      })}
      onClick={onClick}
    >
       ...  
   </Card>
  );
}
Remark Thread Standing being toggled from the popover and mirrored within the sidebar.

Conclusion

On this article, we constructed the core UI infrastructure for a Commenting System on a Wealthy Textual content Editor. The set of functionalities we add right here act as a basis to construct a richer Collaboration Expertise on an editor the place collaborators may annotate components of the doc and have conversations about them. Including a Feedback Sidebar offers us an area to have extra conversational or review-based functionalities to be enabled on the product.

Alongside these strains, listed here are a few of options {that a} Wealthy Textual content Editor may think about including on high of what we constructed on this article:

  • Help for @ mentions so collaborators may tag each other in feedback;
  • Help for media varieties like pictures and movies to be added to remark threads;
  • Suggestion Mode on the doc stage that permits reviewers to make edits to the doc that seem as recommendations for adjustments. One may consult with this function in Google Docs or Change Tracking in Microsoft Phrase as examples;
  • Enhancements to the sidebar to go looking conversations by key phrase, filter threads by standing or remark creator(s), and so forth.
Smashing Editorial
(vf, yk, il)



Source link