When I started building my own Content Management System (CMS), one of the first things I needed was a solid "What You See Is What You Get" (WYSIWYG) editor. Having a WYSIWYG editor allows me to focus on creating content without coding every single page from scratch. It’s truly the heart of any CMS. My original plan was to avoid using any third-party libraries or frameworks in this project and build everything from scratch.
I even attempted to create my own editor initially, but I quickly realized just how complex this was going to be. From handling text formatting to adding images and links, it became clear I was in over my head if I wanted it to function well and look polished. So, I made an exception in this case and started looking at existing WYSIWYG editors. That’s when I stumbled upon TinyMCE.
TinyMCE stood out because it’s easy to use, highly customizable, and offers a self-hosted version that I can modify to my needs without relying on an API. Most importantly, the base version is free, with optional premium features if you want more functionality. TinyMCE offers different subscription tiers with expanded features, but if you're thrifty and have some coding knowledge, you can add many of these features yourself. One of the first modifications I added was the ability to upload images within the editor.
My custom upload script allows me to upload images, which are then resized, converted to WebP format, and optimized by deleting the original image file. This setup worked great for over a year. But one day, while browsing Wisedocks on my phone, I noticed that images weren’t clickable, which was a problem for readability on smaller screens. Users should be able to click on an image to view it full-screen, especially when details might be hard to read on mobile devices.
So, I set out to make all images clickable automatically on the backend, without requiring any extra steps. This turned out to be trickier than expected. After a lot of trial and error, I turned to ChatGPT for help. The solution was a JavaScript function that wraps each image with a link to its source file, making it clickable. If you’re a developer and want to add this functionality to your own TinyMCE setup, here’s what the function looks like:
// Automatically wrap uploaded images in a link
editor.on('BeforeSetContent', function (e) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = e.content;
tempDiv.querySelectorAll('img').forEach(image => {
const src = image.getAttribute('src');
if (src && !image.parentNode.matches('a')) { // Check if the image isn't already wrapped
const link = document.createElement('a');
link.setAttribute('href', src);
link.setAttribute('target', '_blank'); // Opens link in new tab
image.parentNode.insertBefore(link, image);
link.appendChild(image);
}
});
e.content = tempDiv.innerHTML;
});
This code automatically wraps all images in <a> tags, making them clickable and opening in a new tab. I added it to the setup: function (editor) in my TinyMCE configuration, and it’s been working flawlessly.
Since images can slow down page load times, I decided to add lazy loading to all images within the editor. Lazy loading defers the loading of images until they’re about to scroll into view, which can significantly improve performance. With TinyMCE, I implemented a helper function that adds loading="lazy" to each image.
Here’s the full TinyMCE setup, including lazy loading and clickable images:
setup: function (editor) {
// Add loading="lazy" when an image is inserted or modified
editor.on('SetContent', function (e) {
addLazyLoading(editor);
});
editor.on('Change', function () {
addLazyLoading(editor);
});
// Modify the HTML before saving to ensure all images have loading="lazy"
editor.on('SaveContent', function (e) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = e.content;
tempDiv.querySelectorAll('img').forEach(image => {
if (!image.hasAttribute('loading')) {
image.setAttribute('loading', 'lazy');
}
});
e.content = tempDiv.innerHTML;
});
// Automatically wrap uploaded images in a link
editor.on('BeforeSetContent', function (e) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = e.content;
tempDiv.querySelectorAll('img').forEach(image => {
const src = image.getAttribute('src');
if (src && !image.parentNode.matches('a')) { // Check if the image isn't already wrapped
const link = document.createElement('a');
link.setAttribute('href', src);
link.setAttribute('target', '_blank'); // Opens link in new tab
image.parentNode.insertBefore(link, image);
link.appendChild(image);
}
});
e.content = tempDiv.innerHTML;
});
}
One limitation is that this setup only affects new uploads, meaning older posts are still stuck with unclickable images. Rather than creating a script to update my entire database, I’ve put this feature in the TinyMCE setup for editing posts, so I can manually update older content as needed. Mostly because it's not the end of the world as my older posts aren't really relevant enough to constitute messing up my database if I screw it up.
When I set up TinyMCE, I actually created two separate configurations: one for new posts and another for editing posts. In hindsight, I could have used if statements to manage different functionalities within a single setup, but I’ve decided to leave things as they are—if it’s not broken, I’ll break it some other day, right?
For anyone curious about the lazy loading function from the setup above, here it is:
// Helper function to add loading="lazy" to all images in the editor
function addLazyLoading(editor) {
const images = editor.getDoc().querySelectorAll('img');
images.forEach(image => {
if (!image.hasAttribute('loading')) {
image.setAttribute('loading', 'lazy');
}
});
}
Now, here’s why it’s important to keep this function outside the TinyMCE initializer itself. The addLazyLoading function is meant to be a reusable helper that TinyMCE can call whenever it needs to add loading="lazy" to images. Placing it outside the TinyMCE initializer keeps your code modular and ensures TinyMCE doesn’t reinitialize addLazyLoading each time it loads, which could create unnecessary duplicates and potential memory issues, especially with dynamically loaded editors.
Keeping addLazyLoading outside the TinyMCE configuration makes it accessible from anywhere on your page, including other JavaScript files. This way, the function is flexible and reusable across the site. You could place addLazyLoading inside a DOMContentLoaded listener anywhere on the page—or even in an external JavaScript file—so long as it’s called after the page loads. It keeps things simple and ready to use wherever you might need it.
This function also checks if lazy loading is already applied to an image and only adds it if it’s missing. That makes it more robust and avoids unnecessary updates.
Another modification I made was to build a simple internal link button. It’s a straightforward script that adds a button to the toolbar, so when I highlight text in the editor, it runs a search in the database to find that word elsewhere on my blog and automatically adds an internal link for me. You could easily adapt the script to search an entire post and auto-link every matching word, but I prefer having control over which words become links.
The setup is pretty simple. It’s a JavaScript function that calls a PHP file, which searches the database and returns results in JSON format. Nothing fancy—but effective. Here’s the script that makes the call (with paths changed for security), so you can get an idea:
tinymce.PluginManager.add('internal_linking', function(editor, url) {
editor.ui.registry.addButton('internal_linking_button', {
text: 'Find Internal Link',
onAction: function () {
var selectedText = editor.selection.getContent({ format: 'text' });
var keyword = prompt("Enter keyword to link:", selectedText);
if (keyword) {
fetch('root/folder/to/internal_link.php?keyword=' + encodeURIComponent(keyword))
.then(response => response.text()) // First, get the response as text
.then(text => {
try {
var data = JSON.parse(text); // Then try to parse it as JSON
if (data.url) {
editor.insertContent('<a href="' + data.url + '">' + selectedText + '</a>');
} else if (data.error) {
alert(data.error); // Show any error returned by the server
} else {
alert('No matching link found for the keyword: ' + keyword);
}
} catch (error) {
console.error('Error parsing JSON:', error);
alert('An error occurred while processing the request.');
}
})
.catch(error => {
console.error('Fetch error:', error);
alert('An error occurred while fetching the link.');
});
}
}
});
});
This setup makes adding internal links a breeze, and I use it on just about every post I make. It’s one of those tools that has quickly become essential for keeping my content connected while also improving SEO.
My setup is still pretty simple and constantly evolving. Every time I come up with new ideas, I consider them carefully and only add features I’ll use in nearly every post. I’m not interested in all the bells and whistles I could implement—keeping the interface clean and straightforward makes for a much more enjoyable workspace. That’s one of the reasons I appreciate TinyMCE: you can make the setup as simple or as complex as you want.
TinyMCE is a tool I use almost every day to create content across all of my sites. It’s a workhorse that sometimes gets taken for granted, like a trusty hammer—it just keeps pounding away, helping me get the job done.