Skip to content

Instantly share code, notes, and snippets.

@flogaribal
Last active February 16, 2022 07:48
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save flogaribal/aca78d101499f918a0c1722e60f058b5 to your computer and use it in GitHub Desktop.
Save flogaribal/aca78d101499f918a0c1722e60f058b5 to your computer and use it in GitHub Desktop.
React MarkDown Editor based on react-slate. Pretty new to React, might not follow best practices, open to feedbacks !

This Gist details how I developed a React Markdown Editor based on Slate libraries

Result

image

Features :

  • Write Markdown
  • Save value as Markdown (and not Slate JSON object)
    • This can be discussed and I used this format because I wanted to be retrocompatible with all the content I already have in database
  • Preview directly in text area the result
  • Headers H1/H2/H3/etc
  • List (bullet point & numbered list)
  • Bold/italic/underlined
  • Hovering toolbar when text selected

Known issues (open to feedbacks to fix them) :

  • value is update onBlur because serialization can take a lot of time depending on the content length and complexity.
    • This editor cannot provide live preview in another component. Preview will only be updated when unfocusing the text area.
    • Same for content length, live text length counter cannot be live yet.
  • Double click on a word which is already formatted (bold for example) does not detect formating (bold is not marked as selected).
    • This might come from the selection bounds when double click, did not investigate yet
import React from "react";
import ReactDOM from "react-dom";
import { Editor, Path, Transforms } from "slate";
export const MDButton = React.forwardRef(({ className, active, reversed, ...props }, ref) => {
const button = <span
{...props}
ref={ref}
className={`${className || ''}`}
style={{
cursor: 'pointer',
color: reversed ?
active ?
'white'
: '#aaa'
: active
? props.activeColor || 'black'
: '#ccc'
}}
/>
return button
});
export const MDIcon = React.forwardRef(({ className, ...props }, ref) =>
<span
{...props}
ref={ref}
className={`${className || ''} material-icons md-icon`}
/>
);
export const MDMenu = React.forwardRef(({ className, ...props }, ref) => (
<div
{...props}
ref={ref}
className={`${className || ''} md-menu`}
/>
));
export const MDPortal = ({ children }) => {
return typeof document === "object"
? ReactDOM.createPortal(children, document.body)
: null;
};
export const MDToolbar = React.forwardRef(({ className, ...props }, ref) => (
<MDMenu
{...props}
ref={ref}
className={`${className || ''} md-toolbar`}
/>
));
export const MDElement = ({ attributes, children, element }) => {
switch (element.type) {
case 'block_quote':
return <blockquote {...attributes}>{children}</blockquote>
case 'heading_one':
return <h1 {...attributes}>{children}</h1>
case 'heading_two':
return <h2 {...attributes}>{children}</h2>
case 'heading_three':
return <h3 {...attributes}>{children}</h3>
case 'heading_four':
return <h4 {...attributes}>{children}</h4>
case 'heading_five':
return <h5 {...attributes}>{children}</h5>
case 'heading_six':
return <h6 {...attributes}>{children}</h6>
case 'ul_list':
return <ul {...attributes}>{children}</ul>
case 'ol_list':
return <ol {...attributes}>{children}</ol>
case 'list_item':
return <li {...attributes}>{children}</li>
default:
return <p className='my-1' {...attributes}>{children}</p>
}
}
export const MDLeaf = ({ attributes, children, leaf }) => {
if (leaf.bold) {
children = <strong>{children}</strong>
}
if (leaf.code) {
children = <code>{children}</code>
}
if (leaf.italic) {
children = <em>{children}</em>
}
if (leaf.underline) {
children = <u>{children}</u>
}
return <span {...attributes}>{children}</span>
}
export const withLineBreakPlugin = (editor) => {
const defaultInsertBreak = editor.insertBreak
editor.insertBreak = () => {
const [match] = Editor.nodes(editor, { match: n => n.type === 'list_item' });
if (!match) {
defaultInsertBreak()
return;
}
// Current line break is inside a list item
// If selection is at the end of the line, create a new empty list_item
// otherwise create a new list_item with the end of the string as content
// Fow now, list_item can only have ONE paragraph inside
const [node, path] = match;
const carretOffsetInCurrentNode = editor.selection.anchor.offset
const listChild = node.children[0]
let newListItemText = ''
if (listChild && carretOffsetInCurrentNode < listChild.children[0].text.length) {
// If the user hit enter before the end of the current paragraph we need to move all the content
// to the next list_item and update current node
const textToSlice = listChild.children[0].text
// Save text to move to next list_item
newListItemText = textToSlice?.slice(carretOffsetInCurrentNode)
// Get current leaf and remove text that will be move to next list_item
const [_, leafPath] = Editor.leaf(editor, editor.selection)
Transforms.select(editor, { anchor: { path: leafPath, offset: carretOffsetInCurrentNode }, focus: { path: leafPath, offset: listChild.children[0].text.length } })
editor.deleteFragment()
}
const newParagraph = {
type: 'paragraph',
children: [
{ text: newListItemText }
]
}
Transforms.insertNodes(editor, { type: 'list_item', children:[newParagraph] }, { at: Path.next(path) })
Transforms.move(editor, { distance: 1, unit: 'word' });
}
return editor;
}
// Not used
export const MDEditorValue = React.forwardRef(({ className, value, ...props }, ref) => {
const textLines = value.document.nodes
.map((node) => node.text)
.toArray()
.join("\n");
return (
<div
ref={ref}
{...props}
className={`${className || ''}`}
style={{
margin: '30px -20px 0',
}}
>
<div
className={''}
style={{
fontSize: '14px',
padding: '5px 20px',
color: '#404040',
borderTop: '2px solid #eeeeee',
background: '#f8f8f8',
}}
>
Slate's value as text
</div>
<div
className={''}
style={{
color: '#404040',
font: '12px monospace',
whiteSpace: 'pre-wrap',
padding: '10px 20px',
}}
>
{textLines}
</div>
</div>
);
});
// Not used
export const MDInstruction = React.forwardRef(({ className, ...props }, ref) => (
<div
{...props}
ref={ref}
className={`${className || ''} md-instructions`}
/>
));
import React from 'react';
const Icon = (props) => {
const riIconStyle = props.iconStyle ? props.iconStyle : 'line';
const googleIconStyle = props.iconStyle ? `-${props.iconStyle}` : '';
return (
<span onClick={props.onClick} className={[props.ri ? (`ri-${props.icon}-${riIconStyle}`) : `material-icons${googleIconStyle}`, props.onClick ? 'mouse-pointer' : '', props.className].join(' ')} style={props.style}>
{props.ri ? null : props.icon}
</span>
);
}
export default Icon;
import React, { useCallback, useEffect, useRef, useState } from 'react'
import isHotkey from 'is-hotkey'
import { Editable, withReact, useSlate, Slate, ReactEditor } from 'slate-react'
import {
Editor,
Transforms,
createEditor,
Element as SlateElement,
Range,
} from 'slate'
import { withHistory } from 'slate-history'
import slate, { serialize } from 'remark-slate';
import { unified } from 'unified';
import markdown from 'remark-parse';
import { MDButton, MDIcon, MDMenu, MDPortal, MDToolbar,MDElement, MDLeaf, withLineBreakPlugin } from './Components'
import Icon from 'Icon';
import './MarkdownEditor.scss';
const HOTKEYS = {
'mod+b': 'bold',
'mod+i': 'italic',
'mod+u': 'underline',
// 'mod+`': 'code',
}
const LIST_TYPES = {
ol_list: 'ol_list',
ul_list: 'ul_list',
}
const TOOLBAR_CONTENT = [
{
type: 'MarkButton',
label: 'Gras',
format: 'bold',
icon: 'format_bold',
},
{
type: 'MarkButton',
label: 'Italique',
format: 'italic',
icon: 'format_italic',
},
{
type: 'MarkButton',
label: 'Souligné',
format: 'underline',
icon: 'format_underlined',
},
// {
// type: 'MarkButton',
// label: 'Code',
// format: 'code',
// icon: 'code',
// },
{
type: 'BlockButton',
label: 'Titre 1',
format: 'heading_five', // Do not generete H1, H2, H3, H4 for SEO reason
icon: 'looks_one',
},
{
type: 'BlockButton',
label: 'Titre 2',
format: 'heading_six', // Do not generete H1, H2, H3, H4 for SEO reason
icon: 'looks_two',
},
// {
// type: 'BlockButton',
// label: 'Citation',
// format: 'block_quote',
// icon: 'format_quote',
// },
{
type: 'BlockButton',
label: 'Liste numérotée',
format: LIST_TYPES.ol_list,
icon: 'format_list_numbered',
},
{
type: 'BlockButton',
label: 'Liste à puces',
format: LIST_TYPES.ul_list,
icon: 'format_list_bulleted',
},
]
const HOVERING_TOOLBAR_CONTENT = [
{
type: 'MarkButton',
label: 'Gras',
format: 'bold',
icon: 'format_bold',
},
{
type: 'MarkButton',
label: 'Italique',
format: 'italic',
icon: 'format_italic',
},
{
type: 'MarkButton',
label: 'Souligné',
format: 'underline',
icon: 'format_underlined',
},
]
const BlockButton = (props) => {
const editor = useSlate()
return (
<MDButton
label={props.label}
activeColor={props.activeColor}
active={isBlockActive(editor, props.format)}
onMouseDown={event => {
event.preventDefault()
toggleBlock(editor, props.format)
}}
>
<MDIcon>{props.icon}</MDIcon>
</MDButton>
)
}
const isBlockActive = (editor, format) => {
const { selection } = editor
if (!selection) return false
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: n =>
!Editor.isEditor(n) && SlateElement.isElement(n) && n.type === format,
})
)
return !!match
}
const toggleBlock = (editor, format) => {
const isActive = isBlockActive(editor, format)
const isList = Object.values(LIST_TYPES).includes(format)
// Unwrap node only if current node is a list
// Used to remove ol_list/ul_list node
Transforms.unwrapNodes(editor, {
match: n =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
Object.values(LIST_TYPES).includes(n.type),
split: true,
})
// Unwrap node only if node is a list_item
Transforms.unwrapNodes(editor, {
match: n =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
n.type === 'list_item',
split: true,
})
// Switching current node type according to given format
// If it was formatted, switch it back to basic paragraph
// Otherwise change it to list_item or given format
if (!isActive && isList) {
// If it's a list toggle, we need to wrap the children with ol_list/ul_list
const ulOlBlock = { type: format, children: [] }
Transforms.wrapNodes(editor, ulOlBlock)
const listItemBlock = { type: 'list_item', children: [] }
Transforms.wrapNodes(editor, listItemBlock)
} else {
const newProperties = {
type: isActive ?
'paragraph'
: format
}
Transforms.setNodes(editor, newProperties)
}
}
const MarkButton = (props) => {
const editor = useSlate()
return (
<MDButton
label={props.label}
activeColor={props.activeColor}
active={isMarkActive(editor, props.format)}
onMouseDown={event => {
event.preventDefault()
toggleMark(editor, props.format)
}}
>
<MDIcon>{props.icon}</MDIcon>
</MDButton>
)
}
const isMarkActive = (editor, format) => {
const marks = Editor.marks(editor)
return marks ? marks[format] === true : false
}
const toggleMark = (editor, format) => {
const isActive = isMarkActive(editor, format)
if (isActive) {
Editor.removeMark(editor, format)
} else {
Editor.addMark(editor, format, true)
}
}
const MDHoveringToolbar = () => {
const ref = useRef()
const editor = useSlate()
useEffect(() => {
const el = ref.current
const { selection } = editor
if (!el) {
return
}
if (!selection ||
!ReactEditor.isFocused(editor) ||
Range.isCollapsed(selection) ||
Editor.string(editor, selection) === ''
) {
el.removeAttribute('style')
return
}
const domSelection = window.getSelection()
const domRange = domSelection.getRangeAt(0)
const rect = domRange.getBoundingClientRect()
el.style.opacity = '1'
el.style.top = `${rect.top + window.pageYOffset - el.offsetHeight}px`
el.style.left = `${rect.left + window.pageXOffset - el.offsetWidth / 2 + rect.width / 2}px`
})
return (
<MDPortal>
<MDMenu
ref={ref}
className='md-hovering-toolbar'
>
{
HOVERING_TOOLBAR_CONTENT.map((item) =>
item.type === 'MarkButton' ?
<MarkButton { ...item } activeColor={'red'}/>
: <BlockButton { ...item } activeColor={'red'}/>
)
}
</MDMenu>
</MDPortal>
)
}
const MarkdownEditor = (props) => {
const [slateObjValue, setSlateObjValue] = useState([])
const [initialized, setInitialized] = useState(false)
const [blurred, setBlurred] = useState(false)
const [editorClassNames, setEditorClassNames] = useState('')
const [displayValidation, setDisplayValidation] = useState(false)
const [lastContentLength, setLastContentLength] = useState(0)
const editorRef = useRef()
if (!editorRef.current) editorRef.current = withLineBreakPlugin(withHistory(withReact(createEditor()), []))
const editor = editorRef.current
const renderElement = useCallback(props => <MDElement {...props} />, [])
const renderLeaf = useCallback(props => <MDLeaf {...props} />, [])
useEffect(() => {
if (initialized) {
return
}
// Empty value -> create empty slate object with 1 element (mandatory)
if (!props.value || props.value.trim() === '') {
const obj = [{ type: 'paragraph', children: [{ text: '' }] }]
editor.children = obj
setSlateObjValue(obj)
return;
}
// deserialize markdown string to slate object
unified()
.use(markdown)
.use(slate)
.process(props.value)
.then(
(file) => {
const obj = file.result;
editor.children = obj
setSlateObjValue(obj)
setInitialized(true)
setLastContentLength(props.value.length)
},
(error) => {
console.log('errors parsing', error)
}
)
}, [props.value]);
useEffect(() => {
refreshEditorClassNames()
refreshDisplayValidation()
}, [props.disabled, props.className, props.isValid]);
const onChange = (slateObj) => {
const isAChange = editor.operations.some((op => op.type !== 'set_selection'))
if (!isAChange) {
return;
}
if (slateObj.length === 0) {
return;
}
setSlateObjValue(slateObj)
// Do not call props.onChange as serialization requires a lot of computations
// Call onChange on onBlur to only serialize once editing is over
}
const onPaste = (event) => {
// Disable paste default event and simulate default paste event
// Without this it caused editor to crash.
// It might be because of formatting when pasting
// Here we only paste the raw content without any formatting
event.preventDefault()
const text = event.clipboardData.getData('text')
editor.insertText(text);
};
// On blur also handle on change because serialization (slateObj -> MDstring) requires a lot of computations
const onBlur = (e) => {
setBlurred(true)
refreshEditorClassNames()
refreshDisplayValidation()
// Serialize object
const mdText = slateObjValue.map((v) => serialize(v)).join('').replaceAll('<br>', '');
if (
props.onChange
&& mdText
// We do not want to trigger on change when only empty content have been modified
// EXCEPT when content have been deleted (detected by lastContentLength)
&& (mdText?.replaceAll(/(<br>|\s)/g, '').length > 0
|| lastContentLength != mdText.length)
) {
setLastContentLength(mdText.length)
props.onChange(mdText)
}
if (props.onBlur) {
props.onBlur(e)
if (props.setFieldTouched) {
props.setFieldTouched(props.name)
}
}
}
const refreshEditorClassNames = () => {
let classNames = `md-editor ${props.className || ''}`
if (!props.disabled) {
if (blurred) {
// Do not display validation until blurred
classNames += props.isValid ? ' valid' : ' invalid'
}
} else {
classNames += ' disabled'
}
setEditorClassNames(classNames)
}
const refreshDisplayValidation = () => {
setDisplayValidation(!props.disabled && blurred)
}
const getMaxLineStyle = () => {
if (!props.rows) {
return {}
}
const rowHeight = 18;
return {
height: `${rowHeight*props.rows}px`,
overflowY: 'scroll',
}
}
return <div className={editorClassNames}>
{/* onChange callBack only updates the slateObject, serialization is done on onBlur callback */}
{/* slateObjValue is only used for initValue, then object changes are handled by onChange */}
<Slate editor={editor} value={slateObjValue} onChange={onChange}>
<MDHoveringToolbar />
{
!props.disabled ?
<MDToolbar>
{
TOOLBAR_CONTENT.map((item) =>
item.type === 'MarkButton' ?
<MarkButton { ...item } />
: <BlockButton { ...item } />
)
}
</MDToolbar>
: null
}
<Editable
readOnly={props.disabled}
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder={props.placeholder || 'Entrez votre texte ici...'}
autoFocus
onBlur={onBlur}
onPaste={onPaste}
onKeyDown={event => {
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event)) {
event.preventDefault()
const mark = HOTKEYS[hotkey]
toggleMark(editor, mark)
}
}
}}
style={getMaxLineStyle()} />
{
displayValidation ?
<div className='validation-icon' contentEditable={false} style={{userSelect: 'none', color: props.isValid ? 'green' : 'red'}}>
<Icon icon={props.isValid ? 'done' : 'error_outline'}/>
</div>
: null
}
</Slate>
</div>
}
export default MarkdownEditor
.md-hovering-toolbar {
padding: 8px 7px 6px;
position: absolute;
z-index: 1;
top: -10000px;
left: -10000px;
margin-top: -6px;
opacity: 0;
background-color: #222;
border-radius: 4px;
transition: opacity 0.75s;
}
.md-menu {
& > * {
display: inline-block;
}
& > * + * {
margin-left: 5px;
}
}
.md-instructions {
white-space: pre-wrap;
margin: 0 -20px 10px;
padding: 10px 20px;
font-size: 14px;
background: #f8f8e8;
}
.md-toolbar {
position: relative;
padding: 1px 18px 5px;
margin: 0 -20px;
}
.md-icon {
font-size: 22px;
vertical-align: text-bottom;
}
.md-editor {
position: relative;
color: #6c757d;
background-color: white;
padding: 5px 10px;
border-radius: 10px;
border: 1px solid #ced4da;
&.disabled {
background-color: #e9ecef;
}
&.invalid {
border: 1px solid red;
}
&.valid {
border: 1px solid green;
}
.validation-icon {
position: absolute;
top: 10px;
right: 10px;
}
}
@Emiltayeb
Copy link

Thank you very much for sharing this.
I currently building somthing similar, and this help clear come things up.
I tried making unwrapNodes to work but faced with issues
the docs didn't provided examples, so defently gonna deep dive into this.

@flogaribal
Copy link
Author

Glad it helps!
I'm not an expert with manipulating slate object but you can still explain your problem here :)
Otherwise, the Slate slack (https://slate-slack.herokuapp.com/) can give some!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment