Configuring the Comments plugin with the Mentions plugin

The Comments plugin can be used with the Mentions plugin to allow users to mention other users in comments.

When a user starts typing a mention with the @ symbol, the mention dropdown will appear. Users can keep typing to narrow the search results, and either click on the desired user or navigate using arrow keys and press enter to add a mention to the comment.

When the comment is saved, mentioned users will appear in the comment text in the same way as they do in the editor.

All mentions are stored in the comment text as plain text, so for the mentions to be displayed correctly, the Mentions plugin must be included in the TinyMCE configuration when the comments are displayed.

To add mentions to comments, both the Comments and Mentions plugins must be included in the TinyMCE configuration, and all required options must be set.

Callback mode interactive example

  • TinyMCE

  • HTML

  • JS

  • Edit on CodePen

<textarea id="comments-callback">
<h2>Welcome to Tiny Comments!</h2>
<p>Please try out this demo of our Tiny Comments premium plugin with @mentions support.</p>
<ol>
  <li>Highlight the content you want to comment on.</li>
  <li>Click the <em>Add Comment</em> icon in the toolbar.</li>
  <li>Type your comment into the text field at the bottom of the Comment sidebar, and use <code>@</code> followed by a name to mention a collaborator.</li>
  <li>Click <strong>Comment</strong>.</li>
</ol>
<p>Your comment is <span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_420304606321716900864126" data-mce-annotation="tinycomments">then</span> attached to the text, <span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_19679600221621399703915" data-mce-annotation="tinycomments">exactly like this!</span> You can <span class="mymention" style="color: #37F;" data-mention-id="jennynichols" data-mce-mentions-id="jennynichols">@Jenny Nichols</span> directly in your comments to notify them.</p>
<p>If you want to take Tiny Comments for a test drive in your own environment, Tiny Comments is one of the premium plugins you can try for free for 14 days by signing up for a Tiny account. Make sure to check out our documentation as well.</p>
<h2>A simple table to play with</h2>
<table style="border-collapse: collapse; width: 100%;" border="1">
  <thead>
    <tr>
      <th>Product</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://www.tiny.cloud/">Tiny Cloud</a></td>
      <td>The easiest and most reliable way to integrate powerful rich text editing into your application.</td>
    </tr>
    <tr>
      <td><a href="https://www.tiny.cloud/drive/">Tiny Drive</a></td>
      <td>Image and file management for TinyMCE in the cloud.</td>
    </tr>
  </tbody>
</table>
<p>Thanks for supporting TinyMCE! We hope it helps your users create great content.</p>
</textarea>
import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then(({ faker }) => {
  const adminUser = {
    id: 'johnsmith',
    name: 'John Smith',
    fullName: 'John Smith',
    description: 'Company Founder',
    image: "https://i.pravatar.cc/150?img=11"
  };

  const currentUser = {
    id: 'jennynichols',
    name: 'Jenny Nichols',
    fullName: 'Jenny Nichols',
    description: 'Marketing Director',
    image: "https://i.pravatar.cc/150?img=10"
  };
  
  const conversationDb = {
    'mce-conversation_19679600221621399703915': {
      uid: 'mce-conversation_19679600221621399703915',
      comments: [{
        uid: 'mce-conversation_19679600221621399703915',
        author: currentUser.id,
        authorName: currentUser.fullName,
        authorAvatar: currentUser.image,
        content: `What do you think about this @${adminUser.id}?`,
        createdAt: '2021-05-19T04:48:23.914Z',
        modifiedAt: '2021-05-19T04:48:23.914Z'
      },
      {
        uid: 'mce-conversation_19679600221621399703917',
        author: adminUser.id,
        authorName: adminUser.fullName,
        authorAvatar: adminUser.image,
        content: `I think this is a great idea @${currentUser.id}!`,
        createdAt: "2024-05-28T12:54:24.126Z",
        modifiedAt: "2024-05-28T12:54:24.126Z",
      }]
    },
    'mce-conversation_420304606321716900864126': {
      uid: 'mce-conversation_420304606321716900864126',
      comments: [{
        uid: 'mce-conversation_420304606321716900864126',
        author: adminUser.id,
        authorName: adminUser.fullName,
        authorAvatar: adminUser.image,
        content: `@${currentUser.id} Please revise this sentence, exclamation points are unprofessional!`,
        createdAt: '2024-05-28T12:54:24.126Z',
        modifiedAt: '2024-05-28T12:54:24.126Z'
      }]
    }
  };
  
  const fakeDelay = 200;
  const numberOfUsers = 200;
  const randomString = () => {
    return crypto.getRandomValues(new Uint32Array(1))[0].toString(36).substring(2, 14);
  };
  
  /* These are "local" caches of the data returned from the fake server */
  let fetchedUsers = false;
  let usersRequest; // Promise
  const userRequest = {};
  const resolvedConversationDb = {};
  
  const setupFakeServer = () => {
    const images = [ adminUser.image, currentUser.image ];
    const userNames = [ adminUser.fullName, currentUser.fullName ];

    for (let i = 0; i < numberOfUsers; i++) {
      images.push(faker.image.avatar());
      userNames.push(`${faker.person.firstName()} ${faker.person.lastName()}`);
    }
  
    /* This represents a database of users on the server */
    const userDb = {
      [adminUser.id]: adminUser,
      [currentUser.id]: currentUser
    };
    userNames.map((fullName) => {
      if ((fullName !== currentUser.fullName) && (fullName !== adminUser.fullName)) {
        const id = fullName.toLowerCase().replace(/ /g, '');
        userDb[id] = {
          id,
          name: fullName,
          fullName,
          description: faker.person.jobTitle(),
          image: images[Math.floor(images.length * Math.random())]
        };
      }
    });
  
    /* This represents getting the complete list of users from the server with the details required for the mentions "profile" item */
    const fetchUsers = () => new Promise((resolve) => {
      /* simulate a server delay */
      setTimeout(() => {
        const users = Object.keys(userDb).map((id) => ({
          id,
          name: userDb[id].name,
          image: userDb[id].image,
          description: userDb[id].description
        }));
        resolve(users);
      }, fakeDelay);
    });
  
    const fetchUser = (id) => new Promise((resolve, reject) => {
      /* simulate a server delay */
      setTimeout(() => {
        if (Object.prototype.hasOwnProperty.call(userDb, id)) {
          resolve(userDb[id]);
        }
        reject('unknown user id "' + id + '"');
      }, fakeDelay);
    });

    return {
      fetchUsers,
      fetchUser
    };
  };

  const fakeServer = setupFakeServer();
  
  const mentions_fetch = (query, success) => {
    if (!fetchedUsers) {
      fetchedUsers = true;
      usersRequest = fakeServer.fetchUsers();
    }
    usersRequest.then((users) => {
      const userMatch = (name) => name.toLowerCase().indexOf(query.term.toLowerCase()) !== -1;
      success(users.filter((user) => userMatch(user.name) || userMatch(user.id)));
      fetchedUsers = false;
    });
  };
  
  const mentions_menu_hover = (userInfo, success) => {
    if (!userRequest[userInfo.id]) {
      userRequest[userInfo.id] = fakeServer.fetchUser(userInfo.id);
    }
    userRequest[userInfo.id].then((userDetail) => {
      success({ type: 'profile', user: userDetail });
    });
  };
  
  const mentions_menu_complete = (editor, userInfo) => {
    const span = editor.getDoc().createElement('span');
    span.className = 'mymention';
    span.setAttribute('style', 'color: #37F;');
    span.setAttribute('data-mention-id', userInfo.id);
    span.appendChild(editor.getDoc().createTextNode('@' + userInfo.name));
    return span;
  };
  
  const mentions_select = (mention, success) => {
    const id = mention.getAttribute('data-mention-id');
    if (id) {
      userRequest[id] = fakeServer.fetchUser(id);
      userRequest[id].then((userDetail) => {
        success({ type: 'profile', user: userDetail });
      });
    }
  };
  
  const tinycomments_create = (req, done, fail) => {
    if (req.content === 'fail') {
      fail(new Error('Something has gone wrong...'));
    } else {
      const uid = 'annotation-' + randomString();
      conversationDb[uid] = {
        uid,
        comments: [{
        uid,
        authorName: currentUser.fullName,
        authorAvatar: currentUser.image,
        author: currentUser.name,
        content: req.content,
        createdAt: req.createdAt,
        modifiedAt: req.createdAt
        }]
      };
    
      setTimeout(() => {
        done({
        conversationUid: uid
        });
      }, fakeDelay);
    }
  };
  
  const tinycomments_reply = (req, done) => {
    const replyUid = 'annotation-' + randomString();
    const current = conversationDb[req.conversationUid];
    current.comments.push(
      {
        uid: replyUid,
        authorName: currentUser.fullName,
        authorAvatar: currentUser.image,
        author: currentUser.name,
        content: req.content,
        createdAt: req.createdAt,
        modifiedAt: req.createdAt
      }
    );
  
    setTimeout(() => {
      done({
        commentUid: replyUid
      });
    }, fakeDelay);
  };
  
  const tinycomments_delete = (req, done) => {
    delete conversationDb[req.conversationUid];
  
    setTimeout(() => {
      done({
        canDelete: true
      });
    }, fakeDelay);
  };
  
  const tinycomments_resolve = (req, done) => {
    resolvedConversationDb[req.conversationUid] = conversationDb[req.conversationUid];
    delete conversationDb[req.conversationUid];
  
    setTimeout(() => {
      done({
        canResolve: true
      });
    }, fakeDelay);
  };
  
  const tinycomments_delete_comment = (req, done) => {
    const current = conversationDb[req.conversationUid];
    // Should be supported on browsers ...
    current.comments = current.comments.filter((f) => {
      return f.uid !== req.commentUid;
    });
  
    setTimeout(() => {
      done({
        canDelete: true
      });
    }, fakeDelay);
  };
  
  const tinycomments_edit_comment = (req, done) => {
    const current = conversationDb[req.conversationUid];
    // Should be supported on browsers ...
    current.comments = current.comments.map((f) => {
    return f.uid === req.commentUid ? {
        ...f,
        content: req.content,
        modifiedAt: new Date().toISOString()
      } : f;
    });
  
    setTimeout(() => {
      done({
        canEdit: true
      });
    }, fakeDelay);
  };
  
  const tinycomments_delete_all = (req, done) => {
    Object.keys(conversationDb).forEach((k) => {
      delete conversationDb[k];
    });
  
    setTimeout(() => {
      done({
        canDelete: true
      });
    }, fakeDelay);
  };
  
  const tinycomments_lookup = (req, done) => {
    setTimeout(() => {
      done({
        conversation: {
        uid: conversationDb[req.conversationUid].uid,
        comments: conversationDb[req.conversationUid].comments.slice(0)
        }
      });
    }, fakeDelay);
  };
  
  const tinycomments_fetch = (_, done) => {
    setTimeout(() => done({
      conversations: conversationDb
    }), fakeDelay);
  };
  
  
  tinymce.init({
    selector: 'textarea#comments-callback',
    license_key: 'gpl',
    toolbar: 'addcomment showcomments code | bold italic underline',
    menubar: 'file edit view insert format tools tc help',
    menu: {
      tc: {
        title: 'TinyComments',
        items: 'addcomment showcomments deleteallconversations'
      }
    },
    plugins: [ 'tinycomments', 'mentions', 'help', 'code', 'quickbars', 'link', 'lists', 'image' ],
    quickbars_selection_toolbar: 'alignleft aligncenter alignright | addcomment showcomments',
    quickbars_image_toolbar: 'alignleft aligncenter alignright | rotateleft rotateright | imageoptions',
    tinycomments_mentions_enabled: true,
    sidebar_show: 'showcomments',
  
    mentions_item_type: 'profile',
    mentions_min_chars: 0,
    mentions_selector: '.mymention',
    mentions_fetch,
    mentions_menu_hover,
    mentions_menu_complete,
    mentions_select,
  
    tinycomments_mode: 'callback',
    tinycomments_author: currentUser.id,
    tinycomments_author_name: currentUser.fullName,
    tinycomments_avatar: currentUser.image,
    tinycomments_create,
    tinycomments_reply,
    tinycomments_delete,
    tinycomments_resolve,
    tinycomments_delete_all,
    tinycomments_lookup,
    tinycomments_delete_comment,
    tinycomments_edit_comment,
    tinycomments_fetch,
  });
});

Embedded mode interactive example

  • TinyMCE

  • HTML

  • JS

  • Edit on CodePen

<div id="tiny-ui">
  <textarea id="comments-embedded" style="width: 100%; height: 500px;">
    <h2>Welcome to Tiny Comments!</h2>
    <p>Please try out this demo of our Tiny Comments premium plugin with @mentions support.</p>
    <ol>
      <li>Highlight the content you want to comment on.</li>
      <li>Click the <em>Add Comment</em> icon in the toolbar.</li>
      <li>Type your comment into the text field at the bottom of the Comment sidebar, and use <code>@</code> followed by a name to mention a collaborator.</li>
      <li>Click <strong>Comment</strong>.</li>
    </ol>
    <p>Your comment is <span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_420304606321716900864126" data-mce-annotation="tinycomments">then</span> attached to the text, <span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_19679600221621399703915" data-mce-annotation="tinycomments">exactly like this!</span> You can <span class="mymention" style="color: #37F;" data-mention-id="jennynichols" data-mce-mentions-id="jennynichols">@Jenny Nichols</span> directly in your comments to notify them.</p>
    <p>If you want to take Tiny Comments for a test drive in your own environment, Tiny Comments is one of the premium plugins you can try for free for 14 days by signing up for a Tiny account. Make sure to check out our documentation as well.</p>
    <h2>A simple table to play with</h2>
    <table style="border-collapse: collapse; width: 100%;" border="1">
      <thead>
        <tr>
          <th>Product</th>
          <th>Value</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td><a href="https://www.tiny.cloud/">Tiny Cloud</a></td>
          <td>The easiest and most reliable way to integrate powerful rich text editing into your application.</td>
        </tr>
        <tr>
          <td><a href="https://www.tiny.cloud/drive/">Tiny Drive</a></td>
          <td>Image and file management for TinyMCE in the cloud.</td>
        </tr>
      </tbody>
    </table>
    <p>Thanks for supporting TinyMCE! We hope it helps your users create great content.</p>
    <!--tinycomments|2.1|data:application/json;base64,eyJtY2UtY29udmVyc2F0aW9uXzE5Njc5NjAwMjIxNjIxMzk5NzAzOTE1Ijp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMTk2Nzk2MDAyMjE2MjEzOTk3MDM5MTUiLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8xOTY3OTYwMDIyMTYyMTM5OTcwMzkxNSIsImF1dGhvciI6Implbm55bmljaG9scyIsImF1dGhvck5hbWUiOiJKZW5ueSBOaWNob2xzIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pLnByYXZhdGFyLmNjLzE1MD9pbWc9MTAiLCJjb250ZW50IjoiV2hhdCBkbyB5b3UgdGhpbmsgYWJvdXQgdGhpcyBAam9obnNtaXRoPyIsImNyZWF0ZWRBdCI6IjIwMjEtMDUtMTlUMDQ6NDg6MjMuOTE0WiIsIm1vZGlmaWVkQXQiOiIyMDIxLTA1LTE5VDA0OjQ4OjIzLjkxNFoifSx7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMTk2Nzk2MDAyMjE2MjEzOTk3MDM5MTciLCJhdXRob3IiOiJqb2huc21pdGgiLCJhdXRob3JOYW1lIjoiSm9obiBTbWl0aCIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaS5wcmF2YXRhci5jYy8xNTA/aW1nPTExIiwiY29udGVudCI6IkkgdGhpbmsgdGhpcyBpcyBhIGdyZWF0IGlkZWEgQGplbm55bmljaG9scyEiLCJjcmVhdGVkQXQiOiIyMDI0LTA1LTI4VDEyOjU0OjI0LjEyNloiLCJtb2RpZmllZEF0IjoiMjAyNC0wNS0yOFQxMjo1NDoyNC4xMjZaIn1dfSwibWNlLWNvbnZlcnNhdGlvbl80MjAzMDQ2MDYzMjE3MTY5MDA4NjQxMjYiOnsidWlkIjoibWNlLWNvbnZlcnNhdGlvbl80MjAzMDQ2MDYzMjE3MTY5MDA4NjQxMjYiLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl80MjAzMDQ2MDYzMjE3MTY5MDA4NjQxMjYiLCJhdXRob3IiOiJqb2huc21pdGgiLCJhdXRob3JOYW1lIjoiSm9obiBTbWl0aCIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaS5wcmF2YXRhci5jYy8xNTA/aW1nPTExIiwiY29udGVudCI6IkBqZW5ueW5pY2hvbHMgUGxlYXNlIHJldmlzZSB0aGlzIHNlbnRlbmNlLCBleGNsYW1hdGlvbiBwb2ludHMgYXJlIHVucHJvZmVzc2lvbmFsISIsImNyZWF0ZWRBdCI6IjIwMjQtMDUtMjhUMTI6NTQ6MjQuMTI2WiIsIm1vZGlmaWVkQXQiOiIyMDI0LTA1LTI4VDEyOjU0OjI0LjEyNloifV19fQ==-->
  </textarea>
</div>
import ('https://cdn.jsdelivr.net/npm/@faker-js/faker@9/dist/index.min.js').then(({ faker }) => {
  const adminUser = {
    id: 'johnsmith',
    name: 'John Smith',
    fullName: 'John Smith',
    description: 'Company Founder',
    image: "https://i.pravatar.cc/150?img=11"
  };

  const currentUser = {
    id: 'jennynichols',
    name: 'Jenny Nichols',
    fullName: 'Jenny Nichols',
    description: 'Marketing Director',
    image: "https://i.pravatar.cc/150?img=10"
  };
  
  const fakeDelay = 500;
  const numberOfUsers = 200;
  
  /* These are "local" caches of the data returned from the fake server */
  let fetchedUsers = false;
  let usersRequest; // Promise
  const userRequest = {};
  
  const setupFakeServer = () => {
    const images = [ adminUser.image, currentUser.image ];
    const userNames = [ adminUser.fullName, currentUser.fullName ];

    for (let i = 0; i < numberOfUsers; i++) {
      images.push(faker.image.avatar());
      userNames.push(`${faker.person.firstName()} ${faker.person.lastName()}`);
    }
  
    /* This represents a database of users on the server */
    const userDb = {
      [adminUser.id]: adminUser,
      [currentUser.id]: currentUser
    };
    userNames.map((fullName) => {
      if ((fullName !== currentUser.fullName) && (fullName !== adminUser.fullName)) {
        const id = fullName.toLowerCase().replace(/ /g, '');
        userDb[id] = {
          id,
          name: fullName,
          fullName,
          description: faker.person.jobTitle(),
          image: images[Math.floor(images.length * Math.random())]
        };
      }
    });
  
    /* This represents getting the complete list of users from the server with the details required for the mentions "profile" item */
    const fetchUsers = () => new Promise((resolve) => {
      /* simulate a server delay */
      setTimeout(() => {
        const users = Object.keys(userDb).map((id) => ({
          id,
          name: userDb[id].name,
          image: userDb[id].image,
          description: userDb[id].description
        }));
        resolve(users);
      }, fakeDelay);
    });
  
    const fetchUser = (id) => new Promise((resolve, reject) => {
      /* simulate a server delay */
      setTimeout(() => {
        if (Object.prototype.hasOwnProperty.call(userDb, id)) {
          resolve(userDb[id]);
        }
        reject('unknown user id "' + id + '"');
      }, fakeDelay);
    });

    return {
      fetchUsers,
      fetchUser
    };
  };

  const fakeServer = setupFakeServer();
  
  const mentions_fetch = (query, success) => {
    if (!fetchedUsers) {
      fetchedUsers = true;
      usersRequest = fakeServer.fetchUsers();
    }
    usersRequest.then((users) => {
      const userMatch = (name) => name.toLowerCase().indexOf(query.term.toLowerCase()) !== -1;
      success(users.filter((user) => userMatch(user.name) || userMatch(user.id)));
      fetchedUsers = false;
    });
  };
  
  const mentions_menu_hover = (userInfo, success) => {
    if (!userRequest[userInfo.id]) {
      userRequest[userInfo.id] = fakeServer.fetchUser(userInfo.id);
    }
    userRequest[userInfo.id].then((userDetail) => {
      success({ type: 'profile', user: userDetail });
    });
  };
  
  const mentions_menu_complete = (editor, userInfo) => {
    const span = editor.getDoc().createElement('span');
    span.className = 'mymention';
    span.setAttribute('style', 'color: #37F;');
    span.setAttribute('data-mention-id', userInfo.id);
    span.appendChild(editor.getDoc().createTextNode('@' + userInfo.name));
    return span;
  };
  
  const mentions_select = (mention, success) => {
    const id = mention.getAttribute('data-mention-id');
    if (id) {
      userRequest[id] = fakeServer.fetchUser(id);
      userRequest[id].then((userDetail) => {
        success({ type: 'profile', user: userDetail });
      });
    }
  };

  const tinycomments_can_resolve = (req, done, _fail) => {
    const allowed = req.comments.length > 0 && req.comments[0].author === author;
    done({
      canResolve: allowed
    });
  };
  
  tinymce.init({
    selector: 'textarea#comments-embedded',
    license_key: 'gpl',
    toolbar: 'addcomment showcomments code | bold italic underline',
    menubar: 'file edit view insert format tools tc help',
    menu: {
      tc: {
        title: 'TinyComments',
        items: 'addcomment showcomments deleteallconversations'
      }
    },
    plugins: [ 'tinycomments', 'mentions', 'help', 'code', 'quickbars', 'link', 'lists', 'image' ],
    quickbars_selection_toolbar: 'alignleft aligncenter alignright | addcomment showcomments',
    quickbars_image_toolbar: 'alignleft aligncenter alignright | rotateleft rotateright | imageoptions',
    tinycomments_mentions_enabled: true,
  
    mentions_item_type: 'profile',
    mentions_min_chars: 0,
    mentions_selector: '.mymention',
    mentions_fetch,
    mentions_menu_hover,
    mentions_menu_complete,
    mentions_select,
  
    tinycomments_mode: 'embedded',
    sidebar_show: 'showcomments',
    tinycomments_author: currentUser.id,
    tinycomments_author_name: currentUser.fullName,
    tinycomments_avatar: currentUser.image,
    tinycomments_can_resolve,
  });
});

Required Mentions Options

These configuration options are required for use of the mentions plugin.

mentions_fetch

This option lets you request a list of users from your server that match a search query. The callback gets passed two parameters: one is the search query object, the other is the success callback to execute with the results. The query object has a term property that contains what the user has typed after the "@" sign.

The success call should contain an array of users.

Only the first ten (10) users listed in the array are displayed in the Mentions UI presented to end-users.

For information on the user properties to pass to the success callback for the available mentions item types (mentions_item_type), see: User properties.

Type: Function

Example: using mentions_fetch

let usersRequest = null;

tinymce.init({
  selector: 'textarea',
  plugins: 'mentions',
  mentions_fetch: (query, success) => {
    // Fetch your full user list from the server and cache locally
    if (usersRequest === null) {
      usersRequest = fetch('/users');
    }
    usersRequest.then((users) => {
      // `query.term` is the text the user typed after the '@'
      users = users.filter((user) => {
        return user.name.toLowerCase().includes(query.term.toLowerCase());
      });

      users = users.slice(0, 10);

      // Where the user object must contain the properties `id` and `name`
      // but you could additionally include anything else you deem useful.
      success(users);
    });
  }
});

The success callback can be passed an optional array of extra items. When clicked, the menu reloads and passes additional query parameters to the fetch function. The extra items can be used to search with different queries or show additional results, such as a full text search (which is slower to fetch). Each extra item should contain:

  • A "text" property for the content to be displayed in the menu.

  • A "meta" property for that will be passed using the fetch query parameter.

Example with extras

tinymce.init({
  selector: 'textarea',
  plugins: 'mentions',
  mentions_fetch: (query, success) => {
    // query.term is the text the user typed after the '@'
    let url = '/users?query=' + query.term;
    const isFullTextSearch = query.meta && query.meta.fullTextSearch;
    if (isFullTextSearch) {
      url += '&full=true'
    }

    // Extras are shown at the end of the list and will reload the menu
    // by passing the meta to the fetch function
    const extras = isFullTextSearch ? [ ] : [
      {
        text: 'Full user search...',
        meta: { fullTextSearch: true }
      }
    ];

    fetch(url).then((users) => {
      // Where the user object must contain the properties `id` and `name`
      // but you could additionally include anything else you deem useful.
      success(users, extras);
    });
  }
});

Required Comments options in callback mode

When using callback mode, the Comments plugin requires callback functions for the following options:

tinycomments_create

The Comments plugin uses the tinycomments_create function to create a comment.

The tinycomments_create function saves the comment as a new conversation and returns a unique conversation ID via the done callback. If an unrecoverable error occurs, it should indicate this with the fail callback.

The tinycomments_create function is given a request (req) object as the first parameter, which has these fields:

  • content: The content of the comment to create.

  • createdAt: The date the comment was created.

The done callback should accept the following object:

{
  conversationUid: string, // the new conversation uid
  // Optional error callback which will be run if the new conversation could not be created
  onError: (err) => { ... },
  // Optional success callback which will be run when the new conversation is successfully created
  onSuccess: (uid) => { ... }
}
For example:
const create_comment = (ref, done, fail) => {
  const { content, createdAt } = ref;

  fetch('https://api.example/conversations/', {
    method: 'POST',
    body: JSON.stringify({ content: content, createdAt: createdAt }),
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
  })
    .then((response) => {
      if (!response.ok) {
        throw new Error('Failed to create comment');
      }
      return response.json();
    })
    .then((ref2) => {
      let conversationUid = ref2.conversationUid;
      done({ conversationUid: conversationUid });
    })
    .catch((e) => {
      fail(e);
    });
}

tinymce.init({
  selector: 'textarea', // change this value according to your HTML
  plugins: 'tinycomments',
  tinycomments_mode: 'callback',
  tinycomments_create: create_comment, // Add the callback to TinyMCE
  tinycomments_reply: reply_comment,
  tinycomments_edit_comment: edit_comment,
  tinycomments_delete: delete_comment_thread,
  tinycomments_delete_all: delete_all_comment_threads,
  tinycomments_delete_comment: delete_comment,
  tinycomments_lookup: lookup_comment,
  tinycomments_fetch: fetch_comments // Optional callback
});

tinycomments_reply

The Comments plugin uses the tinycomments_reply function to reply to a comment.

The tinycomments_reply function saves the comment as a reply to an existing conversation and returns via the done callback once successful. Unrecoverable errors are communicated to TinyMCE by calling the fail callback instead.

The tinycomments_reply function is given a request (req) object as the first parameter, which has these fields:

  • conversationUid: The uid of the conversation the reply is targeting.

  • content: The content of the comment to create.

  • createdAt: The date the comment was created (ISO 8601 date string) format.

The done callback should accept the following object:

{
  commentUid: string, // the new comment uid
  author: string, // the id of the current author
  authorName: string // the name of the current author
}
For example:
const reply_comment = (ref, done, fail) => {
  const { conversationUid, content, createdAt } = ref;

  fetch(`https://api.example/conversations/${conversationUid}`, {
    method: 'POST',
    body: JSON.stringify({ content: content, createdAt: createdAt }),
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
  })
    .then((response) => {
      if (!response.ok) {
        throw new Error('Failed to reply to comment');
      }
      return response.json();
    })
    .then((ref2) => {
      let commentUid = ref2.commentUid;
      done({
        commentUid: replyUid,
        author: currentUser.id,
        authorName: currentUser.fullName
      });
    })
    .catch((e) => {
      fail(e);
    });
}

tinymce.init({
  selector: 'textarea', // change this value according to your HTML
  plugins: 'tinycomments',
  tinycomments_mode: 'callback',
  tinycomments_create: create_comment,
  tinycomments_reply: reply_comment, // Add the callback to TinyMCE
  tinycomments_edit_comment: edit_comment,
  tinycomments_delete: delete_comment_thread,
  tinycomments_delete_all: delete_all_comment_threads,
  tinycomments_delete_comment: delete_comment,
  tinycomments_lookup: lookup_comment,
  tinycomments_fetch: fetch_comments // Optional callback
});

tinycomments_edit_comment

The Comments plugin uses the tinycomments_edit_comment function to edit a comment.

The tinycomments_edit_comment function allows updating or changing existing comments and returns via the done callback once successful. Unrecoverable errors are communicated to TinyMCE by calling the fail callback instead.

The tinycomments_edit_comment function is given a request (req) object as the first parameter, which has these fields:

  • conversationUid: The uid of the conversation the reply is targeting.

  • commentUid: The uid of the comment to edit (it can be the same as conversationUid if editing the first comment in a conversation).

  • content: The content of the comment to create.

  • modifiedAt: The date the comment was modified.

The done callback should accept the following object:

{
  canEdit: boolean, // whether or not the Edit succeeded
  reason: string? // an optional string explaining why the edit was not allowed (if canEdit is false)
}
For example:
const edit_comment = (ref, done, fail) => {
  const { conversationUid, commentUid, content, modifiedAt } = ref;

  fetch(
    `https://api.example/conversations/${conversationUid}/${commentUid}`,
    {
      method: 'PUT',
      body: JSON.stringify({ content: content, modifiedAt: modifiedAt }),
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
    }
  )
    .then((response) => {
      if (!response.ok) {
        throw new Error('Failed to edit comment');
      }
      return response.json();
    })
    .then((ref2) => {
      let canEdit = ref2.canEdit;
      done({ canEdit: canEdit });
    })
    .catch((e) => {
      fail(e);
    });
}

tinymce.init({
  selector: 'textarea', // change this value according to your HTML
  plugins: 'tinycomments',
  tinycomments_mode: 'callback',
  tinycomments_create: create_comment,
  tinycomments_reply: reply_comment,
  tinycomments_edit_comment: edit_comment, // Add the callback to TinyMCE
  tinycomments_delete: delete_comment_thread,
  tinycomments_delete_all: delete_all_comment_threads,
  tinycomments_delete_comment: delete_comment,
  tinycomments_lookup: lookup_comment,
  tinycomments_fetch: fetch_comments // Optional callback
});

tinycomments_delete_comment

The tinycomments_delete_comment function should asynchronously return a flag indicating whether the comment or comment thread was removed using the done callback. Unrecoverable errors are communicated to TinyMCE by calling the fail callback instead.

The tinycomments_delete_comment function is given a request (req) object as the first parameter, which has these fields:

  • conversationUid: The uid of the conversation the reply is targeting.

  • commentUid: The uid of the comment to delete (cannot be the same as conversationUid).

The done callback should accept the following object:

{
  canDelete: boolean, // whether or not an individual comment can be deleted
  reason: string? // an optional reason explaining why the delete was not allowed (if canDelete is false)
}
Failure to delete due to permissions or business rules is indicated by "false", while unexpected errors should be indicated using the "fail" callback.
For example:
const delete_comment = (ref, done, fail) => {
  const { conversationUid, commentUid } = ref;

  fetch(
    `https://api.example/conversations/${conversationUid}/${commentUid}`,
    {
      method: 'DELETE',
    }
  ).then((response) => {
    if (response.ok) {
      done({ canDelete: true });
    } else if (response.status === 403) {
      done({ canDelete: false });
    } else {
      fail(new Error('Something has gone wrong...'));
    }
  });
}

tinymce.init({
  selector: 'textarea', // change this value according to your HTML
  plugins: 'tinycomments',
  tinycomments_mode: 'callback',
  tinycomments_create: create_comment,
  tinycomments_reply: reply_comment,
  tinycomments_edit_comment: edit_comment,
  tinycomments_delete: delete_comment_thread, // Add the callback to TinyMCE
  tinycomments_delete_all: delete_all_comment_threads,
  tinycomments_delete_comment: delete_comment,
  tinycomments_lookup: lookup_comment,
  tinycomments_fetch: fetch_comments // Optional callback
});

tinycomments_delete

The tinycomments_delete function should asynchronously return a flag indicating whether the comment thread was removed using the done callback. Unrecoverable errors are communicated to TinyMCE by calling the fail callback instead.

The tinycomments_delete function is passed a (req) object as the first parameter, which contains the following key-value pair:

  • conversationUid: The uid of the conversation the reply is targeting.

The done callback should accept the following object:

{
  canDelete: boolean // whether or not the conversation can be deleted
  reason: string? // an optional string explaining why the delete was not allowed (if canDelete is false)
}
Failure to delete due to permissions or business rules is indicated by "false", while unexpected errors should be indicated using the "fail" callback.

For example:

const delete_comment_thread = (ref, done, fail) => {
  const conversationUid = ref.conversationUid;
  fetch(`https://api.example/conversations/${conversationUid}`, {
    method: 'DELETE',
  }).then((response) => {
    if (response.ok) {
      done({ canDelete: true });
    } else if (response.status === 403) {
      done({ canDelete: false });
    } else {
      fail(new Error('Something has gone wrong...'));
    }
  });
}

tinymce.init({
  selector: '#editor',
  plugins: 'tinycomments',
  tinycomments_mode: 'callback',
  tinycomments_create: create_comment,
  tinycomments_reply: reply_comment,
  tinycomments_edit_comment: edit_comment,
  tinycomments_delete: delete_comment_thread, // Add the callback to TinyMCE
  tinycomments_delete_all: delete_all_comment_threads,
  tinycomments_delete_comment: delete_comment,
  tinycomments_lookup: lookup_comment,
  tinycomments_fetch: fetch_comments // Optional callback
});

tinycomments_delete_all

The tinycomments_delete_all function should asynchronously return a flag indicating whether all the comment threads were removed using the done callback. Unrecoverable errors are communicated to TinyMCE by calling the fail callback instead.

The tinycomments_delete_all function is given a request (req) object as the first parameter with no fields.

The done callback should accept the following object:

{
  canDelete: boolean, // whether or not all conversations can be deleted
  reason: string? // an optional string explaining why the deleteAll was not allowed (if canDelete is false)
}
Failure to delete due to permissions or business rules should be indicated by canDelete: false, while unexpected errors should be indicated using the fail callback.
For example:
const delete_all_comment_threads = (_req, done, fail) => {
  fetch('https://api.example/conversations', {
    method: 'DELETE',
  }).then((response) => {
    if (response.ok) {
      done({ canDelete: true });
    } else if (response.status === 403) {
      done({ canDelete: false });
    } else {
      fail(new Error('Something has gone wrong...'));
    }
  });
}

tinymce.init({
  selector: 'textarea', // change this value according to your HTML
  plugins: 'tinycomments',
  tinycomments_mode: 'callback',
  tinycomments_create: create_comment,
  tinycomments_reply: reply_comment,
  tinycomments_edit_comment: edit_comment,
  tinycomments_delete: delete_comment_thread,
  tinycomments_delete_all: delete_all_comment_threads, // Add the callback to TinyMCE
  tinycomments_delete_comment: delete_comment,
  tinycomments_lookup: lookup_comment,
  tinycomments_fetch: fetch_comments // Optional callback
});

tinycomments_lookup

The Comments plugin uses the tinycomments_lookup function to retrieve one existing conversation using a conversation’s unique ID.

The Display names configuration must be considered for the tinycomments_lookup function:

Display names

The Comments plugin uses a simple string for the display name. For the lookup function, Comments expects each comment to contain the author’s display name, not a user ID, as Comments does not know the user identities. The lookup function should be implemented considering this and resolve user identifiers to an appropriate display name.

The conventional conversation object structure that should be returned via the done callback is as follows:

The tinycomments_lookup function is passed a (req) object as the first parameter, which contains the following key-value pair:

conversationUid

The uid of the conversation the reply is targeting.

The done callback should accept the following object:

{
 conversation: {
   uid: string, // the uid of the conversation,
   comments: [
    {
      author: string, // author of first comment
      authorName: string, // optional - Display name to use instead of author. Defaults to using `author` if not specified
      authorAvatar: string, // optional - URL to the author's avatar. If not provided an automated avatar will be generated
      createdAt: date, // when the first comment was created
      content: string, // content of first comment
      modifiedAt: date, // when the first comment was created/last updated
      uid: string // the uid of the first comment in the conversation
    },
    {
      author: string, // author of second comment
      authorName: string, // optional - Display name to use instead of author. Defaults to using `author` if not specified
      authorAvatar: string, // optional - URL to the author's avatar. If not provided an automated avatar will be generated
      createdAt: date, // when the second comment was created
      content: string, // content of second comment
      modifiedAt: date, // when the second comment was created/last updated
      uid: string // the uid of the second comment in the conversation
    }
  ]
 }
}

The dates should use ISO 8601 format. This can be generated in JavaScript with: new Date().toISOString().

The author avatar feature is only available in TinyMCE 6.1 or higher and if provided:

  • will be scaled to a 36px diameter circle; and

  • can be any filetype able to be wrapped in an <img> element.

For example:
const lookup_comment = ({ conversationUid }, done, fail) => {
  const lookup = async () => {
    const convResp = await fetch(
      `https://api.example/conversations${conversationUid}`
    );
    if (!convResp.ok) {
      throw new Error('Failed to get conversation');
    }
    const comments = await convResp.json();
    const usersResp = await fetch('https://api.example/users/');
    if (!usersResp.ok) {
      throw new Error('Failed to get users');
    }
    const { users } = await usersResp.json();
    const getUser = (userId) => users.find((u) => u.id === userId);
    return {
      conversation: {
        uid: conversationUid,
        comments: comments.map((comment) => {
          const user = getUser(comment.author);
          return {
            ...comment,
            content: comment.content,
            authorName: user?.displayName,
            authorAvatar: user?.avatarUrl
          };
        })
      }
    };
  };

  lookup()
    .then((data) => {
      console.log(`Lookup success ${conversationUid}`, data);
      done(data);
    })
    .catch((err) => {
      console.error(`Lookup failure ${conversationUid}`, err);
      fail(err);
    });
};

tinymce.init({
  selector: '#editor',
  plugins: 'tinycomments',
  tinycomments_mode: 'callback',
  tinycomments_create: create_comment,
  tinycomments_reply: reply_comment,
  tinycomments_edit_comment: edit_comment,
  tinycomments_delete: delete_comment_thread,
  tinycomments_delete_all: delete_all_comment_threads,
  tinycomments_delete_comment: delete_comment,
  tinycomments_lookup: lookup_comment, // Add the callback to TinyMCE
  tinycomments_fetch: fetch_comments // Optional callback
});

Required Comments options for embedded mode

When using embedded mode, the Comments plugin requires the following options:

tinycomments_author

This option sets the author id to be used when creating or replying to comments.

Type: String

Default value: 'Anon'

Example: using tinycomments_author

tinymce.init({
  selector: 'textarea',  // change this value according to your html
  plugins: 'tinycomments',
  toolbar: 'addcomment showcomments',
  tinycomments_mode: 'embedded',
  tinycomments_author: 'embedded_journalist',
});

tinycomments_author_name

Optional: This option sets the author’s display name to be used when creating or replying to comments. If this option is omitted, the author id is used instead.

Type: String

Example: using tinycomments_author_name

tinymce.init({
  selector: 'textarea',  // change this value according to your html
  plugins: 'tinycomments',
  toolbar: 'addcomment showcomments',
  tinycomments_mode: 'embedded',
  tinycomments_author: 'embedded_journalist',
  tinycomments_author_name: 'Embedded Journalist',
});

Additional options

tinycomments_mentions_enabled

The Comments plugin offers the tinycomments_mentions_enabled option to control whether the Mentions plugin will be incorporated into the Comments plugin, when both plugins are included in the configuration.

Type: Boolean

Possible values: true, false

Default value: true

Example: using tinycomments_mentions_enabled option

tinymce.init({
  selector: 'textarea',  // change this value according to your html
  plugins: 'tinycomments mentions',
  toolbar: 'addcomment showcomments',
  tinycomments_mentions_enabled: true,
  tinycomments_mode: 'embedded',
  tinycomments_author: 'johnsmith',
  tinycomments_author_name: 'John Smith',
  mentions_fetch,
  mentions_menu_complete,
  mentions_menu_hover,
  mentions_select
});

APIs

The Comments plugin provides the getEventLog() API, as well as an annotator named 'tinycomments' created using the editor.annotator API.

getEventLog()

This feature is only available for TinyMCE 6.1 and later.

The getEventLog returns a log that contains information and timestamps of all changes to comments, including when:

  • A new comment is added.

{
  "type": "create",
  "timestamp": "2024-10-01T03:07:53.771Z",
  "conversationUid": "annotation-r1nn5xdo5ye",
  "conversationContext": "Welcome",
  "conversationContent": "new comment",
  "conversationAuthor": {
      "author": "DEMO USER",
      "authorName": "DEMO USER"
  }
},
  • A comment is edited.

{
  "type": "edit-comment",
  "timestamp": "2024-10-01T03:08:06.551Z",
  "conversationUid": "annotation-r1nn5xdo5ye",
  "commentUid": "annotation-r1nn5xdo5ye",
  "conversationContext": "Welcome",
  "conversationContent": "new comment",
  "conversationCreatedAt": "2024-10-01T03:07:53.771Z",
  "commentContent": "new comment (Edit comment)",
  "commentAuthor": {
      "author": "DEMO USER",
      "authorName": "DEMO USER"
  },
  "conversationAuthor": {
      "author": "DEMO USER",
      "authorName": "DEMO USER"
  }
},
  • A reply to a comment is added.

{
  "type": "reply",
  "timestamp": "2024-10-01T03:07:53.771Z",
  "conversationUid": "annotation-r1nn5xdo5ye",
  "commentUid": "annotation-uh00rb41kma",
  "conversationContext": "Welcome",
  "conversationContent": "new comment (Edit comment)",
  "conversationCreatedAt": "2024-10-01T03:07:53.771Z",
  "commentContent": "reply to existing comment",
  "commentAuthor": {
      "author": "DEMO USER",
      "authorName": "DEMO USER"
  },
  "conversationAuthor": {
      "author": "DEMO USER",
      "authorName": "DEMO USER"
  }
},
  • A comment is resolved.

{
  "type": "resolve",
  "timestamp": "2024-10-01T03:08:25.783Z",
  "conversationUid": "annotation-r1nn5xdo5ye",
  "conversationContext": "Welcome",
  "conversationContent": "new comment (Edit comment)",
  "conversationAuthor": {
      "author": "DEMO USER",
      "authorName": "DEMO USER"
  }
},
  • A comment is deleted.

{
  "type": "delete-comment",
  "timestamp": "2024-10-01T03:08:23.292Z",
  "conversationUid": "annotation-r1nn5xdo5ye",
  "commentUid": "annotation-uh00rb41kma",
  "conversationContext": "Welcome",
  "conversationContent": "new comment (Edit comment)",
  "commentContent": "reply to existing comment",
  "commentAuthor": {
      "author": "DEMO USER",
      "authorName": "DEMO USER"
  },
  "conversationAuthor": {
      "author": "DEMO USER",
      "authorName": "DEMO USER"
  }
},

The event log can be retrieved either in full or with the after option, which restricts the returned list to Comment events after a time-stamp date in the ISO-8601 format, both shown in the following:

With the Mentions plugin

When the Mentions plugin is enabled, each of the above events will include the mentionedUids property, which contains an array of UIDs mentioned in the comment. The mentionedUids property is only included when the Mentions plugin is enabled.

It is recommended to use this API to retrieve which users have been mentioned in comments.

The mentionedUids array captures strings following the @ symbol without verifying if they correspond to valid user IDs. It is the integrator’s responsibility to validate these strings against the database to ensure they represent valid users.

For guidance on retrieving and verifying the mentionedUids array, refer to the getEventLog example.

Example: using getEventLog()

// Sample user database
const userDb = {
    "johnsmith": {
        "id": "johnsmith",
        "name": "John Smith",
        "fullName": "John Smith",
        "description": "Company Founder",
        "image": "https://i.pravatar.cc/150?img=11"
    },
    "jennynichols": {
        "id": "jennynichols",
        "name": "Jenny Nichols",
        "fullName": "Jenny Nichols",
        "description": "Marketing Director",
        "image": "https://i.pravatar.cc/150?img=10"
    }
};

const comments = tinymce.activeEditor.plugins.tinycomments;

console.log(comments.getEventLog());
console.log(comments.getEventLog(
  { after: '2022-02-22T12:34:56Z' }  // ISO-8601 standard: YYYY-MM-DDThh:mm:ssZ
));

const eventLog = comments.getEventLog();
const events = eventLog.events;

// Ensure that the mentioned users are valid users in the database
const validatedEvents = JSON.parse(JSON.stringify(events));
validatedEvents.forEach((event) => {
  // Filter out invalid users - change this to your own validation logic
  event.mentionedUids = event.mentionedUids ? event.mentionedUids.filter((uid) => userDb[uid]) : undefined;
});

const mentionedUsers = validatedEvents.flatMap(({ mentionedUids }) => mentionedUids || []);
console.log(mentionedUsers);

let whoMentionedWho = {};
validatedEvents.forEach((event) => {
  if ((event.type === "create" || event.type === "reply") && event.mentionedUids !== undefined) {
    console.log(event);
    if (whoMentionedWho[event.conversationAuthor.author] === undefined) {
      whoMentionedWho[event.conversationAuthor.author] = [...event.mentionedUids];
    } else {
      whoMentionedWho[event.conversationAuthor.author].push(...event.mentionedUids);
    }
  }
});
console.log(whoMentionedWho);