Skip to content

Instantly share code, notes, and snippets.

@jeromegwilson
Created November 30, 2020 14:35
Show Gist options
  • Save jeromegwilson/29c52a34d5fb3f641042c568b9e3dd58 to your computer and use it in GitHub Desktop.
Save jeromegwilson/29c52a34d5fb3f641042c568b9e3dd58 to your computer and use it in GitHub Desktop.
TinyMCE in react with GDS
/* eslint-disable @typescript-eslint/camelcase */
import 'govuk-frontend/govuk/components/input/_input.scss';
import * as React from 'react';
import cx from 'classnames';
import '../../assets/_overrides.scss';
import '../../assets/richTextEditor.scss';
import { EditorEvent, Editor } from 'tinymce/tinymce';
import { Editor as ReactEditor } from '@tinymce/tinymce-react';
import { GdsTextarea } from './textarea';
import { GdsHint, GdsHintProps } from './hint';
import { removeMarkup } from '../helpers';
export interface RichTextEditorProps {
name: string;
isError?: boolean;
className?: string;
editorType?: 'basic' | 'enhanced';
defaultValue?: string;
onChange?: (e: string) => void;
disabled?: boolean;
showCodeTools?: boolean;
showStatusBar?: boolean;
contentCssPaths?: string[];
useCssFromNextJs?: boolean;
height?: number;
wordCountAllowed?: number;
onWordCountChange?: (wordCount: number, isOverAllowedWordCount: boolean) => void;
rows?: number;
}
interface RichTextEditorState {
wordCount: number;
wordCountRemaining: number;
isOverAllowedWordCount: boolean;
}
export class GdsRichTextEditor extends React.Component<RichTextEditorProps, RichTextEditorState> {
editor: Editor;
public constructor(props: RichTextEditorProps) {
super(props);
this.updateWordCount = this.updateWordCount.bind(this);
this.getHintText = this.getHintText.bind(this);
this.setupEditor = this.setupEditor.bind(this);
this.onEditorCommand = this.onEditorCommand.bind(this);
this.setContentClasses = this.setContentClasses.bind(this);
this.setContentElementClass = this.setContentElementClass.bind(this);
this.state = {
wordCount: 0,
wordCountRemaining: this.props.wordCountAllowed ?? 999999,
isOverAllowedWordCount:false
};
}
private wordCountHtml(html: string): number {
const text = removeMarkup(html);
const trimmedText = text.trim().replace(/\s+/g, ' ');
return trimmedText === '' ? 0 : 1 + trimmedText.replace(/\S/g, '').length;
}
private updateWordCount() {
const prevWordCount = this.state.wordCount;
const wordCount = this.wordCountHtml(this.editor.getContent());
const wordCountRemaining = (this.props.wordCountAllowed ?? 999999) - wordCount;
const isOverAllowedWordCount = wordCountRemaining < 0;
this.setState({
wordCount,
wordCountRemaining,
isOverAllowedWordCount
});
if (wordCount !== prevWordCount && this.props.onWordCountChange) {
this.props.onWordCountChange(wordCount, isOverAllowedWordCount);
}
}
private getHintText(): string {
const wordCountRemaining = this.state.wordCountRemaining;
if (wordCountRemaining >= 0) {
return `You have ${wordCountRemaining} word${wordCountRemaining === 1 ? '' : 's'} remaining`;
} else {
return `You are ${Math.abs(wordCountRemaining)} word${wordCountRemaining === -1 ? '' : 's'} over the limit`;
}
}
render() {
const {
className,
isError,
defaultValue,
disabled,
editorType,
name,
onChange,
showCodeTools,
showStatusBar,
contentCssPaths,
height,
useCssFromNextJs,
wordCountAllowed,
onWordCountChange,
rows
} = this.props;
const enableWordCount = !!(wordCountAllowed || onWordCountChange);
let extraTools: string[] = [];
let extraPlugins: string[] = [];
if (showCodeTools) {
extraTools.push('code');
extraPlugins.push('code');
}
if (editorType === 'enhanced') {
extraTools.push('table');
extraPlugins.push('table');
}
if (enableWordCount) {
extraPlugins.push('wordcount');
}
const textAreaId = `${name}__rte-textarea`;
const rteHiddenTextAreaId = `${name}__rte_hidden`;
const handleEditorChange = (event: EditorEvent<Editor>) => {
const content = event.target.getContent();
// @ts-ignore
document.getElementById(textAreaId).value = content;
if (onChange) {
onChange(content);
}
};
let cssPaths: string[] = contentCssPaths ?? [];
if (useCssFromNextJs !== false) {
cssPaths.push(`/_next/static/css/main.css?nocache=${Date.now()}`);
// this is for dev
cssPaths.push(`/_next/static/css/styles.chunk.css?nocache=${Date.now()}`);
}
const hintProps: GdsHintProps = {
text: this.getHintText(),
hintedElementId: this.props.name,
info: true,
'aria-live': 'polite'
};
const classNames = cx('rte-wrapper', className, { 'govuk-input--error': isError || this.state.isOverAllowedWordCount});
return (
<React.Fragment>
<a id={name}/>
<noscript>
<div className="alerts alerts--danger">
<strong>JavaScript is not enabled</strong> This means anything you enter or paste into the box
below will appear without formatting. To use &apos;rich text editing&apos; functions, like bold
text or lists, you will need{' '}
<a
href="https://www.enable-javascript.com/"
className="govuk-link"
rel="noreferrer"
target="_blank"
>
to enable JavaScript
</a>{' '}
in your device or browser&apos;s security settings. If you did not disable JavaScript, try
refreshing this page in a few minutes.
</div>
</noscript>
<div className={classNames}>
<GdsTextarea
name={name}
id={textAreaId}
rows={rows ?? 8}
defaultValue={defaultValue}
className="rte-textarea"
/>
<ReactEditor
tinymceScriptSrc="/tinymce/tinymce.min.js"
textareaName={rteHiddenTextAreaId}
id={rteHiddenTextAreaId}
apiKey="123123123123123123123123123"
initialValue={defaultValue}
init={{
hidden_input: false,
content_css: cssPaths,
content_style: 'body { margin: 10px 15px; }' +
'td.govuk-table__cell { padding-left: 0; padding-right: 0 }', // TinyMCE calculates column widths incorrectly with padding
height: height ?? 500,
menubar: false,
plugins: [`advlist autolink lists link paste ${extraPlugins.join(' ')}`],
toolbar: `formatselect | bold italic underline | bullist numlist outdent indent | link ${extraTools.join(' | ')}`,
block_formats: 'Paragraph=p;Heading 1=h1;Heading 2=h2;Heading 3=h3',
style_formats_merge: false,
advlist_bullet_styles: 'disc',
advlist_number_styles: 'default',
link_context_toolbar: false,
link_assume_external_targets: true,
default_link_target: '_self',
target_list: false,
link_title: false,
branding: false,
statusbar: !!showStatusBar,
resize: true,
contextmenu: false,
table_sizing_mode: 'relative',
table_column_resizing: 'preservetable',
table_toolbar: 'tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tabledelete',
setup: this.setupEditor
}}
disabled={!!disabled}
onChange={handleEditorChange}
/>
</div>
{this.state.isOverAllowedWordCount ? (
<span id={`more-detail-info-${this.props.name}`} className="govuk-error-message" aria-live="polite">
{this.getHintText()}
</span>
) : (
<GdsHint {...hintProps} />
)}
</React.Fragment>
);
}
private setupEditor(activeEditor: Editor) {
this.editor = activeEditor;
activeEditor.on('init', () => {
this.setContentClasses('all');
this.updateWordCount();
});
activeEditor.on('input', this.updateWordCount);
activeEditor.on('ExecCommand', ({ command }) => {
this.onEditorCommand(command);
this.updateWordCount();
});
}
private onEditorCommand(command: string) {
const elementType = commandMap[command];
if (elementType) {
this.setContentClasses(elementType);
}
}
private setContentClasses(elementType: 'all' | 'typography' | 'list' | 'link' | 'table') {
const mapping = elementType === 'all' ? classMapAll : classMap[elementType];
for (const element of Object.getOwnPropertyNames(mapping)) {
this.setContentElementClass(element, classMapAll[element]);
}
}
private setContentElementClass(element: string, className: string) {
// the typescript defs for setAttribute are incorrect, hence requiring ts-ignore
// @ts-ignore
this.editor.dom.setAttrib(this.editor.dom.select(element), 'class', className);
}
}
const classMap = {
// classes for elements, grouped by element type
typography: {
p: 'govuk-body',
h1: 'govuk-heading-l',
h2: 'govuk-heading-m',
h3: 'govuk-heading-s',
},
list: {
ul: 'govuk-list govuk-list--bullet',
ol: 'govuk-list govuk-list--number',
},
link: {
a: 'govuk-link',
},
table: {
table: 'govuk-table responsive-table',
caption: 'govuk-table__caption',
thead: 'govuk-table__head',
tr: 'govuk-table__row',
th: 'govuk-table__header',
tbody: 'govuk-table__body',
td: 'govuk-table__cell',
}
};
const classMapAll = Object.assign({}, ...Object.values(classMap));
const commandMap = {
// maps editor commands to the element types they affect
InsertUnorderedList: 'list',
InsertOrderedList: 'list',
mceInsertContent: 'all',
mceInsertClipboardContent: 'all',
mceToggleFormat: 'typography',
mceInsertLink: 'link',
indent: 'list',
outdent: 'list',
mceTableSplitCells: 'table',
mceTableMergeCells: 'table',
mceTableInsertRowBefore: 'table',
mceTableInsertRowAfter: 'table',
mceTableInsertColBefore: 'table',
mceTableInsertColAfter: 'table',
mceTablePasteRowBefore: 'table',
mceTablePasteRowAfter: 'table',
mceTablePasteColBefore: 'table',
mceTablePasteColAfter: 'table',
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment