...
editor/
theming/
... list of theming
icons.js
editor.js
index.js
...
TL;DR; You can check this code example in action and play with the code in real-time by accessing this StackBlitz with the example.! π
Introduction
A design system is a collection of reusable components, having clear standards that can be assembled together to compose any number of applications.
There are several design systems available, but today I'll be focusing at Material Design. Material Design is a Design System developed in 2014 by Google and is very popular for web and mobile applications. It has some inspiration by the physical world and its textures, including how they reflect light and cast shadows. Material surfaces reimagine the mediums of paper and ink.
At this time I'll be fosucing at Material-UI, a package that enable building your own design system using it as a foundation, or start with Material Design directly in React apps. However, there are several other ways to integrate material design in your app, in case you're not using React.
Styling your WYSIWYG editor
As soon as you're creating your own Design System you might face some common components to apply some specific styling, such as Graphs, dashboards or Editors. For the case of it, you should first choose the library you should pick, having some constraints that might be relevant for your application.
In case of "What You See Is What You Get" - A.K.A. WYSIWYG - Editors, which are text editors that gives the user the ability of edit some text, add images, align content, add links and more, one of the most common editors is TinyMCE.
Choose the best tool for your app is something crucial. I strongly recommend you to check all the pros and cons before choose for a library
Usually, it comes with some common features and styles. Also it has a powerful setup since it's a VanillaJS package. It also has a wrapper in React in order to help your React app. But how can you create a specific theme for your app? What if you need to add some specific feature based on your application needs? Here comes some bad news... π
At the moment I'm writing this post the React package is quite simple. It has some common setup for bootstrap and that's all. In addition of the setup, it supports some of the functions that are quite relevant for editor's manipulation, but it doesn't have a good integration via React, neither for Material-UI package.
On the other hand, there are some great news. Since it's a wrapper, you can always go to their documentation - and they have a massive documentation across their own docs and other websites, blog posts and StackOverflow answers. So here it goes some tips to get you covered! π
Defining a component architecture for your Editor
Firstly, let's create a component's architecture for the common editor requirements. Initially, it needs to cover few things such as:
- icons: a place for editor icons, in case you want to create new toolbar actions or even override the default icons
- editor: Editor's initialisation and default configuration
- theming: styles for a specific theme for your editor. Extremely useful in case of shared components across multiple applications, brands, etc.
Having these constraints in mind, the folder structure will be something like this.
Creating a Material UI component to wrap the theme of your Editor
Now let's write our components. The first one will have all the defaults for editor's initialisation, initial text value and a callback to be called when the editor content has some changes.
// This file lives in `editor/editor.js` | |
import React, { useEffect, useState } from "react"; | |
import { Editor as TinyMCEEditor } from "@tinymce/tinymce-react"; | |
// Default Editor configuration to be mixed with the ones passed | |
// from the consumers | |
const defaults = { | |
init: { | |
width: "100%", | |
height: "300", | |
menubar: false, | |
branding: false, | |
plugins: ["link image", "table paste"], | |
toolbar: "undo redo | bold italic | alignleft aligncenter alignright" | |
} | |
}; | |
export const Editor = ({ onEditorChange, value, init, id }) => { | |
const [configHasChanged, setRerenderEditor] = useState(false); | |
const [internalInit, setInternalInit] = useState(init); | |
// Unfortunately, the easiest way to make sure the editor will be updated and respect React lifecycle | |
// is by forcing a remount in case of configuration changes. | |
// Since this won't happen that often, it shouldn't be a problem in your app | |
const forceEditorRerender = () => { | |
setRerenderEditor(true); | |
requestAnimationFrame(() => setRerenderEditor(false)); | |
}; | |
useEffect(() => { | |
if (JSON.stringify(init) !== JSON.stringify(internalInit)) { | |
setInternalInit(init); | |
forceEditorRerender(); | |
} | |
}, [init]); | |
return configHasChanged ? null : ( | |
<TinyMCEEditor | |
id={id} | |
init={{ | |
...defaults.init, | |
...internalInit, | |
id, | |
}} | |
value={value} | |
onEditorChange={onEditorChange} | |
/> | |
); | |
}; |
After that, the second component will wrap all the styling structure of your editor inside your application. For that we can use withStyles
High-Order Component - HOC - helper wrapping a common component (in this case I'm using Grid
, but feel free to use the one that makes more sense for your project).
Bear in mind that WYSiWYG editors are often with default classes and styles. So we can reuse the classes and override their styles. This is an example sharing how to increase toolbar styles, changing colors, border-radius, and more. Another good point of that: there are also some changes on the last toolbar list of items. This will be interesting to make sure the last list of items are always on the right, it doesn't matter what.
So this will be the component wrapping the theme styles of your editor. You can always create other wrappers and define the usage on the consumer side.
// This file will live in `editor/themes/minimal.js` | |
import React from "react"; | |
import { Grid, withStyles } from "@material-ui/core"; | |
export const Minimal = withStyles(theme => ({ | |
root: { | |
"& .tox .tox-tbtn svg": { | |
fill: theme.palette.grey["500"] | |
}, | |
"& .tox .tox-tbtn:hover svg": { | |
fill: theme.palette.common.white | |
}, | |
"& .tox .tox-tbtn": { | |
transition: "background-color 0.3s ease" | |
}, | |
"& .tox .tox-tbtn:hover, & .tox .tox-tbtn:active, & .tox .tox-tbtn:focus": { | |
backgroundColor: theme.palette.grey["500"], | |
cursor: "pointer" | |
}, | |
"& .tox-tinymce": { | |
borderRadius: theme.shape.borderRadius | |
}, | |
"& .tox .tox-toolbar__primary": { | |
backgroundColor: theme.palette.grey["100"], | |
backgroundImage: "none", | |
padding: theme.spacing(2) | |
}, | |
"& .tox .tox-edit-area__iframe": { | |
borderTop: `1px solid ${theme.palette.grey["400"]}` | |
}, | |
"& .tox:not([dir=rtl]) .tox-toolbar__group:not(:last-of-type)": { | |
border: "transparent" | |
}, | |
"& .tox-toolbar-overlord .tox-toolbar__primary .tox-toolbar__group:last-child": { | |
marginLeft: "auto" | |
} | |
} | |
}))(Grid); |
The last file to be covered in this section will have all the icons that will be used in the editor.
// This file will live in `editor/icons.js` | |
export const MAXIMIZE_ICON = | |
'<svg class="maximize" width="24" height="24" viewBox="0 0 24 24" ><path d="M3 5v4h2V5h4V3H5c-1.1 0-2 .9-2 2zm2 10H3v4c0 1.1.9 2 2 2h4v-2H5v-4zm14 4h-4v2h4c1.1 0 2-.9 2-2v-4h-2v4zm0-16h-4v2h4v4h2V5c0-1.1-.9-2-2-2z"></path></svg>'; | |
export const MINIMIZE_ICON = | |
'<svg class="minimize" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" tabindex="-1"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"></path></svg>'; | |
export const BOLD_ICON = | |
'<svg class="bold" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" tabindex="-1"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"></path></svg>'; | |
export const ITALIC_ICON = '<svg class="italic" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" tabindex="-1"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"></path></svg>' |
Having all these changes in place, this is the first result of your theme that will be used in your Editor. ππ
Creating a toolbar action for your Editor
As a next step of our Editor integration, we should also be able to create new actions inside the editor. This is relevant for your customers since it enhances the editor by empowering new features inside the editor and improving the user experience in your product.
To enable that, TinyMCE integration has a setup
function that can be passed on as component configuration. This function receives the editor instance inside the callback, giving your app the flexibility to add, update and remove items, add toolbar actions, disable specific features and more. These method are very well documented on TinyMCE official documentation.
Let's create a new case in our app to elaborate a real scenario in an app. The app will have a maximize and minimize actions.
So, when the user clicks on maximize, it will trigger an action to maximize the editor and change the icon to minimize and vice-versa. Another good point to keep in mind is that this should be configurable, not having anything coupled across the different app brands and products.
How these requirements can be covered? In our case, we will create a new prop called onSetupActions
that will receive some specific configuration for actions, icons and buttons on the consumer side.
So, at the end the component will receive these props as configuration and it will make the icons and toolbar actions available. These actions will be added and/or removed based on the toolbar configuration.
import React, { useState, useEffect } from "react"; | |
import { Button, makeStyles } from "@material-ui/core"; | |
import { | |
Editor, | |
MAXIMIZE_ICON, | |
MINIMIZE_ICON, | |
BOLD_ICON, | |
ITALIC_ICON, | |
Minimal as MinimalTheme, | |
Sea as SeaTheme | |
} from "./tinymce"; | |
const ThemeWrapper = ({ theme, children }) => { | |
return theme === "Minimal" ? ( | |
<MinimalTheme>{children}</MinimalTheme> | |
) : ( | |
<SeaTheme>{children}</SeaTheme> | |
); | |
}; | |
const useStyles = makeStyles(theme => ({ | |
buttonsWrapper: { | |
padding: theme.spacing(2, 1), | |
"& > *": { | |
margin: theme.spacing(1) | |
} | |
} | |
})); | |
const App = () => { | |
const classes = useStyles(); | |
const [editorValue, setEditorValue] = useState("Default value"); | |
const [isMaximized, setMaximize] = useState(false); | |
const [theme, setTheme] = useState("Minimal"); | |
const handleOnChange = content => { | |
setEditorValue(content); | |
}; | |
return ( | |
<ThemeWrapper theme={theme}> | |
<h1>Choose your theme</h1> | |
<p> | |
This demo shows how to create themes for TinyMCE Editor with React | |
Material-UI.{" "} | |
<a href="https://stackblitz.com/edit/tinymce-react-wrapper?file=src%2FApp.js"> | |
<b>Edit this code sample in this link</b> | |
</a> | |
</p> | |
<p> | |
You can also click on the maximize icon and check how to add iteractions | |
for your theme, such as maximize and minimize icons and toggle buttons. | |
Please check the code in <code>App.js</code> and{" "} | |
<code>tinymce/editor.js</code> files to know more about it. | |
</p> | |
<div className={classes.buttonsWrapper}> | |
{["Minimal", "Sea"].map(theme => ( | |
<Button | |
key={theme} | |
variant="contained" | |
color="primary" | |
onClick={() => setTheme(theme)} | |
> | |
{theme} | |
</Button> | |
))} | |
</div> | |
<Editor | |
// Adding dynamic ID here to make sure Stackblitz will show TinyMCE Editor all the time | |
// Otherwise, it shows textare after hot reload | |
id={`my-editor-${Date.now()}`} | |
onEditorChange={handleOnChange} | |
value={editorValue} | |
init={{ | |
// This field will change configuration | |
// In case of configuration changes, the editor is rerendered to make sure | |
// the new configuration will be updated | |
toolbar: `undo redo | bold italic | alignleft aligncenter alignright | ${ | |
isMaximized ? "minimize" : "maximize" | |
}` | |
}} | |
onSetupActions={{ | |
icons: [ | |
{ | |
name: "bold", | |
icon: BOLD_ICON | |
}, | |
{ | |
name: "italic", | |
icon: ITALIC_ICON | |
}, | |
{ | |
name: "maximize-icon", | |
icon: MAXIMIZE_ICON | |
}, | |
{ | |
name: "minimize-icon", | |
icon: MINIMIZE_ICON | |
} | |
], | |
togleButtons: [ | |
{ | |
name: "maximize", | |
icon: "maximize-icon", | |
tooltip: "Maximize Icon", | |
onAction: () => { | |
setMaximize(true); | |
} | |
}, | |
{ | |
name: "minimize", | |
icon: "minimize-icon", | |
tooltip: "Minimize Icon", | |
onAction: () => { | |
setMaximize(false); | |
} | |
} | |
] | |
}} | |
/> | |
</ThemeWrapper> | |
); | |
}; | |
export default App; |
This configuration passed via onSetupActions
will be used on the Editor component by calling editor.ui.registry.addIcon
in case of adding and/overriding an icon in your application and editor.ui.registry.addToggleButton
if you the prop has some configuration for add a new toolbar action.
import React, { useEffect, useState } from "react"; | |
import { Editor as TinyMCEEditor } from "@tinymce/tinymce-react"; | |
const defaults = { | |
init: { | |
width: "100%", | |
height: "300", | |
menubar: false, | |
branding: false, | |
plugins: ["link image", "table paste"], | |
toolbar: "undo redo | bold italic | alignleft aligncenter alignright" | |
} | |
}; | |
export const Editor = ({ onEditorChange, value, init, id, onSetupActions }) => { | |
const [configHasChanged, setRerenderEditor] = useState(false); | |
const [internalInit, setInternalInit] = useState(init); | |
const setup = editor => { | |
// Adding icons | |
if (onSetupActions.icons) { | |
onSetupActions.icons.forEach(item => { | |
editor.ui.registry.addIcon(item.name, item.icon); | |
}); | |
} | |
// Adding toggle buttons | |
if (onSetupActions.togleButtons) { | |
onSetupActions.togleButtons.forEach(item => { | |
editor.ui.registry.addToggleButton(item.name, { | |
icon: item.icon, | |
tooltip: item.tooltip, | |
onAction: item.onAction | |
}); | |
}); | |
} | |
}; | |
// Unfortunately, the easiest way to make sure the editor will be updated and respect React lifecycle | |
// is by forcing a remount in case of configuration changes. | |
// Since this won't happen that often, it shouldn't be a problem in your app | |
const forceEditorRerender = () => { | |
setRerenderEditor(true); | |
requestAnimationFrame(() => setRerenderEditor(false)); | |
}; | |
useEffect(() => { | |
if (JSON.stringify(init) !== JSON.stringify(internalInit)) { | |
setInternalInit(init); | |
forceEditorRerender(); | |
} | |
}, [init]); | |
return configHasChanged ? null : ( | |
<TinyMCEEditor | |
id={id} | |
init={{ | |
...defaults.init, | |
...internalInit, | |
id, | |
setup, | |
}} | |
value={value} | |
onEditorChange={onEditorChange} | |
/> | |
); | |
}; |
As you can see, the component is now flexible, easy to maintain and following the design and feature specs as we defiined. All set and done! β
The results
As a final result, your app will have a single place with the source of your WYSIWYG editor code: styles, features, wrappers and icons all in a single. Also, it has all the flexibility in case you might come up with new requirements π
Feel free to check the example in real time, change the code, and more on the StackBlitz code sample.
Material-UI + TinyMCE = π€©
Thatβs all forΒ now
I hope you enjoyed this reading as much as I enjoyed writing it. Thank you so much for reading until the end and see you soon!
ππππππ