|
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 |
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 issuesthe docs didn't provided examples, so defently gonna deep dive into this.