Created
October 19, 2020 01:39
-
-
Save phanngoc/473229c74d0119704d9c603b1251782a to your computer and use it in GitHub Desktop.
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
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; | |
import { Text, Editor, createEditor, Node, Range, Point, Transforms } from 'slate'; | |
import { | |
Slate, | |
Editable, | |
withReact, | |
useSelected, | |
useFocused, | |
useSlate } from 'slate-react'; | |
import { withHistory } from 'slate-history'; | |
import { cx, css } from 'emotion'; | |
import { jsx } from 'slate-hyperscript'; | |
import isHotkey from 'is-hotkey'; | |
import imageExtensions from 'image-extensions'; | |
import isUrl from 'is-url'; | |
import { | |
MdCode, | |
MdFormatBold, | |
MdFormatItalic, | |
MdFormatListBulleted, | |
MdFormatListNumbered, MdFormatQuote, MdFormatUnderlined, MdImage, MdList, | |
MdLooksOne, MdLooksTwo, MdLooks3 | |
} from 'react-icons/md'; | |
const HOTKEYS = { | |
'mod+b': 'bold', | |
'mod+i': 'italic', | |
'mod+u': 'underline', | |
'mod+`': 'code', | |
}; | |
const LIST_TYPES = ['numbered-list', 'bulleted-list']; | |
const ELEMENT_TAGS = { | |
A: el => ({ type: 'link', url: el.getAttribute('href') }), | |
BLOCKQUOTE: () => ({ type: 'quote' }), | |
H1: () => ({ type: 'heading-one' }), | |
H2: () => ({ type: 'heading-two' }), | |
H3: () => ({ type: 'heading-three' }), | |
H4: () => ({ type: 'heading-four' }), | |
H5: () => ({ type: 'heading-five' }), | |
H6: () => ({ type: 'heading-six' }), | |
IMG: el => ({ type: 'image', url: el.getAttribute('src') }), | |
LI: () => ({ type: 'list-item' }), | |
OL: () => ({ type: 'numbered-list' }), | |
P: () => ({ type: 'paragraph' }), | |
PRE: () => ({ type: 'code' }), | |
UL: () => ({ type: 'bulleted-list' }), | |
TABLE: () => ({ type: 'table' }), | |
TBODY: () => ({ type: 'tbody' }), | |
THEAD: () => ({ type: 'thead' }), | |
TR: () => ({ type: 'table-row' }), | |
TD: () => ({ type: 'table-cell' }), | |
TH: () => ({ type: 'table-cell-header' }), | |
HEADER: () => ({ type: 'header' }), | |
SECTION: () => ({ type: 'section' }), | |
} | |
// COMPAT: `B` is omitted here because Google Docs uses `<b>` in weird ways. | |
const TEXT_TAGS = { | |
CODE: () => ({ code: true }), | |
DEL: () => ({ strikethrough: true }), | |
EM: () => ({ italic: true }), | |
I: () => ({ italic: true }), | |
S: () => ({ strikethrough: true }), | |
STRONG: () => ({ bold: true }), | |
U: () => ({ underline: true }), | |
} | |
export const deserialize = (el, mAttrs = {}) => { | |
console.log('deserialize:', el, el.nodeType, el.nodeName) | |
if (el.nodeType === 3) { | |
return el.textContent | |
} else if (el.nodeType !== 1) { | |
return null | |
} else if (el.nodeName === 'BR') { | |
return '\n' | |
} | |
const { nodeName } = el | |
let parent = el | |
if (el.childNodes[0]) { | |
console.log('deserialize:sub', el.childNodes[0], el.childNodes[0].nodeName) | |
} | |
let children = []; | |
if (nodeName === 'PRE') { | |
if (el.childNodes[0] && el.childNodes[0].nodeName === 'CODE') { | |
parent = el.childNodes[0] | |
} | |
console.log('deserialize:pre:', parent, parent.childNodes); | |
children = Array.from(parent.childNodes) | |
.map((e) => { | |
return { | |
text: e.textContent, | |
code: true, | |
} | |
}) | |
} else { | |
children = Array.from(parent.childNodes) | |
.map(deserialize) | |
.flat() | |
} | |
if (el.nodeName === 'BODY') { | |
return jsx('fragment', {}, children); | |
} | |
if (ELEMENT_TAGS[nodeName]) { | |
const attrs = ELEMENT_TAGS[nodeName](el); | |
return jsx('element', attrs, children) | |
} | |
if (TEXT_TAGS[nodeName]) { | |
const attrs = TEXT_TAGS[nodeName](el); | |
return children.map(child => jsx('text', attrs, child)) | |
} | |
return children | |
} | |
export default function RichEditor(props) { | |
const renderElement = useCallback(props => <Element {...props} />, []) | |
const renderLeaf = useCallback(props => <Leaf {...props} />, []) | |
const editor = useMemo( | |
() => withImages(withTables(withHtml(withReact(withHistory(createEditor()))))), | |
[] | |
) | |
let fileUploadRef = useRef(null) | |
let onChangeUpload = (e) => { | |
fnRemoteImage(editor, e.target.files) | |
} | |
useEffect(()=> { | |
props.editorRef(editor) | |
, []}); | |
return ( | |
<Slate editor={editor} | |
value={props.value} | |
onChange={newValue => props.onChange(newValue)}> | |
<Toolbar> | |
<MarkButton format="bold" icon="format_bold" /> | |
<MarkButton format="italic" icon="format_italic" /> | |
<MarkButton format="underline" icon="format_underlined" /> | |
<MarkButton format="code" icon="code" /> | |
<BlockButton format="heading-one" icon="heading-one" /> | |
<BlockButton format="heading-two" icon="heading-two" /> | |
<BlockButton format="heading-three" icon="heading-three" /> | |
<BlockButton format="block-quote" icon="format_quote" /> | |
<BlockButton format="numbered-list" icon="format_list_numbered" /> | |
<BlockButton format="bulleted-list" icon="format_list_bulleted" /> | |
<Button | |
onMouseDown={event => { | |
event.preventDefault() | |
fileUploadRef.current.click() | |
}} | |
> | |
<MdImage size="24" /> | |
</Button> | |
<input type="file" onChange={onChangeUpload} id="chooseFile" className="invisible" ref={fileUploadRef} /> | |
</Toolbar> | |
<Editable | |
className="slate-editor" | |
renderElement={renderElement} | |
renderLeaf={renderLeaf} | |
spellCheck | |
autoFocus | |
onKeyDown={event => { | |
for (const hotkey in HOTKEYS) { | |
if (isHotkey(hotkey, event)) { | |
event.preventDefault() | |
const mark = HOTKEYS[hotkey] | |
toggleMark(editor, mark) | |
} | |
} | |
}} | |
/> | |
</Slate> | |
) | |
}; | |
const fnRemoteImage = (editor, files) => { | |
for (const file of files) { | |
const [mime] = file.type.split('/'); | |
if (mime === 'image') { | |
const formData = new FormData(); | |
formData.append("image", file); | |
fetch("/api/note/upload", { | |
method: "POST", | |
body: formData | |
} | |
) | |
.then(response => response.json()) | |
.then(result => { | |
console.log(result); | |
insertImage(editor, result.data.uri) | |
}) | |
.catch(error => { | |
alert("Error:", error); | |
}); | |
} | |
} | |
} | |
const withImages = editor => { | |
const { insertData, isVoid } = editor | |
editor.isVoid = element => { | |
return element.type === 'image' ? true : isVoid(element) | |
} | |
editor.insertData = data => { | |
const text = data.getData('text/plain') | |
const { files } = data | |
console.log('insertData:', files) | |
if (files && files.length > 0) { | |
fnRemoteImage(editor, files) | |
} else if (isImageUrl(text)) { | |
insertImage(editor, text) | |
} else { | |
insertData(data) | |
} | |
} | |
return editor | |
} | |
const isImageUrl = url => { | |
if (!url) return false | |
if (!isUrl(url)) return false | |
const ext = new URL(url).pathname.split('.').pop() | |
return imageExtensions.includes(ext) | |
} | |
const insertImage = (editor, url) => { | |
const text = { text: '' } | |
const image = { type: 'image', url, children: [text] } | |
Transforms.insertNodes(editor, image) | |
} | |
const withTables = editor => { | |
const { deleteBackward, deleteForward, insertBreak } = editor | |
editor.deleteBackward = unit => { | |
const { selection } = editor | |
if (selection && Range.isCollapsed(selection)) { | |
const [cell] = Editor.nodes(editor, { | |
match: n => n.type === 'table-cell', | |
}) | |
if (cell) { | |
const [, cellPath] = cell | |
const start = Editor.start(editor, cellPath) | |
if (Point.equals(selection.anchor, start)) { | |
return | |
} | |
} | |
} | |
deleteBackward(unit) | |
} | |
editor.deleteForward = unit => { | |
const { selection } = editor | |
if (selection && Range.isCollapsed(selection)) { | |
const [cell] = Editor.nodes(editor, { | |
match: n => n.type === 'table-cell', | |
}) | |
if (cell) { | |
const [, cellPath] = cell | |
const end = Editor.end(editor, cellPath) | |
if (Point.equals(selection.anchor, end)) { | |
return | |
} | |
} | |
} | |
deleteForward(unit) | |
} | |
editor.insertBreak = () => { | |
const { selection } = editor | |
if (selection) { | |
const [table] = Editor.nodes(editor, { match: n => n.type === 'table' }) | |
if (table) { | |
return | |
} | |
} | |
insertBreak() | |
} | |
return editor | |
} | |
const withHtml = editor => { | |
const { insertData, isInline, isVoid } = editor | |
editor.isInline = element => { | |
return element.type === 'link' ? true : isInline(element) | |
} | |
editor.isVoid = element => { | |
return element.type === 'image' ? true : isVoid(element) | |
} | |
editor.insertData = data => { | |
const html = data.getData('text/html') | |
if (html) { | |
const parsed = new DOMParser().parseFromString(html, 'text/html') | |
const fragment = deserialize(parsed.body) | |
Transforms.insertFragment(editor, fragment) | |
return | |
} | |
insertData(data) | |
} | |
return editor | |
} | |
const Element = props => { | |
const { attributes, children, element } = props | |
switch (element.type) { | |
case 'thead': | |
return ( | |
<thead {...attributes}>{children}</thead> | |
) | |
case 'tbody': | |
return ( | |
<tbody {...attributes}>{children}</tbody> | |
) | |
case 'table': | |
return ( | |
<table {...attributes}>{children}</table> | |
) | |
case 'table-row': | |
return <tr {...attributes}>{children}</tr> | |
case 'table-cell': | |
return <td {...attributes}>{children}</td> | |
case 'table-cell-header': | |
return <th {...attributes}>{children}</th> | |
case 'header': | |
return <header {...attributes}>{children}</header> | |
case 'section': | |
return <section {...attributes}>{children}</section> | |
case 'block-quote': | |
return <blockquote {...attributes}>{children}</blockquote> | |
case 'code': | |
return ( | |
<pre> | |
<code {...attributes}>{children}</code> | |
</pre> | |
) | |
case 'bulleted-list': | |
return <ul {...attributes}>{children}</ul> | |
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 'list-item': | |
return <li {...attributes}>{children}</li> | |
case 'numbered-list': | |
return <ol {...attributes}>{children}</ol> | |
case 'link': | |
return ( | |
<a href={element.url} {...attributes}> | |
{children} | |
</a> | |
) | |
case 'image': | |
return <ImageElement {...props} /> | |
default: | |
return <p {...attributes}>{children}</p> | |
} | |
} | |
const ImageElement = ({ attributes, children, element }) => { | |
const selected = useSelected() | |
const focused = useFocused() | |
return ( | |
<div {...attributes}> | |
{children} | |
<img | |
src={element.url} | |
className={css` | |
display: block; | |
max-width: 100%; | |
max-height: 20em; | |
box-shadow: ${selected && focused ? '0 0 0 2px blue;' : 'none'}; | |
`} | |
/> | |
</div> | |
) | |
} | |
const Leaf = ({ 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> | |
} | |
if (leaf.strikethrough) { | |
children = <del>{children}</del> | |
} | |
return <span {...attributes}>{children}</span> | |
} | |
const BlockButton = ({ format, icon }) => { | |
const editor = useSlate(); | |
let iconCompo = null; | |
switch (icon) { | |
case 'heading-one': | |
iconCompo = (<MdLooksOne size={24} />); | |
break; | |
case 'heading-two': | |
iconCompo = (<MdLooksTwo size={24} />); | |
break; | |
case 'heading-three': | |
iconCompo = (<MdLooks3 size={24} />); | |
break; | |
case 'format_quote': | |
iconCompo = (<MdFormatQuote size={24} />); | |
break; | |
case 'format_list_numbered': | |
iconCompo = (<MdFormatListNumbered size={24} />); | |
break; | |
case 'format_list_bulleted': | |
iconCompo = (<MdFormatListBulleted size={24} />); | |
break; | |
} | |
return ( | |
<Button | |
active={isBlockActive(editor, format)} | |
onMouseDown={event => { | |
event.preventDefault() | |
toggleBlock(editor, format) | |
}} | |
> | |
{iconCompo} | |
</Button> | |
) | |
} | |
const MarkButton = ({ format, icon }) => { | |
const editor = useSlate(); | |
let iconCompo = null; | |
switch (icon) { | |
case 'format_bold': | |
iconCompo = (<MdFormatBold size={24} />); | |
break; | |
case 'format_italic': | |
iconCompo = (<MdFormatItalic size={24} />); | |
break; | |
case 'format_underlined': | |
iconCompo = (<MdFormatUnderlined size={24} />); | |
break; | |
case 'code': | |
iconCompo = (<MdCode size={24} />); | |
break; | |
} | |
return ( | |
<Button | |
active={isMarkActive(editor, format)} | |
onMouseDown={event => { | |
event.preventDefault() | |
toggleMark(editor, format) | |
}} | |
> | |
{iconCompo} | |
</Button> | |
) | |
} | |
const toggleBlock = (editor, format) => { | |
const isActive = isBlockActive(editor, format) | |
const isList = LIST_TYPES.includes(format) | |
Transforms.unwrapNodes(editor, { | |
match: n => LIST_TYPES.includes(n.type), | |
split: true, | |
}) | |
Transforms.setNodes(editor, { | |
type: isActive ? 'paragraph' : isList ? 'list-item' : format, | |
}) | |
if (!isActive && isList) { | |
const block = { type: format, children: [] } | |
Transforms.wrapNodes(editor, block) | |
} | |
} | |
const toggleMark = (editor, format) => { | |
const isActive = isMarkActive(editor, format) | |
if (isActive) { | |
Editor.removeMark(editor, format) | |
} else { | |
Editor.addMark(editor, format, true) | |
} | |
} | |
const isBlockActive = (editor, format) => { | |
const [match] = Editor.nodes(editor, { | |
match: n => n.type === format, | |
}) | |
return !!match | |
} | |
const isMarkActive = (editor, format) => { | |
const marks = Editor.marks(editor) | |
return marks ? marks[format] === true : false | |
} | |
export const Menu = React.forwardRef( | |
( | |
{ className, ...props }, | |
ref | |
) => ( | |
<div | |
{...props} | |
ref={ref} | |
className={cx( | |
className, | |
css` | |
& > * { | |
display: inline-block; | |
} | |
& > * + * { | |
margin-left: 15px; | |
} | |
` | |
)} | |
/> | |
) | |
) | |
export const Toolbar = React.forwardRef( | |
( | |
{ className, ...props }, | |
ref | |
) => ( | |
<Menu | |
{...props} | |
ref={ref} | |
className={cx( | |
className, | |
css` | |
position: relative; | |
padding: 1px 11px 8px; | |
border-bottom: 2px solid #eee; | |
` | |
)} | |
/> | |
) | |
) | |
export const Button = React.forwardRef( | |
( | |
{ | |
className, | |
active, | |
reversed, | |
...props | |
}, | |
ref | |
) => ( | |
<span | |
{...props} | |
ref={ref} | |
className={cx( | |
className, | |
css` | |
cursor: pointer; | |
color: ${reversed | |
? active | |
? 'white' | |
: '#aaa' | |
: active | |
? 'black' | |
: '#ccc'}; | |
` | |
)} | |
/> | |
) | |
) | |
export const Icon = React.forwardRef( | |
( | |
{ className, ...props }, | |
ref | |
) => ( | |
<span | |
{...props} | |
ref={ref} | |
className={cx( | |
'material-icons', | |
className, | |
css` | |
font-size: 18px; | |
vertical-align: text-bottom; | |
` | |
)} | |
/> | |
) | |
) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment