react-native-controlled-mentions
Version:
Fully controlled React Native mentions component
491 lines • 20.7 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.replaceTriggerValues = exports.getValueFromParts = exports.parseValue = exports.getTriggerValue = exports.generateTriggerPart = exports.generatePlainTextPart = exports.generateValueWithAddedSuggestion = exports.generateValueFromMentionStateAndChangedText = exports.getTriggerPartSuggestionKeywords = exports.getKeyword = exports.getPartsInterval = exports.getConfigsArray = exports.getTypedKeys = exports.getTextLength = exports.isTriggerConfig = void 0;
const diff_1 = require("diff");
const constraints_1 = require("./constraints");
const isCustomTriggerConfig = (config) => {
return config.pattern != null;
};
const isTriggerConfig = (config) => {
return config.trigger != null;
};
exports.isTriggerConfig = isTriggerConfig;
const getRegexFromConfig = (config) => {
if (isCustomTriggerConfig(config)) {
return config.pattern;
}
if (isTriggerConfig(config)) {
return constraints_1.singleGroupTriggerRegEx;
}
return config.pattern;
};
const getTextLength = (text) => text.length;
exports.getTextLength = getTextLength;
const getTextSubstring = (text, start, end) => text.slice(start, end);
const getPartIndexByCursor = (parts, cursor, isIncludeEnd) => {
return parts.findIndex((one) => cursor >= one.position.start && isIncludeEnd
? cursor <= one.position.end
: cursor < one.position.end);
};
/**
* Helper that returns typed array of object's keys
*
* @param obj
*/
const getTypedKeys = (obj) => Object.keys(obj);
exports.getTypedKeys = getTypedKeys;
/**
* Combines object configs into array
*
* @param triggersConfig
* @param patternsConfig
*/
const getConfigsArray = (triggersConfig, patternsConfig) => {
const triggersArray = triggersConfig
? getTypedKeys(triggersConfig).map((trigger) => triggersConfig[trigger])
: [];
const patternsArray = patternsConfig
? getTypedKeys(patternsConfig).map((pattern) => patternsConfig[pattern])
: [];
return [...triggersArray, ...patternsArray];
};
exports.getConfigsArray = getConfigsArray;
/**
* The method for getting parts between two cursor positions.
* ```
* | part1 | part2 | part3 |
* a b c|d e f g h i j h k|l m n o
* ```
* We will get 3 parts here:
* 1. Part included 'd'
* 2. Part included 'efghij'
* 3. Part included 'hk'
* Cursor will move to position after 'k'
*
* @param parts full part list
* @param cursor current cursor position
* @param count count of characters that didn't change
*/
const getPartsInterval = (parts, cursor, count) => {
const newCursor = cursor + count;
const currentPartIndex = getPartIndexByCursor(parts, cursor);
const currentPart = parts[currentPartIndex];
const newPartIndex = getPartIndexByCursor(parts, newCursor, true);
const newPart = parts[newPartIndex];
let partsInterval = [];
if (!currentPart || !newPart) {
return partsInterval;
}
// Push whole first affected part or sub-part of the first affected part
if (currentPart.position.start === cursor && currentPart.position.end <= newCursor) {
partsInterval.push(currentPart);
}
else {
partsInterval.push(generatePlainTextPart(getTextSubstring(currentPart.text, cursor - currentPart.position.start, cursor - currentPart.position.start + count)));
}
if (newPartIndex > currentPartIndex) {
// Concat fully included parts
partsInterval = partsInterval.concat(parts.slice(currentPartIndex + 1, newPartIndex));
// Push whole last affected part or sub-part of the last affected part
if (newPart.position.end === newCursor && newPart.position.start >= cursor) {
partsInterval.push(newPart);
}
else {
partsInterval.push(generatePlainTextPart(getTextSubstring(newPart.text, 0, newCursor - newPart.position.start)));
}
}
return partsInterval;
};
exports.getPartsInterval = getPartsInterval;
/**
* We need to be sure, that before trigger we have at least a space or beginning of row.
* This helper allows us to check this.
*
* @param text
*/
const getIsPrevPartTextSliceAcceptableForTrigger = (text) => {
return !text || /[\s\n]/gi.test(text[text.length - 1]);
};
const getKeyword = ({ mentionState, selection, config, }) => {
var _a, _b;
// Check if we don't have selection range
if (selection.end != selection.start) {
return;
}
// Find the part with the cursor
const part = mentionState.parts.find((one) => selection.end > one.position.start && selection.end <= one.position.end);
// Check if the cursor is not in mention type part
if (part == null || part.data != null) {
return;
}
const partTextDividedByTrigger = part.text.split(config.trigger);
// If we didn't have trigger in the part with cursor
if (partTextDividedByTrigger.length === 0) {
return undefined;
}
const cursor = selection.end;
const firstPartTextSlice = (_a = partTextDividedByTrigger.shift()) !== null && _a !== void 0 ? _a : '';
// We don't need to search for a keyword in the first part (before first trigger)
// So we shifting it and calculating initial local cursor
let localCursor = part.position.start + getTextLength(firstPartTextSlice + config.trigger);
let keyword = undefined;
let isPrevPartTextSliceAcceptableForTrigger = getIsPrevPartTextSliceAcceptableForTrigger(firstPartTextSlice);
while (localCursor <= cursor) {
const nextPartTextSlice = partTextDividedByTrigger.shift();
if (nextPartTextSlice != null) {
const nextPlainTextPartLength = getTextLength(nextPartTextSlice);
localCursor += nextPlainTextPartLength;
if (localCursor >= cursor) {
// If we don't have space or beginning of row before string
if (!isPrevPartTextSliceAcceptableForTrigger) {
return undefined;
}
const difference = localCursor - cursor;
const charactersAfterTriggerLength = nextPlainTextPartLength - difference;
const nextPartTextDividedBySpaces = getTextSubstring(nextPartTextSlice, 0, charactersAfterTriggerLength).split(' ');
// Check if we don't have too mach spaces between trigger and cursor
if (nextPartTextDividedBySpaces.length <=
((_b = config.allowedSpacesCount) !== null && _b !== void 0 ? _b : constraints_1.DEFAULT_ALLOWED_SPACES_COUNT) + 1) {
keyword = getTextSubstring(nextPartTextSlice, 0, charactersAfterTriggerLength);
}
}
localCursor += getTextLength(config.trigger);
isPrevPartTextSliceAcceptableForTrigger =
getIsPrevPartTextSliceAcceptableForTrigger(nextPartTextSlice);
}
}
return keyword;
};
exports.getKeyword = getKeyword;
/**
* Function for getting object with keyword for each mention part type
*
* If keyword is undefined then we don't tracking mention typing and shouldn't show suggestions.
* If keyword is not undefined (even empty string '') then we are tracking mention typing.
*
* Examples where @name is just plain text yet, not mention:
* '|abc @name dfg' - keyword is undefined
* 'abc @| dfg' - keyword is ''
* 'abc @name| dfg' - keyword is 'name'
* 'abc @na|me dfg' - keyword is 'na'
* 'abc @|name dfg' - keyword is against ''
* 'abc @name |dfg' - keyword is 'name '
* 'abc @name dfg|' - keyword is 'name dfg'
* 'abc @name dfg |' - keyword is undefined (we have more than one space)
* 'abc @name dfg he|' - keyword is undefined (we have more than one space)
*
* // ToDo — refactor to object params
*/
const getTriggerPartSuggestionKeywords = (mentionState, selection, triggersConfig, onChange) => {
const keywordByTrigger = {};
getTypedKeys(triggersConfig).forEach((triggerName) => {
const config = triggersConfig[triggerName];
keywordByTrigger[triggerName] = {
keyword: undefined,
/**
* Callback on mention suggestion press. We should:
* - Get updated value
* - Trigger onChange callback with new value
*
* @param suggestion
*/
onSelect: (suggestion) => {
const newValue = generateValueWithAddedSuggestion(mentionState, selection, config, suggestion);
if (!newValue) {
return;
}
onChange === null || onChange === void 0 ? void 0 : onChange(newValue);
/**
* ToDo — test is this still not working
*
* Move cursor to the end of just added mention starting from trigger string and including:
* - Length of trigger string
* - Length of mention name
* - Length of space after mention (1)
*
* Not working now due to the RN bug
*/
// const newCursorPosition = currentPart.position.start + triggerPartIndex + trigger.length +
// suggestion.name.length + 1;
// textInput.current?.setNativeProps({selection: {start: newCursorPosition, end: newCursorPosition}});
},
};
keywordByTrigger[triggerName].keyword = getKeyword({
mentionState,
selection,
config,
});
});
return keywordByTrigger;
};
exports.getTriggerPartSuggestionKeywords = getTriggerPartSuggestionKeywords;
/**
* Generates new value when we are changing text.
*
* @param mentionState
* @param changedText changed plain text
*/
const generateValueFromMentionStateAndChangedText = (mentionState, changedText) => {
const { parts, plainText } = mentionState;
const changes = (0, diff_1.diffChars)(plainText, changedText);
let newParts = [];
let cursor = 0;
changes.forEach((change) => {
switch (true) {
/**
* We should:
* - Move cursor forward on the changed text length
*/
case change.removed: {
cursor += change.count;
break;
}
/**
* We should:
* - Push new part to the parts with that new text
*/
case change.added: {
newParts.push(generatePlainTextPart(change.value));
break;
}
/**
* We should concat parts that didn't change.
* - In case when we have only one affected part we should push only that one sub-part
* - In case we have two affected parts we should push first
*/
default: {
if (change.count !== 0) {
newParts = newParts.concat(getPartsInterval(parts, cursor, change.count));
cursor += change.count;
}
break;
}
}
});
return getValueFromParts(newParts);
};
exports.generateValueFromMentionStateAndChangedText = generateValueFromMentionStateAndChangedText;
/**
* Method for adding suggestion to the parts and generating value. We should:
* - Find part with plain text where we were tracking mention typing using selection state
* - Split the part to next parts:
* -* Before new mention
* -* With new mention
* -* After mention with space at the beginning
* - Generate new parts array and convert it to value
*
* @param mentionState - current mention state with parts and plainText
* @param selection - current selection
* @param triggerConfig - actually the mention type
* @param suggestion - suggestion that should be added
*/
const generateValueWithAddedSuggestion = (mentionState, selection, triggerConfig, suggestion) => {
var _a;
const { parts, plainText } = mentionState;
const currentPartIndex = parts.findIndex((one) => selection.end >= one.position.start && selection.end <= one.position.end);
const currentPart = parts[currentPartIndex];
if (!currentPart) {
return;
}
const triggerPartIndex = currentPart.text.lastIndexOf(triggerConfig.trigger, selection.end - currentPart.position.start);
const newMentionPartPosition = {
start: triggerPartIndex,
end: selection.end - currentPart.position.start,
};
const isInsertSpaceToNextPart = triggerConfig.isInsertSpaceAfterMention &&
// Cursor is at the very end of parts or text row
(getTextLength(plainText) === selection.end ||
((_a = parts[currentPartIndex]) === null || _a === void 0 ? void 0 : _a.text.startsWith('\n', newMentionPartPosition.end)));
return getValueFromParts([
...parts.slice(0, currentPartIndex),
// Create part with string before mention
generatePlainTextPart(getTextSubstring(currentPart.text, 0, newMentionPartPosition.start)),
generateTriggerPart(triggerConfig, Object.assign({ original: getTriggerValue(triggerConfig, suggestion), trigger: triggerConfig.trigger }, suggestion)),
// Create part with rest of string after mention and add a space if needed
generatePlainTextPart(`${isInsertSpaceToNextPart ? ' ' : ''}${getTextSubstring(currentPart.text, newMentionPartPosition.end)}`),
...parts.slice(currentPartIndex + 1),
]);
};
exports.generateValueWithAddedSuggestion = generateValueWithAddedSuggestion;
/**
* Method for generating part for plain text
*
* @param text - plain text that will be added to the part
* @param positionOffset - position offset from the very beginning of text
*/
const generatePlainTextPart = (text, positionOffset = 0) => ({
text,
position: {
start: positionOffset,
end: positionOffset + getTextLength(text),
},
});
exports.generatePlainTextPart = generatePlainTextPart;
/**
* Method for generating part for mention
*
* @param triggerConfig
* @param triggerData - mention data
* @param positionOffset - position offset from the very beginning of text
*/
const generateTriggerPart = (triggerConfig, triggerData, positionOffset = 0) => {
const text = getTriggerPlainString(triggerConfig, triggerData);
return {
text,
position: {
start: positionOffset,
end: positionOffset + getTextLength(text),
},
config: triggerConfig,
data: triggerData,
};
};
exports.generateTriggerPart = generateTriggerPart;
/**
* Generates part for matched regex result
*
* @param config - current part type (pattern or mention)
* @param matchPlainText
* @param positionOffset - position offset from the very beginning of text
*/
const generateRegexResultPart = (config, matchPlainText, positionOffset = 0) => ({
text: matchPlainText,
position: {
start: positionOffset,
end: positionOffset + getTextLength(matchPlainText),
},
config,
});
/**
* Method for generation mention value that accepts mention regex
*
* @param triggerConfig
* @param suggestion
*/
const getTriggerValue = (triggerConfig, suggestion) => {
if (isCustomTriggerConfig(triggerConfig)) {
return triggerConfig.getTriggerValue(suggestion);
}
return `{${triggerConfig.trigger}}[${suggestion.name}](${suggestion.id})`;
};
exports.getTriggerValue = getTriggerValue;
const getTriggerPlainString = (config, triggerData) => {
if (config.getPlainString != null) {
return config.getPlainString(triggerData);
}
return `${config.trigger}${triggerData.name}`;
};
const getMentionDataFromRegExMatchResult = ([, original, trigger, name, id,]) => ({
original,
trigger,
name,
id,
});
// ToDo – write own logic for parsing mention match
const getMentionDataFromRegExMatch = (matchPlainText) => {
const regexExecResult = constraints_1.triggerRegEx.exec(matchPlainText);
return regexExecResult ? getMentionDataFromRegExMatchResult(regexExecResult) : null;
};
/**
* Recursive function for deep parse MentionInput's value and get plainText with parts
*
* ToDo – move all utility helpers to a class
* @param value - the MentionInput's value
* @param configs - All provided part types
* @param positionOffset - offset from the very beginning of plain text
*/
const parseValue = (value, configs, positionOffset = 0) => {
if (value == null) {
value = '';
}
let plainText = '';
let parts = [];
// We don't have any part types so adding just plain text part
if (configs.length === 0) {
plainText += value;
parts.push(generatePlainTextPart(value, positionOffset));
}
else {
const [config, ...restConfigs] = configs;
// It's important to use regex with one group that includes complete mention part
const regex = getRegexFromConfig(config);
// We are dividing value by regex with one whole mention group
// Each odd item will be matching value
const dividedValueByRegex = value.split(regex);
// In case when we have only one element in array – matches are not present in this text
// So continue parsing value with rest part types
if (dividedValueByRegex.length === 1) {
return parseValue(value, restConfigs, positionOffset);
}
const textBeforeFirstMatch = dividedValueByRegex[0];
// In case when we have some text before matched part parsing the text with rest part types
if (textBeforeFirstMatch) {
const plainTextAndParts = parseValue(textBeforeFirstMatch, restConfigs, positionOffset);
parts = parts.concat(plainTextAndParts.parts);
plainText += plainTextAndParts.plainText;
}
for (let i = 1; i < dividedValueByRegex.length; i += 2) {
const nextMatchValue = dividedValueByRegex[i];
if (isTriggerConfig(config)) {
const getTriggerData = isCustomTriggerConfig(config)
? config.getTriggerData
: getMentionDataFromRegExMatch;
const triggerData = getTriggerData(nextMatchValue);
// We are generating trigger part:
// - When we have parsed mention data
// - When this data relates to needed trigger
if (triggerData != null && triggerData.trigger === config.trigger) {
const part = generateTriggerPart(config, triggerData, positionOffset + getTextLength(plainText));
parts.push(part);
plainText += part.text;
// In other cases we should parse the mention with rest part types
}
else {
const plainTextAndParts = parseValue(nextMatchValue, restConfigs, positionOffset + getTextLength(plainText));
parts = parts.concat(plainTextAndParts.parts);
plainText += plainTextAndParts.plainText;
}
}
else {
const part = generateRegexResultPart(config, nextMatchValue, positionOffset + getTextLength(plainText));
parts.push(part);
plainText += part.text;
}
const textAfterMatch = dividedValueByRegex[i + 1];
// Check if we have a text after last matched part
// We should parse the text with rest part types
if (textAfterMatch) {
const plainTextAndParts = parseValue(textAfterMatch, restConfigs, positionOffset + getTextLength(plainText));
parts = parts.concat(plainTextAndParts.parts);
plainText += plainTextAndParts.plainText;
}
}
}
// Exiting from parseValue
return {
plainText,
parts,
};
};
exports.parseValue = parseValue;
/**
* Function for generation value from parts array
*
* @param parts
*/
const getValueFromParts = (parts) => parts.map((item) => (item.data ? item.data.original : item.text)).join('');
exports.getValueFromParts = getValueFromParts;
/**
* Replace all mention values in value to some specified format
*
* @param value - value that is generated by MentionInput component
* @param replacer - function that takes mention object as parameter and returns string
*/
const replaceTriggerValues = (value, replacer) => value.replace(RegExp(constraints_1.triggerRegEx, 'g'), (fullMatch, original, trigger, name, id) => replacer({
original,
trigger,
name,
id,
}));
exports.replaceTriggerValues = replaceTriggerValues;
//# sourceMappingURL=helpers.js.map