Created
October 22, 2020 19:02
-
-
Save RStankov/eff2196a48c9441391accac8f44ad127 to your computer and use it in GitHub Desktop.
Draft.js Markdown
This file contains hidden or 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 * as React from 'react'; | |
const BOLD_REGEX = /(^|[^\w])\*\*[^\*]+\*\*($|[^\w])/gi; | |
const ITALIC_REGEX = /(^|[^\w])(^|[^\*])\*[^\*]+\*($|[^\w])/gi; | |
const UNDERLINE_REGEX = /(^|[^\w])(^|[^\~])\~[^\~]+\~($|[^\w])/gi; | |
const STIKE_REGEX = /(^|[^\w])\~\~[^\~]+\~\~($|[^\w])/gi; | |
function boldStrategy(contentBlock: any, callback: any, _contentState: any) { | |
findWithRegex(BOLD_REGEX, contentBlock, callback); | |
} | |
function italicStrategy(contentBlock: any, callback: any, _contentState: any) { | |
findWithRegex(ITALIC_REGEX, contentBlock, callback); | |
} | |
function underlineStrategy( | |
contentBlock: any, | |
callback: any, | |
_contentState: any, | |
) { | |
findWithRegex(UNDERLINE_REGEX, contentBlock, callback); | |
} | |
function strikeStrategy(contentBlock: any, callback: any, _contentState: any) { | |
findWithRegex(STIKE_REGEX, contentBlock, callback); | |
} | |
function findWithRegex(regex: any, contentBlock: any, callback: any) { | |
const text = contentBlock.getText(); | |
let matchArr: any; | |
// tslint:disable-next-line | |
while ((matchArr = regex.exec(text)) !== null) { | |
const start = matchArr.index; | |
callback(start, start + matchArr[0].length); | |
} | |
} | |
const Bold = (props: any) => { | |
return <strong data-offset-key={props.offsetKey}>{props.children}</strong>; | |
}; | |
const Italic = (props: any) => { | |
return <em data-offset-key={props.offsetKey}>{props.children}</em>; | |
}; | |
const Underline = (props: any) => { | |
return <u data-offset-key={props.offsetKey}>{props.children}</u>; | |
}; | |
const Strike = (props: any) => { | |
return ( | |
<s data-offset-key={props.offsetKey} style={{ color: 'silver' }}> | |
{props.children} | |
</s> | |
); | |
}; | |
export default [ | |
{ | |
strategy: boldStrategy, | |
component: Bold, | |
}, | |
{ | |
strategy: italicStrategy, | |
component: Italic, | |
}, | |
{ | |
strategy: underlineStrategy, | |
component: Underline, | |
}, | |
{ | |
strategy: strikeStrategy, | |
component: Strike, | |
}, | |
]; |
This file contains hidden or 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 * as React from 'react'; | |
import * as utils from './utils'; | |
import decorators from './decorators'; | |
import { debounce } from 'lodash'; | |
import classNames from 'classnames'; | |
import styles from './styles.module.css'; | |
import { | |
Editor, | |
EditorState, | |
ContentState, | |
CompositeDecorator, | |
DraftHandleValue, | |
} from 'draft-js'; | |
interface IProps { | |
value: string; | |
id?: string; | |
name?: string; | |
onChange?: (value: string) => void; | |
withInputRef?: (ref: EditorInput) => void; | |
onFocus?: () => void; | |
onBlur?: () => void; | |
} | |
interface IState { | |
editorState: EditorState; | |
isFocused: boolean; | |
} | |
export default class EditorInput extends React.Component<IProps, IState> { | |
state = { | |
editorState: utils.editorStateWithKey(this.editorKey()), | |
isFocused: false, | |
}; | |
ref: Editor | null = null; | |
setRef = (ref: Editor) => { | |
this.ref = ref; | |
}; | |
editorKey() { | |
return this.props.id || this.props.name || 'editor'; | |
} | |
constructor(props: IProps) { | |
super(props); | |
if (props.withInputRef) { | |
props.withInputRef(this); | |
} | |
} | |
focus = () => { | |
if (this.state.isFocused) { | |
return; | |
} | |
this.ref?.focus(); | |
this.setState({ | |
editorState: EditorState.moveFocusToEnd(this.state.editorState), | |
}); | |
document.getElementById(`editor-${this.editorKey()}`)?.scrollIntoView(); | |
}; | |
componentDidMount() { | |
this.setState({ | |
editorState: EditorState.createWithContent( | |
ContentState.createFromText(this.props.value || ''), | |
new CompositeDecorator(decorators), | |
), | |
}); | |
} | |
syncChange = debounce(() => { | |
if (this.props.onChange) { | |
this.props.onChange( | |
this.state.editorState.getCurrentContent().getPlainText(), | |
); | |
} | |
}, 200); | |
onChange = (editorState: any) => { | |
this.setState({ editorState }); | |
if (this.props.onChange) { | |
this.syncChange(); | |
} | |
}; | |
onFocus = () => { | |
this.setState({ isFocused: true }); | |
if (this.props.onFocus) { | |
this.props.onFocus(); | |
} | |
}; | |
onBlur = () => { | |
this.setState({ isFocused: false }); | |
if (this.props.onBlur) { | |
this.props.onBlur(); | |
} | |
}; | |
handleReturn = (): DraftHandleValue => { | |
this.onChange(utils.insertNewLine(this.state.editorState)); | |
return 'handled'; | |
}; | |
handleKeyCommand = (command: string): DraftHandleValue => { | |
const newState = utils.command(command, this.state.editorState); | |
if (newState) { | |
this.onChange(newState); | |
return 'handled'; | |
} | |
return 'not-handled'; | |
}; | |
render() { | |
return ( | |
<div | |
id={`editor-${this.editorKey()}`} | |
onClick={this.focus} | |
className={classNames( | |
'border rounded p-2', | |
this.state.isFocused && styles.focused, | |
styles.editor, | |
)}> | |
<Editor | |
ref={this.setRef} | |
editorKey={this.editorKey()} | |
editorState={this.state.editorState} | |
onChange={this.onChange} | |
onFocus={this.onFocus} | |
onBlur={this.onBlur} | |
handleReturn={this.handleReturn} | |
handleKeyCommand={this.handleKeyCommand} | |
keyBindingFn={utils.keyBindingFn} | |
/> | |
</div> | |
); | |
} | |
} |
This file contains hidden or 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
.editor { | |
min-height: 100px; | |
cursor: text; | |
} | |
.focused { | |
outline: 1px dotted #212121; | |
outline: 3px auto -webkit-focus-ring-color; | |
} |
This file contains hidden or 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 { EditorState, ContentState, SelectionState, Modifier } from 'draft-js'; | |
import * as utils from './utils'; | |
describe('new line', () => { | |
it('inserts a new line', () => { | |
const before = editorWithText(''); | |
const after = utils.insertNewLine(before); | |
expectTextWithSelection(after).toEqual('|\n'); | |
}); | |
it('inserts a new line at the same level of indentation for list item (*)', () => { | |
const text = ' * line item|'; | |
const before = editorWithText(text); | |
const after = utils.insertNewLine(before); | |
expectText(after).toEqual(' * line item\n * '); | |
}); | |
it('inserts a new line at the same level of indentation for list item (-)', () => { | |
const text = ' - line item|'; | |
const before = editorWithText(text); | |
const after = utils.insertNewLine(before); | |
expectText(after).toEqual(' - line item\n - '); | |
}); | |
it('clears list indentation when line is empty', () => { | |
const text = ' - |'; | |
const before = editorWithText(text); | |
const after = utils.insertNewLine(before); | |
expectTextWithSelection(after).toEqual('|'); | |
}); | |
it('replaces selected content with a new line', () => { | |
const text = '|hello]'; | |
const before = editorWithText(text); | |
const after = utils.insertNewLine(before); | |
expectTextWithSelection(after).toEqual('\n|'); | |
}); | |
}); | |
describe('commands', () => { | |
describe('indent forward', () => { | |
it('indent line with spaces', () => { | |
const text = 'text'; | |
const before = editorWithText(text); | |
const after = utils.command('indentForward', before); | |
expectText(after).toEqual(' ' + text); | |
}); | |
it('knows which correct line', () => { | |
const text = 'line 1\nline 2|'; | |
const before = editorWithText(text); | |
const after = utils.command('indentForward', before); | |
expectTextWithSelection(after).toEqual('line 1\n line 2|'); | |
}); | |
}); | |
describe('indent backward', () => { | |
it('removes indentation', () => { | |
const text = '| text'; | |
const before = editorWithText(text); | |
const after = utils.command('indentBackward', before); | |
expectTextWithSelection(after).toEqual('| text'); | |
}); | |
it('only removes white space', () => { | |
const text = '| text'; | |
const before = editorWithText(text); | |
const after = utils.command('indentBackward', before); | |
expectTextWithSelection(after).toEqual('|text'); | |
}); | |
}); | |
describe('bold', () => { | |
it('inserts empty ** pair', () => { | |
const text = ''; | |
const before = editorWithText(text); | |
let after = utils.command('bold', before); | |
expectTextWithSelection(after).toEqual('**|**'); | |
after = EditorState.createWithContent( | |
Modifier.insertText( | |
after.getCurrentContent(), | |
after.getSelection(), | |
'text', | |
), | |
); | |
expectText(after).toEqual('**text**'); | |
}); | |
it('wraps with **', () => { | |
const text = '|text]'; | |
const before = editorWithText(text); | |
let after = utils.command('bold', before); | |
expectTextWithSelection(after).toEqual('**|text]**'); | |
after = EditorState.createWithContent( | |
Modifier.replaceText( | |
after.getCurrentContent(), | |
after.getSelection(), | |
'updated', | |
), | |
); | |
expectText(after).toEqual('**updated**'); | |
}); | |
it('wraps with ** (in reverse)', () => { | |
const text = ']text|'; | |
const before = editorWithText(text); | |
const after = utils.command('bold', before); | |
expectTextWithSelection(after).toEqual('**]text|**'); | |
}); | |
it('can unwrap with ** (collapsed)', () => { | |
const text = '**other** **text| text** **other**'; | |
const before = editorWithText(text); | |
const after = utils.command('bold', before); | |
expectTextWithSelection(after).toEqual('**other** text| text **other**'); | |
}); | |
it('can unwrap with ** (non collapsed)', () => { | |
const text = '|**text**] **other**'; | |
const before = editorWithText(text); | |
const after = utils.command('bold', before); | |
expectTextWithSelection(after).toEqual('|text] **other**'); | |
}); | |
it('can unwrap with ** (non collapsed, reverse)', () => { | |
const text = ']**text**| **other**'; | |
const before = editorWithText(text); | |
const after = utils.command('bold', before); | |
expectTextWithSelection(after).toEqual(']text| **other**'); | |
}); | |
}); | |
}); | |
function editorWithText(text: string) { | |
const currentContent = ContentState.createFromText( | |
text.replace('|', '').replace(']', ''), | |
); | |
let anchorOffset = text.indexOf('|'); | |
let focusOffset = text.indexOf(']'); | |
if (anchorOffset === -1) { | |
anchorOffset = focusOffset = 0; | |
} else if (focusOffset === -1) { | |
focusOffset = anchorOffset; | |
} else if (focusOffset > anchorOffset) { | |
focusOffset -= 1; | |
} else { | |
anchorOffset -= 1; | |
} | |
return EditorState.create({ | |
currentContent, | |
selection: SelectionState.createEmpty( | |
currentContent | |
.getBlockMap() | |
.last() | |
.getKey(), | |
).merge({ | |
anchorOffset, | |
focusOffset, | |
isBackward: anchorOffset > focusOffset, | |
}), | |
}); | |
} | |
function expectText(editorState: EditorState) { | |
return expect(editorState.getCurrentContent().getPlainText()); | |
} | |
export function expectTextWithSelection(editorState: EditorState) { | |
return expect(textWithSelection(editorState)); | |
} | |
function textWithSelection(editorState: EditorState) { | |
const text = editorState.getCurrentContent().getPlainText(); | |
const selection = editorState.getSelection(); | |
if (selection.isCollapsed()) { | |
return addChatAt(text, selection.getStartOffset(), '|'); | |
} | |
if (selection.getIsBackward()) { | |
return addChatAt( | |
addChatAt(text, selection.getFocusOffset(), ']'), | |
selection.getAnchorOffset() + 1, | |
'|', | |
); | |
} else { | |
return addChatAt( | |
addChatAt(text, selection.getAnchorOffset(), '|'), | |
selection.getFocusOffset() + 1, | |
']', | |
); | |
} | |
} | |
function addChatAt(text: string, index: number, char: string) { | |
return text.slice(0, index) + char + text.slice(index, text.length); | |
} |
This file contains hidden or 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 Draft from 'draft-js'; | |
export function editorStateWithKey(key: string) { | |
return Draft.EditorState.createWithContent( | |
Draft.convertFromRaw({ | |
entityMap: {}, | |
blocks: [ | |
{ | |
text: '', | |
key, | |
type: 'unstyled', | |
entityRanges: [], | |
depth: 0, | |
inlineStyleRanges: [], | |
}, | |
], | |
}), | |
); | |
} | |
const KEY_MAP = { | |
tab: 9, | |
'[': 219, | |
']': 221, | |
b: 66, | |
i: 73, | |
u: 85, | |
E: 69, | |
}; | |
const COMMAND_KEY_MAP = { | |
[KEY_MAP[']']]: 'indentForward', | |
[KEY_MAP['[']]: 'indentBackward', | |
[KEY_MAP.b]: 'bold', | |
[KEY_MAP.i]: 'italic', | |
[KEY_MAP.u]: 'underline', | |
[KEY_MAP.E]: 'strike', | |
}; | |
export function keyBindingFn(e: any): string | null { | |
if (Draft.KeyBindingUtil.hasCommandModifier(e)) { | |
const commandName = COMMAND_KEY_MAP[e.keyCode]; | |
if (commandName) { | |
return commandName; | |
} | |
} | |
if (e.keyCode === KEY_MAP.tab) { | |
return 'indentForward'; | |
} | |
return Draft.getDefaultKeyBinding(e); | |
} | |
const INDENT = ' '; | |
/* | |
* command | |
* name: | |
* fn: | |
* trigger: | |
*/ | |
const COMMANDS = { | |
indentForward(editorState: Draft.EditorState) { | |
let content; | |
const selection = editorState.getSelection(); | |
if (selection.isCollapsed()) { | |
content = Draft.Modifier.insertText( | |
editorState.getCurrentContent(), | |
updateSelection(selection, { offset: 0 }), | |
INDENT, | |
); | |
} else { | |
// TODO(rstankov): Move entire block | |
return editorState; | |
} | |
return updateEditor( | |
editorState, | |
content, | |
updateSelection(selection, { by: INDENT.length }), | |
'insert-characters', | |
); | |
}, | |
indentBackward(editorState: Draft.EditorState) { | |
let content; | |
const selection = editorState.getSelection(); | |
if (selection.isCollapsed()) { | |
const line = selectedLine(editorState); | |
let i = INDENT.length; | |
for (i = INDENT.length; i > 0; i--) { | |
if (line[i - 1] === ' ') { | |
break; | |
} | |
} | |
if (!i) { | |
return editorState; | |
} | |
content = Draft.Modifier.removeRange( | |
editorState.getCurrentContent(), | |
updateSelection(selection, { anchor: 0, focus: i }), | |
'backward', | |
); | |
} else { | |
// TODO(rstankov): Move entire block | |
return editorState; | |
} | |
return updateEditor( | |
editorState, | |
content, | |
updateSelection(selection, { by: -INDENT.length }), | |
'remove-range', | |
); | |
}, | |
bold: wrapCommand('**'), | |
italic: wrapCommand('*'), | |
underline: wrapCommand('~'), | |
strike: wrapCommand('~~'), | |
}; | |
export function command(commandName: string, editorState: Draft.EditorState) { | |
if ((COMMANDS as any)[commandName]) { | |
return (COMMANDS as any)[commandName](editorState); | |
} | |
} | |
export function insertNewLine(editorState: Draft.EditorState) { | |
let content; | |
const selection = editorState.getSelection(); | |
if (selection.isCollapsed()) { | |
const line = selectedLine(editorState); | |
if (line.match(/^((?:( )*|\t*)(\*|-) )$/)) { | |
// todo remove-range | |
content = Draft.Modifier.removeRange( | |
editorState.getCurrentContent(), | |
updateSelection(selection, { anchorBy: -line.length }), | |
'backward', | |
); | |
} else { | |
content = Draft.Modifier.splitBlock( | |
editorState.getCurrentContent(), | |
editorState.getSelection(), | |
); | |
const indent = lineIndent(selectedLine(editorState)); | |
if (indent) { | |
content = Draft.Modifier.insertText( | |
content, | |
Draft.SelectionState.createEmpty( | |
content.getBlockAfter(selection.getStartKey()).getKey(), | |
), | |
indent, | |
); | |
} | |
} | |
} else { | |
content = Draft.Modifier.replaceText( | |
editorState.getCurrentContent(), | |
selection, | |
'\n', | |
); | |
} | |
return updateEditor( | |
editorState, | |
content, | |
content.getSelectionAfter(), | |
'insert-characters', | |
); | |
} | |
function selectedLine(editorState: Draft.EditorState) { | |
const selection = editorState.getSelection(); | |
return editorState | |
.getCurrentContent() | |
.getBlockForKey(selection.getStartKey()) | |
.getText(); | |
} | |
const LIST_REGEX = /^((?:( )*|\t*)(\*|-) )/; | |
function lineIndent(line: string | null) { | |
if (!line) { | |
return ''; | |
} | |
const match = line.match(LIST_REGEX); | |
if (!match) { | |
return ''; | |
} | |
return match[0]; | |
} | |
function updateSelection( | |
selection: Draft.SelectionState, | |
update: { | |
anchorBy?: number; | |
focusBy?: number; | |
by?: number; | |
anchor?: number; | |
focus?: number; | |
offset?: number; | |
} = {}, | |
) { | |
if (typeof update.offset !== 'undefined') { | |
update.anchor = update.offset; | |
update.focus = update.offset; | |
} | |
if (typeof update.anchor !== 'undefined') { | |
selection = selection.set( | |
'anchorOffset', | |
update.anchor, | |
) as Draft.SelectionState; | |
} | |
if (typeof update.focus !== 'undefined') { | |
selection = selection.set( | |
'focusOffset', | |
update.focus, | |
) as Draft.SelectionState; | |
} | |
if (typeof update.by !== 'undefined') { | |
update.anchorBy = update.by; | |
update.focusBy = update.by; | |
} | |
if (typeof update.anchorBy !== 'undefined') { | |
selection = selection.set( | |
'anchorOffset', | |
Math.max(0, selection.getAnchorOffset() + update.anchorBy), | |
) as Draft.SelectionState; | |
} | |
if (typeof update.focusBy !== 'undefined') { | |
selection = selection.set( | |
'focusOffset', | |
Math.max(0, selection.getFocusOffset() + update.focusBy), | |
) as Draft.SelectionState; | |
} | |
const isBackward = selection.getAnchorOffset() > selection.getFocusOffset(); | |
if (selection.getIsBackward() !== isBackward) { | |
selection = selection.set('isBackward', isBackward) as Draft.SelectionState; | |
} | |
return selection; | |
} | |
function updateEditor( | |
editorState: Draft.EditorState, | |
content: Draft.ContentState, | |
selection: Draft.SelectionState, | |
operation: 'insert-characters' | 'remove-range', | |
) { | |
return Draft.EditorState.forceSelection( | |
Draft.EditorState.push(editorState, content, operation), | |
selection, | |
); | |
} | |
function wrapCommand(symbol: string) { | |
return (editorState: Draft.EditorState) => { | |
const selection = editorState.getSelection(); | |
let content = editorState.getCurrentContent(); | |
// NOTE(rstankov): Don't handle multilane for now | |
if ( | |
content.getBlockForKey(selection.getStartKey()) !== | |
content.getBlockForKey(selection.getEndKey()) | |
) { | |
return editorState; | |
} | |
const newState = unwrap(editorState, symbol); | |
if (newState) { | |
return newState; | |
} | |
if (selection.isCollapsed()) { | |
content = Draft.Modifier.insertText(content, selection, symbol); | |
content = Draft.Modifier.insertText( | |
content, | |
updateSelection(selection, { by: symbol.length }), | |
symbol, | |
); | |
} else { | |
content = Draft.Modifier.insertText( | |
content, | |
updateSelection(selection, { offset: selection.getStartOffset() }), | |
symbol, | |
); | |
content = Draft.Modifier.insertText( | |
content, | |
updateSelection(selection, { | |
offset: selection.getEndOffset() + symbol.length, | |
}), | |
symbol, | |
); | |
} | |
return updateEditor( | |
editorState, | |
content, | |
updateSelection(selection, { by: symbol.length }), | |
'insert-characters', | |
); | |
}; | |
} | |
function unwrap(editorState: Draft.EditorState, symbol: string) { | |
const selection = editorState.getSelection(); | |
let content = editorState.getCurrentContent(); | |
const text = content.getBlockForKey(selection.getStartKey()).getText(); | |
const cutOffset = selection.isCollapsed() | |
? selection.getStartOffset() | |
: Math.min( | |
selection.getStartOffset() + symbol.length, | |
selection.getEndOffset(), | |
); | |
const symbolStartOffset = getSymbolStartOffset(text, cutOffset, symbol); | |
if (symbolStartOffset === -1) { | |
return; | |
} | |
const symbolEndOffset = getSymobolEndOffset(text, cutOffset, symbol); | |
if (symbolEndOffset === -1) { | |
return; | |
} | |
content = Draft.Modifier.removeRange( | |
content, | |
updateSelection(selection, { | |
anchor: symbolEndOffset - symbol.length, | |
focus: symbolEndOffset, | |
}), | |
'backward', | |
); | |
content = Draft.Modifier.removeRange( | |
content, | |
updateSelection(selection, { | |
anchor: symbolStartOffset, | |
focus: symbolStartOffset + symbol.length, | |
}), | |
'backward', | |
); | |
return updateEditor( | |
editorState, | |
content, | |
updateSelection(selection, { | |
by: selection.isCollapsed() ? -symbol.length : -symbol.length * 2, | |
}), | |
'remove-range', | |
); | |
} | |
function getSymbolStartOffset( | |
text: string, | |
offset: number, | |
symbol: string, | |
): number { | |
const match = text | |
.substr(0, offset) | |
.match(new RegExp(`${escapeRegExp(symbol)}[^${escapeRegExp(symbol)}]*$`)); | |
if (!match) { | |
return -1; | |
} | |
return match.index as number; | |
} | |
function getSymobolEndOffset( | |
text: string, | |
offset: number, | |
symbol: string, | |
): number { | |
const match = text | |
.substr(offset, text.length) | |
.match(new RegExp(`^[^${escapeRegExp(symbol)}]*${escapeRegExp(symbol)}`)); | |
if (!match) { | |
return -1; | |
} | |
return (match.index as number) + match[0].length + offset; | |
} | |
function escapeRegExp(value: string) { | |
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment