The extension is an advanced note-taking tool that allows users to take notes, and therefore, one of its key features is to support rich text editing.
Initially, I used TinyMCE as the rich text editor (RTE) solution for the extension. The bundle size is quite large, 10mb, but it works perfectly fine in version 1.x of the extension. However, when I started adding more features and scaling up the project, I found that TinyMCE was not the best choice.
It leads me to disable the rich text editor in the extension version 2.x and look for a better solution.
I were introduced to Lexical a couple of months ago and quickly fall in love with it. I had spent two weeks to integrate Lexical into the extension just to find out that it has the same issue to TinyMCE: they don't support Shadow DOM very well, which is crucial for Chrome extensions.
It was quite disappointed because of the wrong investment (two weeks of my leisure time). On the other hand, I learned a lot about today rich text editor solutions and how powerful they are.
Finally, I found TipTap. Tt works perfectly fine with the extension version 3.x. The bundle size now is around 4.2mb, or 1mb after zipped. It is lightweight, simple, and Shadow DOM supported.
Below is the comparison of the three rich text editors and a simple guide to integrate them into a Chrome extension written by Svelte.
2. TinyMCE
The Ultra Notes is free to use, non-commercial, at the time this post is written, so I have no problem with the TinyMCE license. But it is worth to learn a little bit about licenses of any open-source solutions you are using in your projects.
In this case, it is GPLv2 for TinyMCE, v.7.3.0, at the time of writing this post.
info
The key features of GPLv2 are:
You need to open your source code if you distribute your software, and maintain the same GPLv2 license for your software.
You can use the software for commercial purposes, but you need to follow the license terms, and retain the copyright notice of the GPLv2 software used in your software.
For example, you may see the TinyMCE editor embedded in your software display a message like "Powered by TinyMCE" or "TinyMCE" in the footer of the editor. This is to retain the copyright notice of the software used in your software. And you are not allowed to hide it.
I have no plan to publish the source code of the extension, so the GPLv2 TinyMCE doesn't work for me.
Certainly, I can pay or subscribe to the TinyMCE premium version to remove the restriction, but it is not worth for a small and free project like Ultra Notes.
Integrate TinyMCE to a Chrome Extension (Svelte)
The official guide to create a TinyMCE Svelte Wrapper is available at https://github.com/tinymce/tinymce-svelte, that can be used for any Svelte web application.
However, to use it in a Chrome extension, we need to make some additional changes.
// Firstly, we need to install the TinyMCE package from npm in
// packages.json
"devDependencies": {
"tinymce": "^7.3.0",
}
The advantage of using TinyMCE is everything is cooked for you, and you can use it right away. However, to use it, we must include the whole bundle of TinyMCE, as a static copy, in the extension.
In Svelte, I copy the artifacts of TinyMCE to the public folder, load the scripts and css, and then initialize the editor in a wrapper (Svelte component).
// vite.config.ts
// ... other imports
import { viteStaticCopy } from 'vite-plugin-static-copy'
const config: UserConfig = {
plugins: [
// ... other configs
viteStaticCopy({
targets: [
{
src: 'node_modules/tinymce/*',
dest: 'tinymce' // copy to the dist/tinymce folder at the build time
}
]
}),
],
// ... other configs
};
export default config;
Don't forget to allow tinymce to be accessible in a Chrome extension by adding the following line to the manifest.json file:
Here we custom the TinyMCE Svelte wrapper component, that allows the editor to change its Light/Dark theme and to load the initial content from outside.
<!--
This File is a copy of the package @tinymce/tinymce-svelte
1. To custom it to re-init when theme config (skin_url) is changed
-->
<script lang="ts" context="module">
const uuid = (prefix: string): string => {
return prefix + '_' + Math.floor(Math.random() * 1000000000) + String(Date.now());
};
const createScriptLoader = () => {
let state = {
listeners: [],
scriptId: uuid('tiny-script'),
scriptLoaded: false,
injected: false
};
const injectScript = (scriptId: string, doc: Document, url: string, cb: () => void) => {
state.injected = true;
const script = doc.createElement('script');
script.referrerPolicy = 'origin';
script.type = 'application/javascript';
script.src = url;
script.onload = () => { cb();}
if (doc.head) doc.head.appendChild(script);
};
const load = (doc: Document, url: string, callback: () => void) => {
if (state.scriptLoaded) {
callback();
} else {
state.listeners.push(callback);
// check we can access doc
if (!state.injected) {
injectScript(state.scriptId, doc, url, () => {
state.listeners.forEach((fn) => fn());
state.scriptLoaded = true;
});
}
}
};
return {
load
}
};
let scriptLoader = createScriptLoader();
</script>
<script lang="ts">
import { onMount, createEventDispatcher, onDestroy } from 'svelte';
import { bindHandlers } from './Utils';
export let id: string = uuid('tinymce-svelte'); // default values
export let inline: boolean | undefined = undefined;
export let disabled: boolean = false;
export let apiKey: string = 'no-api-key';
export let channel: string = '6';
export let scriptSrc: string = undefined;
export let conf: any = {};
export let modelEvents: string = 'change input undo redo';
export let value: string = '';
export let text: string = '';
export let cssClass: string = 'tinymce-wrapper';
let container: HTMLElement;
let element: HTMLElement;
let editorRef: any;
let lastVal = value;
let disablindCache = disabled;
const dispatch = createEventDispatcher();
$: {
if (editorRef && lastVal !== value) {
editorRef.setContent(value);
text = editorRef.getContent({format: 'text'});
lastVal = value;
}
if (editorRef && disabled !== disablindCache) {
disablindCache = disabled;
if (typeof editorRef.mode?.set === 'function') {
editorRef.mode.set(disabled ? 'readonly' : 'design');
} else {
editorRef.setMode(disabled ? 'readonly' : 'design');
}
}
}
// INFO: Custom code to remove the current instance and re-init with new config
$: conf && getTinymce() !== null && getTinymce()?.remove(editorRef) && init();
const getTinymce = () => {
const getSink = () => {
return typeof window !== 'undefined' ? window : global;
};
const sink = getSink();
return sink?.tinymce ?? null;
};
const init = () => {
if (!element) {
return;
}
element.style.visibility = '';
const finalInit = {
...conf,
target: element,
inline: inline !== undefined ? inline : conf.inline !== undefined ? conf.inline : false,
readonly: disabled,
setup: (editor: any) => {
editorRef = editor;
editor.on('init', () => {
editor.setContent(value);
// bind model events
editor.on(modelEvents, () => {
lastVal = editor.getContent();
if (lastVal !== value) {
value = lastVal;
text = editor.getContent({format: 'text'});
}
});
});
bindHandlers(editor, dispatch);
if (typeof conf.setup === 'function') {
conf.setup(editor);
}
},
};
getTinymce()?.init(finalInit);
};
onMount(() => {
if (getTinymce() !== null) {
init();
} else {
// const script = scriptSrc ? scriptSrc : `https://cdn.tiny.cloud/1/${apiKey}/tinymce/${channel}/tinymce.min.js`;
scriptLoader.load(container.ownerDocument, scriptSrc, () => {
init();
});
}
});
onDestroy(() => {
if (editorRef) {
getTinymce()?.remove(editorRef);
}
});
</script>
<div bind:this={container} class={cssClass}>
{#if inline}
<div id={id} bind:this={element}></div>
{:else}
<textarea id={id} bind:this={element} style="visibility:hidden"></textarea>
{/if}
</div>
And finally use it in the Chrome extension popup or sidePanel page.
Besides the drawbacks. of the large bundle size, the main reason why TinyMCE doesn't fit in a Chrome extension is that it doesn't support Shadow DOM very well.
If you are building a Chrome extension that works as an isolated web application, you don't face this issue. For example:
However, the problem arises when you want to make your extension work with the webpage's content, embedding a UI into the webpage, and therefore improve the user experience.
For example, in my use case, I want the Ultra Notes to popup on any webpage, and users can drag and drop it to any position on the webpage.
Using this technique, users can take notes while reading an article, watching a video, or browsing a website, without switching tabs or clicking on the extension icon, which will be closed when the popup loses focus.
I have to admit that Lexical is a great rich text editor, and I love it. It is simple, lightweight, maintained by a big name (Meta, formerly Facebook), and offers the MIT license, which is very friendly for any project.
Users of software using an MIT License are permitted to use, copy, modify, merge publish, distribute, sublicense and sell copies of the software.
Ref. https://pitt.libguides.com/openlicensing/MIT
But it doesn't mean that Lexical is the best choice for all projects.
In the use case of inject content-script into a webpage as I mentioned above, Lexical has the same issue as TinyMCE: it doesn't support Shadow DOM very well.
I play around with the Lexical basic functions and features, and it seems to work fine as a headless editor, pure javascript in a Svelte component (Chrome extension).
I didn't check carefully with the Shadow DOM, but I started to port (rewrite components) the svelte-lexical project to my project.
I copy and modify components, styles, and scripts to fit my project.
I make the component isolated in a library, with intention to reuse each component independently like the shadcn-svelte project.
It took me two weeks to port the Lexical to my project to realize that Selection API doesn't work with the Shadow DOM.
Basically, Lexical partially work with Shadow DOM. But it is nothing if the Selection API doesn't work, because most of everything we do with a rich text editor is built on a Selection.
note
The svelte-lexical make components depend on the global CSS, assets (icons) and other components.
To use its component, it requires us to copy the whole project to our project, and then modify the components to fit our project.
So my idea is rewriting the components, styles, and scripts to make them independent, and then simply import only components that we need.
The idea is similar to the shadcn-svelte project.
3. Tiptap Editor
And then I found Tiptap, a great rich text editor that works perfectly fine with the Shadow DOM.
And MIT license, available for any project.
Tiptap is a headless editor, similar to Lexical, that we need to build the UI by ourselves.
It means that following the official guide at https://tiptap.dev/docs/editor/getting-started/install/svelte, what you receive is a very basic plain editable area.
To make it a rich text editor, we need to build the UI, toolbar, and other features by ourselves.
There are many projects that build on top of Tiptap, that can reduce your time to build the UI, for example, https://mantine.dev/x/tiptap/
Make sure that you check the license of the project you are using.
Tiptap works with the Shadow DOM. This makes it a perfect choice for Chrome extensions or any project that utilize Shadow DOM.
4. Conclusion
In summary, this article discusses three open source Rich Text Editors (RTE) solutions, but there are many ones available, and each has its own pros and drawbacks, that we choose the right one built on our project requirements.
The journey began when I sought a straightforward note-taking app for my everyday needs. Several months ago, I experimented with various online note-taking platforms for work. However, my boss, upon
[Regular Expression (RegEx)](https://www.geeksforgeeks.org/write-regular-expressions/) is a powerful tool for searching and replacing text
that I have implemented in the X-Word Replacer since its fi
## Background
There are many possible ways to build a Chrome extension (or Firefox addon, or web extension in general).
* The simplest way is to use vanilla JavaScript, HTML, and CSS following the o