Web-Design
Friday May 28, 2021 By David Quintanilla
Adding A Commenting System To A WYSIWYG Editor — Smashing Magazine


About The Creator

Shalabh Vyas is a Entrance-Finish Engineer with the expertise of working via all the product-development lifecycle launching wealthy web-based purposes. …
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 allows customers to pick textual content inside a doc and share their feedback on it. We’ll even be bringing in RecoilJS for state administration within the UI utility. (The code for the system we construct right here is obtainable 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 continually 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 attainable.

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 allows collaborators to annotate elements 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 interact the readers into how we attempt to weigh the professionals and cons and try to discover a steadiness between utility complexity and person expertise with regards to constructing options for WYSIWYG Editors or Phrase Processors typically.

With a purpose to discover a technique to symbolize feedback in a wealthy textual content doc’s knowledge construction, let’s take a look at a couple of eventualities underneath which feedback might be created inside an editor.

  • Feedback created over textual content that has no types on it (fundamental 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 way in which they’ll come up in a wealthy textual content doc are similar to character types (daring, italics and so on). They’ll overlap with one another, go over textual content in different forms of nodes like hyperlinks and even span a number of dad or mum nodes like paragraphs.

Because of this, we use the identical methodology to symbolize feedback as we do for character types, 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.

Every 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’d permit 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 symbolize remark threads as marks is that every remark thread is represented by a mark named as commentThread_threadID the place threadID is a singular 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 types 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 truly setting this construction up, it’s price taking a look at 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 symbolize feedback within the doc construction, let’s go forward and add a couple of to the instance doc from the first article and configure the editor to truly present them as highlighted. Since we could have loads of utility features to take care of feedback on this article, we create a EditorCommentUtils module that may home all these utils. To begin with, we create a operate that creates a mark for a given remark thread ID. We then use that to insert a couple of remark threads in our ExampleDocument.

# src/utils/EditorCommentUtils.js

const COMMENT_THREAD_PREFIX = "commentThread_";

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

Under picture underlines in crimson the ranges of textual content that we’ve got as instance remark threads added within the subsequent code snippet. Word that the textual content ‘Richard McClintock’ has two remark threads that overlap one another. Particularly, it is 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 crimson. (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",
        // be aware the 2 remark threads right here.
        [getMarkForCommentThreadID(uuid())]: true,
        [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true,
   },
   {
        textual content: ", a Latin scholar",
        [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true,
   },
   ...
];

We give attention to the UI facet of issues of a Commenting System on this article so we assign them IDs within the instance doc straight 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 give attention to tweaking the editor to point out these textual content nodes as highlighted. With a purpose to do this, when rendering textual content nodes, we want a technique to inform if it has remark threads on it. We add a util getCommentThreadsOnTextNode for that. We construct on the StyledText part that we created within the first article to deal with the case the place it could be making an attempt to render a textual content node with feedback on. Since we’ve got some extra performance coming that might be added to commented textual content nodes later, we create a part CommentedText that renders the commented textual content. StyledText will examine if the textual content node it’s making an attempt 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 operate getCommentThreadsOnTextNode(textNode) {
  return new Set(
     // As a result of marks are simply properties on nodes,
    // we are able to merely use Object.keys() right here.
    Object.keys(textNode)
      .filter(isCommentThreadIDMark)
      .map(getCommentThreadIDFromMark)
  );
}

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

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

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

# src/parts/StyledText.js

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

export default operate 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/parts/CommentedText.js

import "./CommentedText.css";

import classNames from "classnames";

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

# src/parts/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)

Word: The customers at present can’t inform if sure textual content has overlapping feedback on it. All the highlighted textual content vary seems to be like a single remark thread. We deal with that later within the article the place we introduce the idea of lively remark thread which lets customers choose a selected remark thread and be capable to see its vary within the editor.

Earlier than we add the performance that allows 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 on. Let’s add Recoil to our utility:

> yarn add recoil

We use Recoil atoms to retailer these two knowledge constructions. Should you’re not aware of Recoil, atoms are what maintain the applying state. For various items of utility state, you’d normally wish to arrange totally different atoms. Atom Family is a group of atoms — it may be regarded as a Map from a singular key figuring out the atom to the atoms themselves. It’s price going via 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 utility in a RecoilRoot part. RecoilRoot is utilized to supply the context by which 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 could be arrange with a default worth.
  • As we construct additional on this article, we’re going to want a technique to iterate over all of the remark threads which might principally imply needing a technique to iterate over commentThreadsState atom household. On the time of writing this text, the way in which to do 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 stored in sync every time we add/delete remark threads.

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

# src/parts/App.js

import { RecoilRoot } from "recoil";

export default operate App() {
  ...

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

export default operate Editor({ ... }): JSX.Component {
  .....

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

operate 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 utility, be certain that there are not 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 beneath:

  1. Assign a singular 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 operate to EditorCommentUtils that does #1 and #2.

# src/utils/EditorCommentUtils.js

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

export operate insertCommentThread(editor, addCommentThreadToState) {
    const threadID = uuidv4();
    const newCommentThread = {
        // feedback as added could 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 capable of 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 operate 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 a couple of issues that can be utilized to learn/set atom knowledge. The one we’re concerned with proper now could be the set operate 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 operate 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, let’s imagine that if commentThreadsState was a javascript Map, this name is principally — commentThreadsState.set(id, threadData).

Now that we’ve got 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 features.

# src/parts/Toolbar.js

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

export default operate 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>
  );
}

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

Within the beneath 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 accurately. 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.

As you may inform from the above video, the phrase ‘designers’ is now a part of three remark threads. So we’ve got 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 a couple of 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 means within the case of overlapping feedback, an important 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 in all probability don’t wish to permit 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 concerning 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 with regards to overlapping feedback. To maintain issues easy, some editors don’t permit overlapping feedback in any respect. 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 Assessment expertise.

This rule helps us reply the query #1 from above as to which remark thread to pick 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 at all times has a technique to get to the innermost remark thread that’s totally contained inside one other remark thread. For different circumstances (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 with the intention 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 reasonably complicated case of overlap that enables us to make use of this rule and ‘do the fitting factor’ when deciding on 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 way in which Slate splits the textual content nodes with marks, we could 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 technique 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 out to be 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 got 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 may see within the above instance, if we observe the rule we simply described right here, clicking on any character between #20 and #80, would at all times 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 go away us undecided as to which remark thread to pick is when there are a couple of 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 apart 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 an inventory of feedback within the sidebar.

Insertion Rule

The rule is:

“If the textual content person has chosen and is making an attempt to touch upon is already totally lined by remark thread(s), don’t permit that insertion.”

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

this rule, one would possibly surprise why we want it within the first place if we have already got the Shortest Remark Vary Rule that enables us to pick the smallest textual content vary. Why not permit all combos of overlaps if we are able to use the primary rule to infer the fitting remark thread to point out? As a few of 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 reduce the variety of eventualities the place the primary rule can’t assist us and we’ve got 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 well-liked editors.

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

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

Word: Having this rule doesn’t imply we’d by no means have totally contained overlapping feedback. The difficult factor about overlapping feedback is that regardless of the foundations, the order by which feedback are inserted can nonetheless go away 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 permit it and we find yourself with a completely contained scenario — #1 and #2 contained inside #3. That’s nice 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 deciding on of remark threads. Since we now have a toolbar button to insert feedback, we are able to implement the Insertion Rule instantly by checking the rule when the person has some textual content chosen. If the rule is just not glad, we’d disable the Remark button so customers can’t insert a brand new remark thread on the chosen textual content. Let’s get began!

# src/utils/EditorCommentUtils.js

export operate 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 operate is comparatively simple.

  • If the person’s choice is a blinking caret, we don’t permit inserting a remark there as no textual content has been chosen.
  • If the person’s choice is just not a collapsed one, we discover all of the textual content nodes within the choice. Word the usage of the mode: lowest within the name to Editor.nodes (a helper operate 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 may be no less than one textual content node that has no remark threads on it, we could permit the insertion. We use the util getCommentThreadsOnTextNode we wrote earlier right here.

We now use this util operate contained in the toolbar to manage the disabled state of the button.

# src/parts/Toolbar.js

export default operate 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 lined by different feedback.

A nice person expertise element to name out right here is that whereas we disable the toolbar button if the person has chosen all the line of textual content right here, it doesn’t full the expertise for the person. The person could not totally perceive why the button is disabled and is prone to get confused that we’re not responding to their intent to insert a remark thread there. We deal with 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 be capable to go away feedback.

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

Insertion Rule permitting insertion of remark thread when there may be 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 lively 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 hearken to the Recoil state and if they’re a part of the lively remark thread, they’d spotlight themselves in another way. That approach, when the person clicks on the remark thread, all the 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 aim 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 path from that textual content node and maintain updating the thread lengths being tracked.
  3. Cease the traversal in a path after we’ve reached one of many beneath 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 are not any extra textual content nodes to traverse in that path (implying we’ve both reached the beginning or the tip of the doc or a non-text node).

For the reason that traversals in ahead and reverse path are functionally the identical, we’re going to jot down a helper operate updateCommentThreadLengthMap that principally takes a textual content node iterator. It would maintain calling the iterator and maintain updating the monitoring thread lengths. We’ll name this operate twice — as soon as for ahead and as soon as for backward path. Let’s write our primary utility operate that may use this helper operate.

# src/utils/EditorCommentUtils.js

export operate 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
    // at least.
    const commentThreadsLengthByID = new Map(
      commentThreadsAsArray.map((id) => [id, textNode.text.length])
    );


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

    // traverse within the ahead path 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 lined within the above code. The feedback ought to assist observe how the logic flows there.

One factor price calling out is how we created the traversal features. We wish to give a traversal operate to updateCommentThreadLengthMap such that it may possibly 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 operate Textual content.isText so we all know we’re getting a textual content node from the traversal, if there may be one.

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

# src/utils/EditorCommentUtils.js

operate 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 on the lookout for 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 would possibly surprise why will we wait till the intersection turns into 0 to cease iterating in a sure path. Why can’t we simply cease if we’re reached the sting of no less than one remark thread — that might suggest we’ve reached the shortest size in that path, proper? The rationale we are able to’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.

Take a look at the beneath instance the place we’ve got 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 watch for intersection to turn out to be 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 path, we’d cease at first of textual content node #2 itself since that’s the beginning of the remark thread A. Because of this, we would not compute the remark thread lengths accurately for A & B. With the implementation above traversing the farthest edges (textual content nodes 1,2, and three), we should always get B because the shortest remark thread as anticipated.

To see the implementation visually, beneath is a walkthrough with a slideshow of the iterations. We now have 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 displaying 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 got 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 lively remark thread ID for us. We then replace the CommentedText part 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/parts/CommentedText.js

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

export default operate 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 distinct background colour therapy if this textual content node's
        // remark threads do comprise the remark thread lively on the
        // doc proper now.   
        "is-active": commentThreads.has(activeCommentThreadID),
      })}
      onClick={onClick}
    >
      {props.youngsters}
    &gl;/span>
  );
}

This part makes use of useRecoilState that enables a part to subscribe to and in addition be capable to set the worth of Recoil atom. We’d like the subscriber to know if this textual content node is a part of the lively remark thread so it may possibly model itself in another way. Take a look at the screenshot beneath the place the remark thread within the center is lively and we are able to 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 got all of the code in to make choice of remark threads work, let’s see it in motion. To check our traversal code properly, we take a look at some simple 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 lively remark thread ID, one tiny element to maintain is setting the newly created remark thread to be the lively 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 instantly.

# src/parts/Toolbar.js

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

export default operate 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>;
};

Word: Using useSetRecoilState right here (a Recoil hook that exposes a setter for the atom however doesn’t subscribe the part 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/lively 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 features.

Preview of the Remark Popover Characteristic.

When making an attempt to render a Remark Popover near the remark thread that’s lively, we run into a few of the issues that we did within the first article with a Hyperlink Editor Menu. At this level, it’s inspired to learn via 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 part in the fitting place primarily based on the what lively remark thread is. The best way popover would work is:

  • Remark Thread Popover is rendered solely when there may be an lively remark thread ID. To get that info, we hearken 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 lively remark thread to be null thereby de-activating the remark thread and in addition making the popover disappear.
# src/parts/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 operate 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 part:

  • It takes the editorOffsets and the choice from the Editor part the place it might be rendered. editorOffsets are the bounds of the Editor part so we may compute the place of the popover and choice might be present or earlier choice in case the person used a toolbar button inflicting choice to turn out to be null. The part on the Hyperlink Editor from the primary article linked above goes via 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 widespread logic right into a part NodePopover that handles rendering of the part aligned to the textual content node in query. Its implementation particulars are what LinkEditor part had within the first article.
  • NodePopover takes a onClickOutside methodology as a prop that is known as 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 operate makes use of Slate’s helpers to seek out the textual content node.
# src/utils/EditorUtils.js

export operate 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 lively remark thread. Nevertheless, we’ve got to account for the situation when the remark thread popover is open and a sure thread is lively and the person occurs to click on on one other remark thread. In that case, we don’t need the onClickOutside to reset the lively remark thread because the click on occasion on the opposite CommentedText part ought to set the opposite remark thread to turn out to be lively. We don’t wish to intervene 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 lively remark thread Recoil atom. Let’s implement it!

# src/parts/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 may possibly’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 part to hearken to the activeCommentThreadIDAtom and render the popover solely when a remark thread is lively.

# src/parts/Editor.js

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

export default operate Editor({ doc, onChange }): JSX.Component {

  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 fitting remark thread and does clear the lively remark thread after we click on exterior.

Remark Thread Popover accurately 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 Kind enter that enables the person to enter a brand new remark. Whereas the person is typing out the remark, we preserve 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/parts/CommentThreadPopover.js

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

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

export default operate 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"}>
        <Kind.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>
  );
}

Word: 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 Person Expertise choice that would differ 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 knowledge from the Recoil atom household — by calling out the atom as — commentThreadsState(threadID). This offers 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 knowledge. Whether it is nonetheless loading, we are able to select to point out a loading state within the popover.

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

# src/parts/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 identify and creation time. We use the date-fns module to point out a formatted creation time.

# src/parts/CommentRow.js

import { format } from "date-fns";

export default operate 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 part 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 capable of confirm that the Recoil atom for the remark thread is getting up to date accurately as we add new feedback to the thread.

Remark Thread Popover hundreds on deciding on 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 forestall 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 addition that weaves right into a Suggestion & Assessment workflow the place a reviewer can navigate via all of the remark threads one after the opposite in a sweep and be capable to go away feedback/replies wherever they really feel the necessity to. Earlier than we begin implementing the sidebar, there may be one unfinished process we maintain beneath.

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 operate 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 operate 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 centered on the UI implementation, we simply initialize them with some knowledge that lets us affirm the initialization code is working.

Within the real-world utilization of the Commenting System, remark threads are prone 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 prone 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 are able to maintain 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 related.

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

Now, let’s transfer on to calling this operate when the Editor part mounts with the doc so the Recoil state is accurately initialized.

# src/parts/Editor.js

import { initializeStateWithAllCommentThreads } from "../utils/EditorCommentUtils";
import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState";
 
export default operate Editor({ doc, onChange }): JSX.Component {
   ...
  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 got the popover working, we are able to 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 accurately. (Large preview)

Now that our state is accurately initialized, we are able to begin implementing the sidebar. All our remark threads within the UI are saved within the Recoil atom household — commentThreadsState. As highlighted earlier, the way in which we iterate via all of the objects 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 part that iterates via the set of ids on this atom and renders a CommentThread part for every.

# src/parts/CommentsSidebar.js

import "./CommentSidebar.css";

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

export default operate 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 part that listens to the Recoil atom within the household akin to the remark thread it’s rendering. This fashion, because the person provides extra feedback on the thread within the editor or modifications some other metadata, we are able to replace the sidebar to mirror that.

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

# src/parts/CommentSidebar.js

operate 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 part 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 got within the doc and accurately updates as we add new threads or new feedback to present threads.

# src/parts/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 lively within the editor. To have the ability to achieve this, we use the Recoil atom — activeCommentThreadIDAtom. Let’s replace the CommentThread part to help this.

# src/parts/CommentsSidebar.js

operate 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 carefully, we’ve got a bug in our implementation of sync-ing the lively remark thread with the sidebar. As we click on on totally different remark threads within the sidebar, the proper remark thread is certainly highlighted within the editor. Nevertheless, the Remark Popover doesn’t truly transfer to the modified lively 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 technique 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 lively thread.
  2. Type these textual content nodes within the order by which 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 tip 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 needed 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 seems like a cleaner strategy to pick all the remark vary as we actually are deciding on 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.evaluate(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]);

Word: 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 via 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 lively remark thread accurately. This time, we additionally take a look at with a case of overlapping threads to verify 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 got a very 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 give attention to the remark thread within the editor. By setting the choice above utilizing Slate’s API, we get that totally free. Let’s see it in motion beneath.

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

With that, we wrap our implementation of the sidebar. In direction of the tip of the article, we listing out some good function additions and enhancements we are able to do to the Feedback Sidebar that assist elevate the Commenting and Assessment expertise on the editor.

Resolving And Re-Opening Feedback

On this part, we give attention to enabling customers to mark remark threads as ‘Resolved’ or be capable 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, it is a very helpful function because it offers them a technique 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 enables the person to toggle between the 2 statuses: open and resolved.

# src/parts/CommentThreadPopover.js

export default operate 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>
  );
}

operate 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 give attention to these in the event that they wish to.

# src/parts/CommentsSidebar.js

operate 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 elements 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 traces, listed below are a few of options {that a} Wealthy Textual content Editor may contemplate including on high of what we constructed on this article:

  • Help for @ mentions so collaborators may tag each other in feedback;
  • Help for media sorts like pictures and movies to be added to remark threads;
  • Suggestion Mode on the doc stage that enables reviewers to make edits to the doc that seem as solutions for modifications. One may seek advice from 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