Skip to content

Instantly share code, notes, and snippets.

@RStankov
Created October 22, 2020 19:02
Show Gist options
  • Save RStankov/eff2196a48c9441391accac8f44ad127 to your computer and use it in GitHub Desktop.
Save RStankov/eff2196a48c9441391accac8f44ad127 to your computer and use it in GitHub Desktop.
Draft.js Markdown
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,
},
];
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>
);
}
}
.editor {
min-height: 100px;
cursor: text;
}
.focused {
outline: 1px dotted #212121;
outline: 3px auto -webkit-focus-ring-color;
}
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);
}
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