@webselect/editor
v6.1.0
Published
A text editor UI built on top of TipTap
Downloads
37
Readme
Webselect text editor
The editor is a UI built on top of TipTap, which itself is a high-level abstraction of ProseMirror.
Table of Contents
- Installation
- Usage
Installation
npm i @webselect/editorUsage
ESM environments
This can be implemented natively in the browser:
<link rel="stylesheet" href="./path/to/editor.css">
<script type="module">
import { Editor } from './path/to/editor.mjs';
const editor = new Editor({ ... });
</script>Or as part of a build pipeline / bundler:
// Optional depending on setup.
// Explained in the "Styling" section.
import './path/to/editor.css';
import { Editor } from '@webselect/editor';
const editor = new Editor({ ... });Legacy environments
This works in a similar way to libraries such as jQuery, which expose a global variable. In this case that variable is WebselectEditor.
<script src="./path/to/editor.browser.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const editor = new WebselectEditor.Editor({ ... });
});
</script>Creating a new Editor
const editor = new Editor({
element: document.querySelector('.editor'),
});The Editor constructor takes a single options object:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | element | Element | - | The Element that the Editor will be injected into. |
| ❌ | editorClasses | string | - | Additional classes to be added to the TipTap editor. |
| ❌ | controls | string[] | ["bold", "italic", "underline", "link", "textColour", "fontFamily", "fontSize", "heading", "orderedList", "bulletList", "alignLeft", "alignMiddle", "alignRight", "alignJustify", "customStyles", "clearFormatting"] | Defining controls. |
| ❌ | content | string | - | Initial HTML content to populate the Editor with on instantiation. |
| ❌ | swatches | string[] | - | Swatches that will be shown in the colour picker. |
| ❌ | fontFamilies | FontFamilyOption[] | - | Font families. |
| ❌ | fontSizes | FontSizeOptions | - | Font sizes. |
| ❌ | fontWeights | FontWeightOption[] | - | Font weights. |
| ❌ | headings | HeadingOptions | - | Headings. |
| ❌ | textFormatting | TextFormattingOptions | - | Text formatting. |
| ❌ | customStyles | CustomStyleOption[] | - | Custom styles. |
| ❌ | customLinkStyles | CustomLinkStyle[] | - | Custom link styles. |
| ❌ | transforms | Record<string, boolean> | - | Transforms. |
| ❌ | elementBindings | Element | Element[] | - | Any Element(s) that will be automatically bound to the value of the Editor. |
| ❌ | onUpdate | (editor: Editor) ⇒ void | - | Callback function that fires whenever the state of the Editor is changed. |
Defining controls
The controls option is a simple string array which can contain any of the following:
alignLeftalignMiddlealignRightalignJustifyboldbulletListclearFormattingcustomStylesfontFamilyfontSizetextFormattingheadingitaliclinkorderedListtextColourunderline
You can also use a pipe character (|) to create a divider/separator between controls.
The order in which items are defined corresponds to their order in the toolbar.
Font families
The fontFamilies option takes a FontFamilyOptions object:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | families | FontFamily[] | - | An array of font families. |
| ❌ | defaultFontFamily | string | families[0] | The default font family. Defaults to the first item in families. |
| ❌ | defaultValueTemplate | string | '{{name}} (default)' | The template used to render the default option. Template syntax ({{prop}}) can be used here to reference any properties of FontFamily. |
FontFamily
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | name | string | - | The name shown in the toolbar. |
| ✅ | cssValue | string | - | The CSS value that will be applied to the HTML. |
Example:
fontFamilies: {
//defaultValueTemplate: '{{name}} (default)',
families: [
{
name: 'Arial (default)',
cssValue: 'Arial, sans-serif',
isDefault: true,
},
{
name: 'Times New Roman',
cssValue: '"Times New Roman", serif',
},
]
}For consistency, the default font family should also be defined in the CSS:
:root {
--wse-editor-font-family: Arial, sans-serif;
}Font sizes
The fontSizes option takes a FontSizeOptions object:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | sizes | FontSize[] \| (createRange) ⇒ FontSize[] | - | An array of font sizes, or a function that returns an array of font sizes. The FontSize object signature is detailed below, and functions are called with a createRange helper method which is also detailed below. |
| ❌ | defaultFontSize | string | '16px' | The default font size. This should correspond to a FontSize. |
| ❌ | defaultValueTemplate | string | '{{name}} (default)' | The template used to render the default option. Template syntax ({{prop}}) can be used here to reference any properties of FontSize. |
FontSize
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | cssValue | string | - | The CSS value applied to the HTML. |
| ❌ | name | string | cssValue | The name shown in the dropdown. |
createRange(options: CreateRangeOptions) ⇒ FontSize[]
Convenience method to help create a range of items. Takes a CreateRangeOptions object:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | from | number | - | The start of the range. |
| ✅ | to | number | - | The end of the range. |
| ✅ | transformer | (index: number) ⇒ FontSize | - | Method that returns a FontSize. |
| ❌ | step | number | 1 | The increment between values. |
Example using the createRange helper:
fontSizes: {
//defaultFontSize: '16px',
//defaultValueTemplate: '{{name}} (default)',
sizes: (createRange) =>
createRange({
from: 10,
to: 72,
transformer: (i) => ({
//name: `${i}px`,
cssValue: `${i}px`,
}),
}),
},For consistency, the default font size should also be defined in the CSS:
:root {
--wse-editor-font-size: 16px;
}Font weights
The fontWeights option takes a FontWeightOptions object:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | weights | FontWeight[] | - | An array of font weights. |
| ❌ | defaultFontWeight | string | '400' | The default font size. This should correspond to a FontWeight. |
| ❌ | defaultValueTemplate | string | '{{name}} (default)' | The template used to render the default option. Template syntax ({{prop}}) can be used here to reference any properties of FontWeight. |
FontWeight
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | cssValue | string | - | The CSS value applied to the HTML. |
| ❌ | name | string | - | The name shown in the dropdown. |
| ❌ | isBold | boolean | - | Marks the option as "default" bold. Rather than applying CSS styles, text will be wrapped with <strong> tags, for semantically correct and accessible HTML. This isn't mandatory and can be ignored. |
Example:
fontWeights: {
//defaultFontWeight: '400',
//defaultValueTemplate: '{{name}} (default)',
weights: [
{
//name: '100',
cssValue: '100',
//isBold: false,
},
{
//name: '400',
cssValue: '400',
//isBold: false,
},
{
//name: '700',
cssValue: '700',
isBold: true,
},
{
//name: '900',
cssValue: '900',
//isBold: false,
},
],
},Headings
The headings option takes a HeadingOptions object:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | levels | Record<number, HeadingSize> | - | A number-keyed object, where the key represents the heading level, and the value is a HeadingSize object, which is detailed below. |
| ❌ | defaultFontWeight | string | '700' | The default heading weight. This can be overridden by individual heading levels. |
| ❌ | sizeListNameTemplate | string | 'Default ({{cssValue}})' | The template used to render the current heading level in the font size dropdown (if applicable). Template syntax can be used here to reference any properties of HeadingSize. |
HeadingSize
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | name | string | - | The name shown in the dropdown. |
| ✅ | cssValue | string | - | The CSS value applied to the HTML. |
| ❌ | defaultFontWeight | string | HeadingOptions[defaultWeight] | A level-specific weight override. |
Example:
headings: {
//defaultFontWeight: '700',
defaultFontFamily: 'Times',
//sizeListNameTemplate: 'Default ({{cssValue}})',
levels: {
1: {
name: 'Heading 1',
cssValue: '3rem',
//defaultFontWeight: '700',
//defaultFontFamily: 'Times',
},
2: {
name: 'Heading 2',
cssValue: '2.5rem',
//defaultFontWeight: '700',
//defaultFontFamily: 'Times',
},
3: {
name: 'Heading 3',
cssValue: '2rem',
//defaultFontWeight: '700',
//defaultFontFamily: 'Times',
},
},
},For consistency, heading sizes should also be defined in the CSS:
:root {
--wse-editor-font-size-h1: 3rem;
--wse-editor-font-size-h2: 2.5rem;
--wse-editor-font-size-h3: 2rem;
...
}Relationship between font sizes and headings
In instances where both font sizes and headings are defined, without some kind of synchronisation between the two, the UI can become a bit confusing. Let's say the default font size of the editor is 16px, and the corresponding name in the dropdown is "16px (default)". If you select a heading variant which is, for example, 3rem, the HTML markup will change accordingly and visually you'll see the difference, but the font size dropdown will still show "16px (default)". Technically this is correct, but it's not very intuitive. In these instances, a new option will be appended to the font size dropdown to represent the selected heading level.
The value of this option is controlled via the aforementioned sizeListNameTemplate property.
Lastly, the defaultValueTemplate property in FontSizeOptions is ignored whilst a heading is selected.
Text formatting
The textFormatting option takes a TextFormattingOptions object:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ❌ | letterSpacing | LetterSpacingOptions | - | Letter spacing options, as detailed below. |
| ❌ | lineHeight | LineHeightOptions | - | Line height options, as detailed below. |
| ❌ | lineHeightFix | boolean | false | Apply a line height fix to the editor, see Issues with line height. |
Letter spacing
The letterSpacing option takes a LetterSpacingOptions object:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | min | number | - | The minimum value. |
| ✅ | max | number | - | The maximum value. |
| ✅ | step | number | - | The increment between values. |
| ❌ | defaultValue | number | 0 | The default letter spacing. |
| ❌ | unit | string | 'px' | The unit of measurement used. |
In the unlikely event that the default letter spacing is not 0, the default value should also be defined in the CSS:
:root {
--wse-editor-letter-spacing: 1ch;
}Line height
The lineHeight option takes a LineHeightOptions object:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | min | number | - | The minimum value. |
| ✅ | max | number | - | The maximum value. |
| ✅ | step | number | - | The increment between values. |
| ✅ | defaultValue | number | - | The default line height. |
| ❌ | headingOverrides | Record<number \| 'all', Partial<LineHeightOptions>> | - | Global or level-specific heading overrides, as detailed below. |
Heading overrides
The headingOverrides option takes an object whose keys are either numerical, representing individual heading levels, or the keyword 'all', which encompases all heading levels. Any property of LineHeightOptions can be overridden, but for obvious reasons headingOverrides is ignored. Missing properties are inherited.
Example with a global heading override:
{
defaultValue: 1.5,
headingOverrides: {
all: {
defaultValue: 1.2,
},
},
}For consistency, line heights should also be defined in the CSS:
:root {
--wse-editor-line-height: 1.5;
--wse-editor-line-height-heading: 1.2;
}Line heights can also be defined for individual heading levels, should they differ from one another:
:root {
--wse-editor-line-height-h6: 1.6;
...
}Issues with line height
Mixing line height with headings can be problematic.
The way Tiptap works is that, when a style ("mark") is added to a paragraph or heading ("node"), a <span> element is created, for example:
<h1>
<span style="line-height: 1;">Hello world</span>
</h1>This seems like it would work, but more often than not the <h1> will have its own line height set, whether implicitly or explicitly via custom CSS or a UI library. Let's say the line height of the <h1> is 1.4, but the <span>'s line height is 1. It'll actually look as if the <span>'s value of 1 is being ignored. It isn't, but because its parent has a line height of 1.4, the invisible "tracks" completely
encompass that of the <span>. It's as if each line has a min-height rule applied.
If the <span>'s line height is greater than 1.4, then things appear to work again, because it's pushing outside its parents boundaries.
To fix this we can use CSS to query any "node" that contains a <span> with an inline line-height rule, and reset its line height to a base value. That way, any descendants are guaranteed to have a larger line height.
This will also need to be handled on the production side, either with a custom implementation or by copying the source code.
Custom styles
The customStyles option takes an array of CustomStyleOption objects:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | name | string | - | The name shown in the dropdown. |
| ✅ | classList | string[] | - | The class(es) to apply to the HTML. |
Custom link styles
The customLinkStyles option takes an array of CustomLinkStyle objects:
| Required | Parameter | Type | Default | Description |
| --- | --- | --- | --- | --- |
| ✅ | name | string | - | The name shown in the dropdown. |
| ✅ | classList | string[] | - | The class(es) to apply to the HTML. |
Transforms
The transforms option specifies a list of key/value pairs. The key is the name of the transform, and the value is a boolean that either enables or disables the transform. Transforms will be applied to content on initialisation and update, both programatically or via user input (ie. paste). The following transforms are available:
| Key | Enabled by default | Description |
| --- | --- | --- |
| sanitiseNonBreakingSpaces | ❌ | Remove non-breaking spaces. |
Example:
{
transforms: {
sanitiseNonBreakingSpaces: true,
},
}The Editor instance
The underlying TipTap instance is available as property tiptap.
const editor = new Editor({ ... });
// TipTap instance
editor.tiptap;The following instance methods are also available. In some cases these simply proxy TipTap methods for ease of use.
destroy() ⇒ void
Destroy the Editor along with its TipTap instance.
getHTML() ⇒ string
Get the Editor contents as HTML. If the Editor is "empty-ish", as in it doesn't contain any meaningful content, but may contain an empty node, an empty string is returned instead. If you require the default behaviour then you can use editor.tiptap.getHTML() instead.
isEmpty() ⇒ boolean
Check if the Editor is empty or not.
setContent(content: string) ⇒ void
Update the Editor with new content.
Example:
editor.setContent('<p>Example text</p>');closeAllDialogs() ⇒ void
Close any open dialogs.
Styling
If your pipeline/setup allows it, for example using Webpack along with the css-loader plugin, you can import the CSS directly in the module:
import './path/to/editor.css';Otherwise you'll want to copy editor.css from the package into your distribution folder and reference it in your HTML like normal.
The Editor comes with a basic, neutral layout that is based on CSS variables. These can be overridden to customise the Editor as needed. The following tables lists the available variables and their functions:
Toolbar
| Variable | Default | Description |
| --- | --- | --- |
| --wse-toolbar-bg-colour | transparent | The background colour of the toolbar. |
| --wse-toolbar-text-colour | #000 | The text colour of the toolbar. |
| --wse-toolbar-padding | 10px | The toolbar padding. |
| --wse-toolbar-gap | 8px | The gap between controls in the toolbar. |
| --wse-toolbar-border | 1px solid #999 | The border underneath the toolbar. |
| --wse-toolbar-transition-duration | 0.2s | The duration of the toolbar animations. |
| --wse-toolbar-transition-timing-function | ease | The timing function of the toolbar animations. |
Toolbar controls
| Variable | Default | Description |
| --- | --- | --- |
| --wse-control-border | none | The border around toolbar controls. |
| --wse-control-radius | 5px | The border radius of toolbar controls. |
| --wse-control-padding-x | 6px | The horizontal padding of toolbar controls. |
| --wse-control-padding-y | 0px | The vertical padding of toolbar controls. |
| --wse-control-bg-colour | #eee | The background colour of toolbar controls. |
| --wse-control-text-colour | #000 | The text colour of toolbar controls. |
| --wse-control-hover-bg-colour | #ddd | The hover background colour of toolbar controls. |
| --wse-control-hover-text-colour | #000 | The hover text colour of toolbar controls. |
| --wse-control-highlight-bg-colour | #222 | The highlighted background colour of toolbar controls. |
| --wse-control-highlight-text-colour | #fff | The highlighted text colour of toolbar controls. |
| --wse-control-icon-size | 16px | The size of toolbar control icons. |
| --wse-control-colour-outline | 2px | The size of toolbar control outline (used to indicate a selected colour). |
| --wse-control-colour-outline-offset | -2px | The offset of the toolbar control outline. |
Toolbar dividers
| Variable | Default | Description |
| --- | --- | --- |
| --wse-toolbar-divider-width | 1px | The width of the dividers. |
| --wse-toolbar-divider-height | 60% | The height of the dividers. |
| --wse-toolbar-divider-colour | #999 | The colour of the dividers. |
Dialog
| Variable | Default | Description |
| --- | --- | --- |
| --wse-dialog-bg-colour | #222 | The background colour of control dialogs. |
| --wse-dialog-text-colour | #fff | The text colour of control dialogs. |
| --wse-dialog-padding | 15px | The padding of control dialogs. |
| --wse-dialog-radius | 5px | The border radius of control dialogs. |
| --wse-dialog-gap | 6px | The gap between elements inside control dialogs. |
| --wse-dialog-shadow | none | The drop shadow underneath control dialogs. |
| --wse-dialog-transition-duration | 0.6s | The duration of the dialog animation. |
| --wse-dialog-transition-timing-function | cubic-bezier(0.16, 1, 0.3, 1) | The timing function of the dialog animation. |
Select list
| Variable | Default | Description |
| --- | --- | --- |
| --wse-select-max-height | 400px | The maximum height of the select. |
| --wse-select-option-gap | 6px | The gap between options. |
| --wse-select-option-padding-x | 8px | The horizontal padding of each option. |
| --wse-select-option-padding-y | 6px | The vertical padding of each option. |
| --wse-select-option-radius | 5px | The border radius of each option. |
| --wse-select-option-highlight-bg-colour | #fff | The highlighted background colour of each option |
| --wse-select-option-highlight-text-colour | #222 | The highlighted text colour of each option. |
| --wse-select-option-hover-bg-colour | rgb(white, 0.2) | The hover background colour of each option. |
| --wse-select-option-hover-text-colour | #fff | The hover text colour of each option. |
Editor
| Variable | Default | Description |
| --- | --- | --- |
| --wse-editor-bg-colour | #fff | The background colour of the Editor. |
| --wse-editor-text-colour | #000 | The default text colour of the Editor. |
| --wse-editor-font-family | revert | The default font family of the Editor. |
| --wse-editor-font-family-heading | revert | The default heading font family of the Editor. |
| --wse-editor-font-family-h[1-6] | revert | Heading-level specific font family overrides. |
| --wse-editor-font-size | revert | The default font size of the Editor. |
| --wse-editor-font-size-h[1-6] | revert | Heading-level specific font size overrides. |
| --wse-editor-letter-spacing | revert | The default letter spacing of the Editor. |
| --wse-editor-line-height | revert | The default line height of the Editor. |
| --wse-editor-line-height-heading | revert | The default heading line height of the Editor. |
| --wse-editor-line-height-h[1-6] | revert | Heading-level specific line height overrides. |
| --wse-editor-min-height | 200px | The minimum height of the Editor. |
| --wse-editor-max-height | none | The maximum height of the Editor. |
| --wse-editor-padding | 20px | The padding of the Editor. |
| --wse-editor-border | none | The border around the Editor. |
| --wse-editor-radius | 0px | The border radius of the Editor. |
Text colour component
| Variable | Default | Description |
| --- | --- | --- |
| --wse-default-swatch-radius | 50% | The radius of the swatches. |
| --wse-default-swatch-highlight-outline | #fff | The highlight colour of the swatches. |
| --wse-default-swatches-per-line | 8 | The number of swatches per row. |
Contributing
TipTap is headless and totally modular, so out of the box it does virtually nothing. You can intitialise it with "extensions", which enable features such as bold or italic text. In some cases this will include markdown functionality, but it does not include any UI. That's down to us to implement ourselves.
In most cases the requirements for a feature UI are as follows:
- Have a button or some kind of interactive element that will invoke the functionality of an "extension".
- Provide a subsequent UI if needed (eg. a colour picker)
- The element will react to changes in TipTap's state (eg. a bold button will highlight if the user selects bold text in the Editor).
To that end the Editor is based on class inheritance, with common functionality delegated to abstract classes. These can be found under components/abstract/*.
The base class, Control<T>, does very little other than define methods that must be implemented by subclasses, and expose the current Editor instance. This will probably never need to be touched.
Next in the heirarchy is ControlButton, which represents a clickable item in the Editor toolbar. This is used for simple extensions such as toggling bold or italic text.
Some extensions require additional UI, and this is where ControlButtonWithDialog is useful, as it lets us show a dialog (styled as a dropdown). More on Dialog in the next section.
As a stylised replacement for the native <select> element, we also have ControlButtonWithSelect, which implements ControlButtonWithDialog under the hood, but requires less setup when dealing with simple option lists.
Lastly we have the top-level controls that implement one of the aforementioned abstract classes. These are found under components/Control{Name}.ts (note the Control prefix for consistency).
A simple example is the control for bold text:
import icon from '../../img/bold.svg';
import { highlightIf } from '../utils';
import { ControlButton } from './abstract/ControlButton';
class ControlBold extends ControlButton {
constructor() {
super(icon);
}
public override update(): void {
highlightIf(this.tiptap.isActive('bold'), this.element);
}
protected override onClick(): void {
this.tiptap.chain().focus().toggleBold().run();
}
}
export { ControlBold };Currently the icons are provided by Flaticon. There's a collection in our account named "Text editor". Any new icons should follow the same style if possible.
The update() method is mandatory and is called when TipTap fires its transaction event, which essentially means the state has changed somehow. This includes the user making selections or simply changing the caret position.
The highlightIf() method is a simple utility function that will apply some styles to the element if the condition is truthy. What's important is the condition itself: this.tiptap.isActive('bold'). If the caret is within the bounds of a block of bold text, or the current selection contains any bold markers, this will return true and our UI element will be highlighted.
The onClick() method is fired when the UI element is clicked. This then hooks directly into the TipTap API to enable bold text. This action will also trigger the transaction event mentioned previously.
Note that not all extensions have the same methods, so you'll need to reference the TipTap docs when implementing any new extensions. The concept, however, is the same.
For a more complex example, it's worth having a look at ControlTextColour.ts, as this requires an additional UI for displaying a colour picker.
In a nutshell, the process for adding a new UI control is as follows:
- Install the corresponding TipTap extension.
- Register the extension when initialising TipTap (in
main.ts). - Find a new icon and add it to the
imgdirectory. - Create a new
Control{Name}.tsthat extends one of the abstractControl*classes and implement according to the TipTap docs. - Import and add a new instance of the control to the
controlsarray (inmain.ts).
The Dialog component
The Dialog component creates a floating dialog beneath a Control. You'll need to pass in the HTML to render in the dialog, but there are a couple of handy conventions to be aware of:
- If your HTML contains a
<form>element then asubmitevent will automatically be bound to it, which will callevent.preventDefault()and then, if you have provided anonSubmitproperty, it'll be called. - If your HTML contains a
<button>element with adata-type="clear"attribute (inside a<form>element) then aclickevent will be automatically bound to it, which will callonClearif you have provided it.
Development mode
Run watch to begin development mode.
Demo pages
Demo pages can be found under the demos directory. You'll need to spin up a server in the project root, and not under demos, so that the distribution files can be referenced properly. I recommend using serve.
Publishing
Before publishing a new version, please make sure to update CHANGELOG.md.
The prePublishOnly task runs automatically and builds the project for production (including code formatting and generating TypeScript types), so you shouldn't have to do any heavy lifting.
Once you're ready, you can run the release script either from the IDE, or manually from a terminal:
npm run release