wix-style-react
Version:
347 lines (330 loc) • 10.7 kB
JavaScript
import React from 'react';
import {
CompositeDecorator,
convertToRaw,
convertFromRaw,
genKey,
EditorState,
SelectionState,
Modifier,
} from 'draft-js';
import Tag from '../Tag';
import { entityTypes, dataHooks } from './constants';
import { classes } from './VariableInput.st.css';
const insertContent = (editorState, selectionOffset, modifyFn) => {
let contentState = editorState.getCurrentContent();
let selectionState = editorState.getSelection();
if (!selectionState.isCollapsed()) {
// Remove selection range when adding content
contentState = Modifier.removeRange(
contentState,
selectionState,
'backward',
);
selectionState = contentState.getSelectionAfter();
}
const newContent = modifyFn(contentState, selectionState);
const newEditorState = EditorState.push(
editorState,
newContent,
'insert-characters',
);
const newSelection = newEditorState.getSelection();
return _moveSelectionTo(
newEditorState,
newSelection.getAnchorKey(),
newSelection.getAnchorOffset() + selectionOffset,
);
};
/** Insert text in current cursor position */
const insertText = (editorState, text) =>
insertContent(editorState, 0, (contentState, selectionState) =>
Modifier.insertText(contentState, selectionState, text),
);
/** Insert new entity in current cursor position, with the given text and value */
const insertEntity = (editorState, { text, value }) =>
insertContent(editorState, 1, (contentState, selectionState) => {
contentState = contentState.createEntity(
entityTypes.variable,
'IMMUTABLE',
{
value,
text,
},
);
const entityKey = contentState.getLastCreatedEntityKey();
contentState = Modifier.insertText(contentState, selectionState, ' '); // space after entity
contentState = Modifier.insertText(
contentState,
selectionState,
` ${text} `,
null,
entityKey,
);
return contentState;
});
const _escapeRegExp = text => text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
/** Get variable with given prefix and suffix in the given string */
const getMatchesInString = (str, prefix, suffix) => {
const escPrefixFirstChar = _escapeRegExp(prefix[0]);
const escPrefix = _escapeRegExp(prefix);
const escSuffix = _escapeRegExp(suffix);
const pattern = `(?:${escPrefixFirstChar})*(${escPrefix}(.*?)${escSuffix})`;
const regex = new RegExp(pattern, 'g');
let part;
const parts = [];
while ((part = regex.exec(str)) !== null) {
parts.push(part);
}
return parts;
};
/** Check if editor has unparsed entities */
const hasUnparsedEntity = (editorState, prefix, suffix) => {
return (
getMatchesInString(
editorState.getCurrentContent().getPlainText(),
prefix,
suffix,
).length > 0
);
};
/** Convert editor content state, to string with placeholders instead of entities */
const convertToString = ({ editorState, prefix, suffix }) => {
const rawJS = convertToRaw(editorState.getCurrentContent());
const rawString = rawJS.blocks
.map(block => {
const baseString = Array.from(block.text);
let indexOffset = 0;
block.entityRanges.forEach(entityRange => {
const entity = rawJS.entityMap[entityRange.key.toString()];
if (entity.type !== entityTypes.variable) {
return baseString;
}
const entityData = entity.data;
const placeholder = prefix + entityData.value + suffix;
baseString.splice(
entityRange.offset + indexOffset,
entityRange.length,
placeholder,
);
indexOffset += 1 - entityRange.length;
});
return baseString.join('');
})
.join('\n');
return rawString;
};
/** Convert string to editor content state */
const stringToContentState = ({
str = '',
variableParser = () => {},
prefix = '',
suffix = '',
}) => {
let entityIndex = 0;
const entityMap = [];
const blocks = str.split('\n').map(row => {
let rowStr = row;
let indexOffset = 0;
const entityRanges = [];
getMatchesInString(row, prefix, suffix).forEach(match => {
const [wholeMatch, placeholder, value] = match;
const matchIndex = match.index + wholeMatch.indexOf(placeholder);
const text = variableParser(value) || false;
if (text) {
const utfOffset = getUtfOffsetValue(row.substr(0, matchIndex));
const contentPlaceholder = ` ${text} `;
const offset = matchIndex + indexOffset - utfOffset;
rowStr = rowStr.replace(placeholder, contentPlaceholder);
entityRanges.push({
offset,
length: contentPlaceholder.length,
key: entityIndex,
});
entityMap[entityIndex.toString()] = {
type: entityTypes.variable,
mutability: 'IMMUTABLE',
data: { value, text },
};
entityIndex++;
indexOffset += contentPlaceholder.length - placeholder.length;
}
});
return {
key: genKey(),
text: rowStr,
type: 'unstyled',
depth: 0,
inlineStyleRanges: [],
entityRanges: entityRanges,
data: {},
};
});
return convertFromRaw({
blocks,
entityMap,
});
};
const decoratorFactory = ({ tag: { size, disabled } }) => {
return new CompositeDecorator([
{
strategy: (contentBlock, callback) => {
contentBlock.findEntityRanges(character => {
const entityKey = character.getEntity();
return entityKey === null;
}, callback);
},
component: ({ offsetKey, children }) => {
return (
<span data-offset-key={offsetKey} className={classes.textWrapper}>
{children}
</span>
);
},
},
{
strategy: (contentBlock, callback, contentState) => {
contentBlock.findEntityRanges(character => {
const entityKey = character.getEntity();
return (
entityKey !== null &&
contentState.getEntity(entityKey).getType() === entityTypes.variable
);
}, callback);
},
component: props => {
const { offsetKey, contentState, entityKey } = props;
const { text } = contentState.getEntity(entityKey).getData();
/** We adding a space before and after the Tag,
* to prevent from the cursor to enter the Tag while moving it. */
return (
<span
data-offset-key={offsetKey}
contentEditable={false}
className={classes.tagWrapper}
>
<span className={classes.textWrapper}> </span>
<Tag
id={`variableinput-tag-${entityKey}`}
dataHook={dataHooks.tag}
removable={false}
size={size}
disabled={disabled}
{...(!disabled && { theme: 'dark' })}
>
{text}
</Tag>
<span className={classes.textWrapper} />{' '}
</span>
);
},
},
]);
};
/** When pushing content to EditorState selection is resets, we keep the selection and reset it after push */
const pushAndKeepSelection = ({ editorState, content }) => {
const selectionStateBefore = editorState.getSelection();
const blockIndex = Object.keys(
editorState.getCurrentContent().getBlockMap().toJS(),
).indexOf(selectionStateBefore.getAnchorKey());
const updatedEditorState = EditorState.push(editorState, content);
const blockMap = updatedEditorState.getCurrentContent().getBlockMap().toJS();
const blockKeys = Object.keys(blockMap);
const blockKey = blockKeys[blockIndex];
const blockOffset = selectionStateBefore.getAnchorOffset();
if (!blockKey || blockOffset > blockMap[blockKey].text.length) {
// Block not exists in new array, getting the last block and move to end
return EditorState.moveSelectionToEnd(updatedEditorState);
}
// Keep selection in the same location after updating content, keys are updating
const selectionAfter = updatedEditorState.getSelection().merge({
anchorKey: blockKey,
anchorOffset: blockOffset,
focusKey: blockKey,
focusOffset: blockOffset,
hasFocus: false,
});
return EditorState.acceptSelection(updatedEditorState, selectionAfter);
};
/** Move selection to edge of entity */
const moveToEdge = (editorState, forceEnd = false) => {
const selectionOffset = editorState.getSelection().getFocusOffset();
const key = editorState.getSelection().getFocusKey();
const jumpToIndex = _findEntityEdgeIndex(
editorState.getCurrentContent(),
key,
selectionOffset,
forceEnd,
);
if (jumpToIndex !== selectionOffset) {
return _moveSelectionTo(editorState, key, jumpToIndex);
}
return editorState;
};
const _moveSelectionTo = (editorState, key, offset) => {
const selection = new SelectionState({
anchorKey: key,
anchorOffset: offset,
focusKey: key,
focusOffset: offset,
});
return EditorState.forceSelection(editorState, selection);
};
const _findEntityEdgeIndex = (
currentContent,
selectionKey,
startIndex,
forceEnd,
) => {
const characterList = currentContent
.getBlockForKey(selectionKey)
.getCharacterList()
.toJS();
const entityKey =
characterList[startIndex] && characterList[startIndex].entity;
if (!entityKey) {
return startIndex;
}
let beforeOffset = startIndex;
for (; beforeOffset >= 0; beforeOffset--) {
if (characterList[beforeOffset].entity !== entityKey) {
beforeOffset++;
break;
}
}
beforeOffset = Math.max(beforeOffset, 0);
let afterOffset = characterList.findIndex(
(value, index) => index >= startIndex && value.entity !== entityKey,
);
if (afterOffset === -1) {
afterOffset = characterList.length;
}
if (!forceEnd && beforeOffset === startIndex) {
// In case we clicked just at the beginning
return beforeOffset;
}
return afterOffset;
};
/** Returns true if content has changed */
const isContentChanged = (editorStateBefore, editorStateAfter) =>
editorStateBefore.getCurrentContent().getPlainText() !==
editorStateAfter.getCurrentContent().getPlainText();
/** Returns true if state lost focus */
const isBlured = (editorStateBefore, editorStateAfter) =>
editorStateBefore.getSelection().getHasFocus() &&
!editorStateAfter.getSelection().getHasFocus();
const getUtfOffsetValue = aString =>
aString.split('').length - Array.from(aString).length;
export default {
insertText,
insertEntity,
getMatchesInString,
convertToString,
stringToContentState,
decoratorFactory,
pushAndKeepSelection,
hasUnparsedEntity,
moveToEdge,
isContentChanged,
isBlured,
};