How to build your own AI suggestions custom plugin in your TinyMce react editor

AI suggestion plugin is a paid feature of tinymce rich text editor implement the feature absolutely free in your react app

Introduction

TinyMCE is a powerful, feature-rich JavaScript-based WYSIWYG (What You See Is What You Get) editor that enables users to create, edit, and format text content in a web-based environment. It is widely used in content management systems (CMS), blog platforms, and web applications where rich text editing capabilities are required.

Importance of AI suggestions in text editors

  • Boosts Productivity: Speeds up writing with predictive text, autocomplete, and grammar checks.

  • Improves Content Quality: Enhances clarity, readability, and consistency through real-time suggestions.

  • Personalization: Adapts to user preferences, writing style, and context for tailored recommendations.

  • Enhances Creativity: Suggests synonyms, alternative phrasing, and ideas to inspire better content.

  • SEO Optimization: Offers keywords, metadata, and content scoring for improved visibility.

  • Multilingual Support: Aids non-native speakers with grammar, translations, and phrasing.

  • Real-Time Feedback: Instantly corrects errors and aligns content with objectives.

Purpose and scope of the article

In this article, you will get to know about how you can build your own AI suggestions plugin for free the plugin is already there in tinymce plugins list but it’s paid. Here’s a step by step guide to build this plugin into your project:

Step 1: Setting Up Your TinyMCE React Environment

npm install --save @tinymce/tinymce-react

check whether the dependency is installed successfully or not into your package.json file

Now, import the Editor component into your react app

Complete the basic setup for the editor →

import React from "react";
import { Editor } from "@tinymce/tinymce-react";

const TinyMCEEditor = () => {
  const handleEditorChange = (content, editor) => {
    console.log("Content was updated:", content);
  };

  return (
    <Editor
      apiKey="your-tinymce-api-key" // Replace with your TinyMCE API key
      initialValue="<p>This is the initial content of the editor</p>"
      init={{
        height: 500,
        menubar: false,
        plugins: [
          'advlist autolink lists link image charmap print preview anchor',
          'searchreplace visualblocks code fullscreen',
          'insertdatetime media table paste code help wordcount'
        ],
        toolbar:
          'undo redo | formatselect | bold italic backcolor | \
          alignleft aligncenter alignright alignjustify | \
          bullist numlist outdent indent | removeformat | help'
      }}
      onEditorChange={handleEditorChange}
    />
  );
};

export default TinyMCEEditor;

Step 2: Create an input component where the user will send their prompt to the AI

import React, { useState, useEffect } from "react";
import {
  connectWithUserId,
  socket,
} from "../../websocket/getAutocompleteSuggestion";
import { useForm } from "react-hook-form";
import { useSelector } from "react-redux";


const AiHelper = ({editorInstance}) => {
  const {
    register,
    handleSubmit,
    setValue,
    formState: { isSubmitting },
  } = useForm();
  const [suggestion, setSuggestion] = useState("");
  const [disabled, setDisabled] = useState(false);
  const [abortBtnDisable, setAbortButtonDisable] = useState(true);
  const [currentPrompt, setCurrentPrompt] = useState({});
  const [loading, setLoading] = useState(false)
  const userData = useSelector((state) => state.auth.userData);
  const suggestionContainer = useRef(null);

  const submit = (data) => {
    setLoading(true)
    setCurrentPrompt(data);
    socket.emit("prompt", data.prompt);
    setSuggestion("");
    setValue("prompt", "");
  };

  const handleAbort = () => {
    socket.emit("abort", true);
  };

  const handleTryAgain = () => {
    console.log("currentPrompt ", currentPrompt);
    submit(currentPrompt);
  };

  const handleInsert = () => {
    const data = document.querySelector(".suggestion").innerHTML;
    if (editorInstance) {
      editorInstance.insertContent(data);
    }
    setIsActive(false);
  };

  useEffect(() => {
    connectWithUserId(userData.$id);
  }, [userData]);

  useEffect(() => {
    socket.on("suggestion", (data) => {
      setLoading(false)
      setSuggestion((prev) => prev + data);
      setDisabled(true);
      setAbortButtonDisable(false);
    });
    socket.on("complete", (data) => {
      if (data) {
        setDisabled(false);
        setAbortButtonDisable(true);
      }
    });
    const container = suggestionContainer.current;
    if (container) {
      container.scrollTop = container.scrollHeight;
    }
    return () => {
      socket.off("suggestion");
      socket.off("complete");
    };
  }, [suggestion]);

  return (
    <div className="w-full bg-white rounded-lg px-4 py-2 absolute z-10 md:bottom-14 bottom-72 md:left-10 space-y-2 drop-shadow-[0_0_24px_rgba(8,23,53,0.16)]">
      {
        loading && <div className="absolute w-full h-full bg-gray-300 opacity-80 flex justify-center items-center rounded-lg left-0 top-0 overflow-hidden space-x-1">
        <span className="block w-2 h-2 bg-black rounded-full animate-bounce     "></span>
        <span className="block w-2 h-2 bg-black rounded-full animate-bounce     delay-200"></span>
        <span className="block w-2 h-2 bg-black rounded-full animate-bounce     delay-500"></span>
      </div>
      }

      <div className="flex flex-col justify-between">
        <div className="flex justify-between rounded-md mb-2">
          <h1 className="text-xl">AI Assistant</h1>
          <Button
            className="w-8 rounded-md py-1 bg-transparent hover:bg-slate-200"
            onClick={(e) => {
              e.preventDefault();
              setIsActive(false);
            }}
          >
            <i class="fa-solid fa-xmark text-black text-lg"></i>
          </Button>
        </div>
        {suggestion && (
          <>
            <div
              ref={suggestionContainer}
              className=" bg-white mt-2 p-4 max-h-60 overflow-y-scroll border border-gray-200 rounded-md "
            >
              <ReactMarkdown className="prose text-left suggestion">
                {suggestion}
              </ReactMarkdown>
            </div>
            <div className=" flex gap-2 mt-2">
              <Button
                className={`px-4 py-2 font-bold rounded-md text-sm ${
                  disabled
                    ? "bg-blue-700 text-opacity-40 cursor-not-allowed"
                    : "hover:bg-blue-700"
                }`}
                disabled={disabled}
                onClick={handleInsert}
              >
                Insert
              </Button>
              <Button
                className={`px-4 py-2 font-bold rounded-md hover:bg-gray-200 text-sm ${
                  disabled ? "cursor-not-allowed bg-gray-200" : "bg-gray-300"
                }`}
                textColor={disabled ? "text-gray-400" : "text-slate-800"}
                disabled={disabled}
                onClick={handleTryAgain}
              >
                Try again
              </Button>
              <Button
                className={`px-4 py-2 font-bold rounded-md hover:bg-gray-200 text-sm ${
                  abortBtnDisable
                    ? "cursor-not-allowed bg-gray-200"
                    : "bg-gray-300"
                }`}
                textColor={abortBtnDisable ? "text-gray-400" : "text-slate-800"}
                onClick={handleAbort}
                disabled={abortBtnDisable}
              >
                Stop
              </Button>
            </div>
          </>
        )}
      </div>
      <form className="w-full flex gap-2" onSubmit={handleSubmit(submit)}>
        <Input
          placeholder="Ask AI to edit or generate"
          {...register("prompt", {
            required: { value: true },
            minLength: { value: 4 },
          })}
          className={`focus:border-blue-500 ${
            disabled && "cursor-not-allowed"
          }`}
          disabled={disabled}
        />
        <Button
          className={`w-10 rounded-md text-2xl ${
            disabled && "cursor-not-allowed"
          }`}
          type="submit"
          disabled={isSubmitting || disabled}
        ></Button>
      </form>
    </div>
  );
};

export default AiHelper;

Step 3: Add the custom plugin icon and action

<Editor
     apiKey={conf.rteapikey}
     onInit={handleEditorInit}
     value={value || defaultValue}
     init={{
     height: 500,
     menubar: true,
     plugins: [
          'advlist autolink lists link image charmap print preview anchor',
          'searchreplace visualblocks code fullscreen',
          'insertdatetime media table paste code help wordcount'
      ],
      toolbar:
           "undo redo | blocks | " +
           "AI | bold italic forecolor | alignleft aligncenter " +
           "alignright alignjustify | bullist numlist outdent indent | " +
           "removeformat | help",
           content_style:
              "body { font-family:Helvetica,Arial,sans-serif; font-size:14px }",
           }}
/>
  const handleEditorInit = (evt, editor) => {
    editor.ui.registry.addButton('AI', {
      icon: 'AI',
      tooltip: 'Ask AI',
      onAction: () => setIsActive(true)
    });
  };

Yeah the icon is now added to the toolbar, now when the user clicks on it the setIsActive will be true and the prompt box will appear

{isActive && <AiHelper editorInstance={editorInstance} />}

Step 4: Setup the socket.io :

npm install socket.io
import { io } from "socket.io-client"
import conf from "../conf/conf";

// Initialize the socket without connecting
const socket = io('http://localhost:3000', {
    autoConnect: false, // Prevent automatic connection
});

// Function to connect with the query parameter
function connectWithUserId(userId) {
    socket.io.opts.query = { userId }; // Set the query parameters dynamically
    socket.connect(); // Manually initiate the connection
  }

export {socket, connectWithUserId}

Step 5: Setup the server and install the dependencies :

  "dependencies": {
    "dotenv": "^16.4.7",
    "express": "^4.21.2",
    "groq-sdk": "^0.9.1",
    "socket.io": "^4.8.1"
  },

Setup the socket server:

import express from 'express'
import { createServer } from 'http'
import { Server } from 'socket.io'
import getGroqChatCompletion from './getGroqChatCompletion.js'
import dotenv from 'dotenv'


const app = express()
const server = createServer(app)

const io = new Server(server, {
  cors: {origin: process.env.CORS_ORIGIN}
})

dotenv.config({
  path: './.env'
})


const PORT = process.env.PORT || 8000


server.listen(PORT, () => {
    console.log(`Socket.IO server running on http://localhost:${PORT}`);
});


io.on("connection", (socket) => {
    const userId = JSON.stringify(socket.handshake.query)
    const userRoom = `user:${userId}`
    socket.join(userRoom);
    console.log(`User connected: ${userId}`);

    let abortFlag = false

  socket.on("abort", (data) => {
    abortFlag = data       
  })

  socket.on("prompt", async (prompt) => {
    if (prompt) {
      abortFlag = false
      const suggestion = await getGroqChatCompletion(prompt);

      for await (const chunk of suggestion) {

        if (abortFlag)  break;

        if (chunk && chunk.choices && chunk.choices[0].delta) {
          const text = chunk.choices[0].delta;
          if (text.content) {
            io.to(userRoom).emit("suggestion", text.content);
          }
        } else {
          console.error("Unexpected chunk format:", chunk);
        }
      }

      io.to(userRoom).emit("complete", true);
    }

  });
});

Now set the Groq SDK to get the suggestions :

import Groq from "groq-sdk";

const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });


export default async function getGroqChatCompletion(prompt) {
  try {
    const response = await groq.chat.completions.create({
      messages: [
        {
          "role": "system",
          "content": "if the user prompt is related to blog or writing articles on any topic write it professionally with proper headings and pointers like a actual writer"
        },
        {
          role: "user",
          content: prompt,
        },
      ],
      model: "llama-3.3-70b-versatile",
      max_tokens: 1024,
      stream: true,
    });

    const reader = response.iterator();

    return reader

  } catch (error) {
    console.log("ERROR:: getGroqChatCompletion :: ", error);
    return "FAILED TO GENERATE"
  }

}

So your custom plugin is ready now you can generate suggestions from AI just like the paid plugin does

Here’s my blogging platform project where I have implemented this feature

https://codebeetles-blog.vercel.app