Created
October 16, 2019 18:49
-
-
Save blindibrasil/0823834ba65b87d89239ab2497592355 to your computer and use it in GitHub Desktop.
Slate - Metions - Error
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, { useState, useRef, useEffect } from 'react'; | |
import { Link } from 'react-router-dom'; | |
import Html from 'slate-html-serializer'; | |
import { Editor, getEventTransfer } from 'slate-react'; | |
import { Value } from 'slate'; | |
import { toast } from 'react-toastify'; | |
import { isKeyHotkey } from 'is-hotkey'; | |
import _ from 'lodash'; | |
import people from './Suggestions/users.json'; | |
import Suggestions from './Suggestions'; | |
import initialValue from '../value.json'; | |
import newTextValue from '../newTextValue.json'; | |
const DEFAULT_NODE = 'paragraph'; | |
const isBoldHotkey = isKeyHotkey('mod+b'); | |
const isItalicHotkey = isKeyHotkey('mod+i'); | |
const isUnderlinedHotkey = isKeyHotkey('mod+u'); | |
const isCodeHotkey = isKeyHotkey('mod+`'); | |
const BLOCK_TAGS = { | |
p: 'paragraph', | |
li: 'list-item', | |
ul: 'bulleted-list', | |
ol: 'numbered-list', | |
blockquote: 'quote', | |
pre: 'code', | |
h1: 'heading-one', | |
h2: 'heading-two', | |
h3: 'heading-three', | |
h4: 'heading-four', | |
h5: 'heading-five', | |
h6: 'heading-six', | |
}; | |
const MARK_TAGS = { | |
strong: 'bold', | |
em: 'italic', | |
u: 'underline', | |
s: 'strikethrough', | |
code: 'code', | |
}; | |
const rules = [ | |
{ | |
deserialize(el, next) { | |
const block = BLOCK_TAGS[el.tagName.toLowerCase()]; | |
if (block) { | |
return { | |
object: 'block', | |
type: block, | |
nodes: next(el.childNodes), | |
}; | |
} | |
}, | |
}, | |
{ | |
deserialize(el, next) { | |
const mark = MARK_TAGS[el.tagName.toLowerCase()]; | |
if (mark) { | |
return { | |
object: 'mark', | |
type: mark, | |
nodes: next(el.childNodes), | |
}; | |
} | |
}, | |
}, | |
{ | |
deserialize(el, next) { | |
if (el.tagName.toLowerCase() === 'pre') { | |
const code = el.childNodes[0]; | |
const childNodes = | |
code && code.tagName.toLowerCase() === 'code' | |
? code.childNodes | |
: el.childNodes; | |
return { | |
object: 'block', | |
type: 'code', | |
nodes: next(childNodes), | |
}; | |
} | |
}, | |
}, | |
{ | |
// Special case for images, to grab their src. | |
deserialize(el, next) { | |
if (el.tagName.toLowerCase() === 'img') { | |
return { | |
object: 'block', | |
type: 'image', | |
nodes: next(el.childNodes), | |
data: { | |
src: el.getAttribute('src'), | |
}, | |
}; | |
} | |
}, | |
}, | |
{ | |
// Special case for links, to grab their href. | |
deserialize(el, next) { | |
if (el.tagName.toLowerCase() === 'a') { | |
return { | |
object: 'inline', | |
type: 'link', | |
nodes: next(el.childNodes), | |
data: { | |
href: el.getAttribute('href'), | |
}, | |
}; | |
} | |
}, | |
}, | |
]; | |
const serializer = new Html({ rules }); | |
// Mettions | |
const USER_MENTION_NODE_TYPE = 'userMention'; | |
const CONTEXT_ANNOTATION_TYPE = 'mentionContext'; | |
let n = 0; | |
function getMentionKey() { | |
return `highlight_${n++}`; | |
} | |
const schema = { | |
inlines: { | |
[USER_MENTION_NODE_TYPE]: { | |
isVoid: true, | |
}, | |
}, | |
}; | |
const CAPTURE_REGEX = /@(\S*)$/; | |
function getInput(value) { | |
if (!value.startText) { | |
return null; | |
} | |
const startOffset = value.selection.start.offset; | |
const textBefore = value.startText.text.slice(0, startOffset); | |
const result = CAPTURE_REGEX.exec(textBefore); | |
return result == null ? null : result[1]; | |
} | |
function hasValidAncestors(value) { | |
const { document, selection } = value; | |
return document.getParent(selection.start.key).type === 'paragraph'; | |
} | |
// End Mettions | |
export default function TextEditor({ id }) { | |
const editorRef = useRef({}); | |
const [editorValue, setEditorValue] = useState(Value.fromJSON(newTextValue)); | |
const [users, setUsers] = useState([]); | |
useEffect(() => { | |
async function getDocument() { | |
try { | |
const response = await api.get(`/documents/${id}`); | |
setEditorValue(Value.fromJSON(response.data.content)); | |
} catch (err) { | |
setEditorValue(Value.fromJSON(initialValue)); | |
} | |
} | |
getDocument(); | |
}, [id]); | |
function hasMark(type) { | |
return editorValue.activeMarks.some(mark => mark.type === type); | |
} | |
function hasBlock(type) { | |
return editorValue.blocks.some(node => node.type === type); | |
} | |
function hasList(type) { | |
let isActive = hasBlock(type); | |
const { document, blocks } = editorValue; | |
if (['numbered-list', 'bulleted-list'].includes(type)) { | |
if (blocks.size > 0) { | |
const parent = document.getParent(blocks.first().key); | |
isActive = hasBlock('list-item') && parent && parent.type === type; | |
} | |
} | |
return isActive; | |
} | |
function onClickMark(event, type) { | |
event.preventDefault(); | |
editorRef.current.toggleMark(type); | |
} | |
function onClickBlock(event, type) { | |
event.preventDefault(); | |
if (type !== 'bulleted-list' && type !== 'numbered-list') { | |
const isActive = hasBlock(type); | |
const isList = hasBlock('list-item'); | |
if (isList) { | |
editorRef.current | |
.setBlocks(isActive ? DEFAULT_NODE : type) | |
.wrapBlock('bulleted-list') | |
.wrapBlock('numbered-list'); | |
} else { | |
editorRef.current.setBlocks(isActive ? DEFAULT_NODE : type); | |
} | |
} else { | |
const isList = hasBlock('list-item'); | |
const isType = editorValue.blocks.some(block => { | |
return !!editorValue.document.getClosest( | |
block.key, | |
parent => parent.type === type | |
); | |
}); | |
if (isList && isType) { | |
editorRef.current | |
.setBlocks(DEFAULT_NODE) | |
.unwrapBlock('bulleted-list') | |
.unwrapBlock('numbered-list'); | |
} else if (isList) { | |
editorRef.current | |
.unwrapBlock( | |
type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list' | |
) | |
.wrapBlock(type); | |
} else { | |
editorRef.current.setBlocks('list-item').wrapBlock(type); | |
} | |
} | |
} | |
function onPaste(event, editor, next) { | |
const transfer = getEventTransfer(event); | |
if (transfer.type !== 'html') return next(); | |
const { document } = serializer.deserialize(transfer.html); | |
return editor.insertFragment(document); | |
} | |
function renderBlock(props, editor, next) { | |
const { attributes, children, node } = props; | |
switch (node.type) { | |
case 'paragraph': | |
return <p {...attributes}>{children}</p>; | |
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 'bulleted-list': | |
return <ul {...attributes}>{children}</ul>; | |
case 'list-item': | |
return <li {...attributes}>{children}</li>; | |
case 'numbered-list': | |
return <ol {...attributes}>{children}</ol>; | |
default: | |
return next(); | |
} | |
} | |
function renderMark(props, editor, next) { | |
const { children, mark, attributes } = props; | |
switch (mark.type) { | |
case 'bold': | |
return <strong {...attributes}>{children}</strong>; | |
case 'code': | |
return <code {...attributes}>{children}</code>; | |
case 'italic': | |
return <em {...attributes}>{children}</em>; | |
case 'underlined': | |
return <u {...attributes}>{children}</u>; | |
default: | |
return next(); | |
} | |
} | |
function renderAnnotation(props, editor, next) { | |
if (props.annotation.type === CONTEXT_ANNOTATION_TYPE) { | |
return ( | |
<span {...props.attributes} className="mention-context"> | |
{props.children} | |
</span> | |
); | |
} | |
return next(); | |
} | |
function renderInline(props, editor, next) { | |
const { attributes, node } = props; | |
if (node.type === USER_MENTION_NODE_TYPE) { | |
return <b {...attributes}>{props.node.text}</b>; | |
} | |
return next(); | |
} | |
function onKeyDown(event, editor, next) { | |
let mark; | |
if (isBoldHotkey(event)) { | |
mark = 'bold'; | |
} else if (isItalicHotkey(event)) { | |
mark = 'italic'; | |
} else if (isUnderlinedHotkey(event)) { | |
mark = 'underlined'; | |
} else if (isCodeHotkey(event)) { | |
mark = 'code'; | |
} else { | |
return next(); | |
} | |
event.preventDefault(); | |
return editor.toggleMark(mark); | |
} | |
function insertMention(user) { | |
const value = editorValue; | |
const inputValue = getInput(value); | |
const editor = editorRef.current; | |
editor.deleteBackward(inputValue.length + 1); | |
const selectedRange = editor.value.selection; | |
editor | |
.insertText(' ') | |
.insertInlineAtRange(selectedRange, { | |
data: { | |
userId: user.id, | |
username: user.username, | |
}, | |
nodes: [ | |
{ | |
object: 'text', | |
text: `@${user.username}`, | |
marks: [], | |
}, | |
], | |
type: USER_MENTION_NODE_TYPE, | |
}) | |
.focus(); | |
} | |
function search(searchQuery) { | |
setUsers([]); | |
if (!searchQuery) return; | |
setTimeout(() => { | |
const regex = RegExp(`^${searchQuery}`, 'gi'); | |
const result = people.filter(user => { | |
return user.username.match(regex); | |
}); | |
setUsers({ | |
users: result.slice(0, 5), | |
}); | |
}, 50); | |
} | |
function onChange(value) { | |
if (value.document !== editorValue.document) { | |
const content = JSON.stringify(value.toJSON()); | |
localStorage.setItem('content', content); | |
} | |
const inputValue = getInput(value); | |
let lastInputValue = null; | |
if (inputValue !== lastInputValue) { | |
lastInputValue = inputValue; | |
if (hasValidAncestors(value)) { | |
search(inputValue); | |
} | |
const { selection } = value; | |
let annotations = value.annotations.filter( | |
annotation => annotation.type !== CONTEXT_ANNOTATION_TYPE | |
); | |
if (inputValue && hasValidAncestors(value)) { | |
const key = getMentionKey(); | |
annotations = annotations.set(key, { | |
anchor: { | |
key: selection.start.key, | |
offset: selection.start.offset - inputValue.length, | |
}, | |
focus: { | |
key: selection.start.key, | |
offset: selection.start.offset, | |
}, | |
type: CONTEXT_ANNOTATION_TYPE, | |
key: getMentionKey(), | |
}); | |
} | |
setEditorValue(value, () => { | |
editorRef.current.setAnnotations(annotations); | |
}); | |
return; | |
} | |
setEditorValue(value); | |
} | |
return ( | |
<Container> | |
<ContentEditor> | |
<Toolbar> | |
<Button | |
active={hasMark('bold')} | |
onMouseDown={event => onClickMark(event, 'bold')} | |
> | |
<MdFormatBold size={20} /> | |
</Button> | |
<Button | |
active={hasMark('italic')} | |
onMouseDown={event => onClickMark(event, 'italic')} | |
> | |
<MdFormatItalic size={20} /> | |
</Button> | |
<Button | |
active={hasMark('underlined')} | |
onMouseDown={event => onClickMark(event, 'underlined')} | |
> | |
<MdFormatUnderlined size={20} /> | |
</Button> | |
<Button | |
active={hasMark('code')} | |
onMouseDown={event => onClickMark(event, 'code')} | |
> | |
<MdCode size={20} /> | |
</Button> | |
<Button | |
active={hasBlock('heading-one')} | |
onMouseDown={event => onClickBlock(event, 'heading-one')} | |
> | |
<MdTitle size={20} /> | |
</Button> | |
<Button | |
active={hasBlock('heading-two')} | |
onMouseDown={event => onClickBlock(event, 'heading-two')} | |
> | |
<MdTitle size={15} /> | |
</Button> | |
<Button | |
active={hasBlock('block-quote')} | |
onMouseDown={event => onClickBlock(event, 'block-quote')} | |
> | |
<MdFormatQuote size={20} /> | |
</Button> | |
<Button | |
active={hasList('numbered-list')} | |
onMouseDown={event => onClickBlock(event, 'numbered-list')} | |
> | |
<MdFormatListNumbered size={20} /> | |
</Button> | |
<Button | |
active={hasList('bulleted-list')} | |
onMouseDown={event => onClickBlock(event, 'bulleted-list')} | |
> | |
<MdFormatListBulleted size={20} /> | |
</Button> | |
</Toolbar> | |
<Scroll> | |
<Editor | |
spellCheck | |
autoFocus | |
className="editor-root" | |
ref={editorRef} | |
value={editorValue} | |
onPaste={onPaste} | |
onChange={change => onChange(change.value)} | |
onKeyDown={onKeyDown} | |
renderBlock={renderBlock} | |
renderMark={renderMark} | |
renderInline={renderInline} | |
renderAnnotation={renderAnnotation} | |
schema={schema} | |
/> | |
<Suggestions | |
anchor=".mention-context" | |
users={users} | |
onSelect={insertMention} | |
/> | |
</Scroll> | |
</ContentEditor> | |
</Container> | |
); | |
} |
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, { useRef, useState, useEffect } from 'react'; | |
import ReactDOM from 'react-dom'; | |
import { css } from 'emotion'; | |
const SuggestionList = React.forwardRef((props, ref) => ( | |
<ul | |
{...props} | |
ref={ref} | |
className={css` | |
background: #fff; | |
list-style: none; | |
margin: 0; | |
padding: 0; | |
position: absolute; | |
`} | |
/> | |
)); | |
const Suggestion = props => ( | |
<li | |
{...props} | |
className={css` | |
align-items: center; | |
border-left: 1px solid #ddd; | |
border-right: 1px solid #ddd; | |
border-top: 1px solid #ddd; | |
display: flex; | |
height: 32px; | |
padding: 4px 8px; | |
&:hover { | |
background: #87cefa; | |
} | |
&:last-of-type { | |
border-bottom: 1px solid #ddd; | |
} | |
`} | |
/> | |
); | |
const DEFAULT_POSITION = { | |
top: -10000, | |
left: -10000, | |
}; | |
// import { Container } from './styles'; | |
export default function Suggestions({ anchor, users, onSelect }) { | |
const menuRef = useRef({}); | |
const [position, setPosition] = useState(DEFAULT_POSITION); | |
const root = window.document.getElementById('root'); | |
useEffect(() => { | |
function updateMenu() { | |
const anchorSelector = window.document.querySelector(anchor); | |
if (!anchorSelector) { | |
setPosition(DEFAULT_POSITION); | |
} | |
const anchorRect = anchorSelector.getBoundingClientRect(); | |
setPosition({ | |
top: anchorRect.bottom + window.pageYOffset, | |
left: anchorRect.left + window.pageXOffset, | |
}); | |
} | |
updateMenu(); | |
}, [anchor]); | |
console.tron.log(position); | |
const { top, left } = position; | |
const data = users.users; | |
return ReactDOM.createPortal( | |
<SuggestionList | |
ref={menuRef} | |
style={{ | |
top, | |
left, | |
zIndex: '10', | |
cursor: 'pointer', | |
}} | |
> | |
{data && data.length > 0 | |
? data.map(user => { | |
return ( | |
<Suggestion key={user.id} onClick={() => onSelect(user)}> | |
{user.username} | |
</Suggestion> | |
); | |
}) | |
: ''} | |
</SuggestionList>, | |
root | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment