Skip to content

Instantly share code, notes, and snippets.

@blindibrasil
Created October 16, 2019 18:49
Show Gist options
  • Save blindibrasil/0823834ba65b87d89239ab2497592355 to your computer and use it in GitHub Desktop.
Save blindibrasil/0823834ba65b87d89239ab2497592355 to your computer and use it in GitHub Desktop.
Slate - Metions - Error
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>
);
}
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