Created
November 30, 2020 14:35
-
-
Save jeromegwilson/29c52a34d5fb3f641042c568b9e3dd58 to your computer and use it in GitHub Desktop.
TinyMCE in react with GDS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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 'rich text editing' 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'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