fastcomments-react-native-sdk
Version:
React Native FastComments Components. Add live commenting to any React Native application.
334 lines (313 loc) • 15.8 kB
text/typescript
import {ImmutableArray, ImmutableObject, none, State} from "@hookstate/core";
import {getNextNodeId} from "./node-id";
import {EditorNodeNewLine, EditorNodeType, EditorNodeWithoutChildren} from "./node-types";
import {graphToListStateWithoutNewlines, graphToListWithNewlines} from "./node-navigate";
import {deleteNodeRetainFocus} from "./node-delete";
import {createNewlineNode} from "./node-create";
import {focusNode} from "./node-focus";
export interface EditorFormatConfiguration {
/** How many characters does an image take up? Set to zero to disable validation. **/
imageLength?: number
/** How many characters does an emoticon take up? Set to zero to disable validation. **/
emoticonLength?: number
tokenize: (input: string) => (EditorNodeNewLine)[]
formatters: Record<EditorNodeType, (node: ImmutableObject<EditorNodeWithoutChildren | EditorNodeNewLine>, trimToLength?: number) => string>
}
export interface SupportedNodeDefinition {
start: string
end?: string
type: EditorNodeType
// internal
lookaheadIgnore: null | string[]
}
export type SupportedNodesTokenizerConfig = Record<string, SupportedNodeDefinition>;
export function stringToNodes(formatConfig: EditorFormatConfiguration, input: string): EditorNodeNewLine[] {
return formatConfig.tokenize(input);
}
export function getNodeLength(type: EditorNodeType, content: string | undefined, config: Pick<EditorFormatConfiguration, 'imageLength' | 'emoticonLength'>) {
if (type === EditorNodeType.EMOTICON) {
return config.emoticonLength || 0;
}
if (type === EditorNodeType.IMAGE) {
return config.imageLength || 0;
}
if (!content) {
return 0;
}
return content.length;
}
export function deleteNodeState(nodes: State<EditorNodeNewLine[]>, id: number) {
const index = nodes.findIndex((searchingNode) => searchingNode.id.get() === id);
console.log('Removing (index, id)', index, id);
nodes[index].set(none);
console.log('Done removing (index, id)', index, id);
}
/**
* Note: length is based on the content the user sees, not the resulting representation. You should handle this validation server-side.
*/
export function graphToString(graph: ImmutableArray<EditorNodeNewLine> | null, formatConfig: Pick<EditorFormatConfiguration, 'formatters' | 'imageLength' | 'emoticonLength'>, maxLength?: number | null): string {
let content = '';
if (!graph) {
return content;
}
let length = 0;
const nodes = graphToListWithNewlines(graph);
for (const node of nodes) {
if (!node || node === none) {
continue;
}
// It really sucks that we have to do so many hashmap lookups due to the performance overhead, but not sure how else to make the library
// be flexible. If you aren't getting the performance needed, maybe we could maintain a fork that uses a switch() to try to get the JIT to
// create a jump table. We can't maintain a reference to a function on EditorNodeDefinition because serializing functions breaks usehookstate.
const formattedValue = formatConfig.formatters[node.type](node);
// OPTIMIZATION checking maxLength before doing type + EditorNodeType property lookup
if (maxLength) {
length += getNodeLength(node.type, 'content' in node ? node.content : undefined, formatConfig);
}
content += formattedValue;
}
// OPTIMIZATION - we only do this *after* creating the content as it's a less common scenario than just typing within limits
if (maxLength && length > maxLength) {
// recalculate content and trim
content = '';
length = 0;
for (const node of nodes) {
if (!node || node === none) {
continue;
}
const nodeLength = getNodeLength(node.type, 'content' in node ? node.content : undefined, formatConfig);
// if adding this node would exceed the max length - add what we can and break.
if (length + nodeLength > maxLength) {
const remainingLength = maxLength - length;
if (remainingLength > 0) {
// console.log('???', node.type, typeof formatConfig.formatters[node.type]);
const trimmedFormattedValue = formatConfig.formatters[node.type](node, remainingLength);
if (trimmedFormattedValue) {
// if we wanted to, we could trim the node in the graph here, too, but this might be a weird experience for the user.
content += trimmedFormattedValue;
// OPTIMIZATION: we don't need to do anything here with length because we're going to stop iteration.
} else {
// OPTIMIZATION: we don't need to do anything here with length because we're going to stop iteration.
// empty image node does not make sense. We could remove the image here if we wanted to.
}
break;
} else {
// empty image node does not make sense. We could remove the image here if we wanted to.
// OPTIMIZATION: we don't need to do anything here with length because we're going to stop iteration.
break;
}
} else {
// console.log('???', node.type, typeof formatConfig.formatters[node.type]);
const fullFormattedValue = formatConfig.formatters[node.type](node);
content += fullFormattedValue;
}
}
}
console.log('END graph to string')
return content;
}
/**
* This function is not purely functional for performance reasons. That's why it takes a State<T>.
* Note: length is based on the content the user sees, not the resulting representation. You should handle this validation server-side.
*/
export function enforceMaxLength(graph: State<EditorNodeNewLine[]>, formatConfig: Pick<EditorFormatConfiguration, 'formatters' | 'imageLength' | 'emoticonLength'>, maxLength?: number | null): boolean {
let length = 0;
let isEmpty = true;
const nodes = graphToListStateWithoutNewlines(graph);
for (const node of nodes) {
const rawNode = node.get();
if (!rawNode || rawNode === none) {
continue;
}
let nodeContent = 'content' in rawNode ? rawNode.content : undefined;
if (nodeContent) {
isEmpty = false;
}
// It really sucks that we have to do so many hashmap lookups due to the performance overhead, but not sure how else to make the library
// be flexible. If you aren't getting the performance needed, maybe we could maintain a fork that uses a switch() to try to get the JIT to
// create a jump table. We can't maintain a reference to a function on EditorNodeDefinition because serializing functions breaks usehookstate.
// OPTIMIZATION checking maxLength before doing type + EditorNodeType property lookup
if (maxLength) {
length += getNodeLength(rawNode.type, nodeContent, formatConfig);
}
}
// OPTIMIZATION - we only do this *after* creating the content as it's a less common scenario than just typing within limits
if (maxLength && length > maxLength) {
// recalculate content and trim
length = 0;
for (const node of nodes) {
const rawNode = node.get();
if (!rawNode || rawNode === none) {
continue;
}
const nodeLength = getNodeLength(rawNode.type, 'content' in rawNode ? rawNode.content : undefined, formatConfig);
// if adding this node would exceed the max length - add what we can and break.
if (length + nodeLength > maxLength) {
const remainingLength = maxLength - length;
if (remainingLength > 0) {
const trimmedFormattedValue = formatConfig.formatters[rawNode.type](rawNode, remainingLength);
if (trimmedFormattedValue) {
node.content.set(trimmedFormattedValue);
// OPTIMIZATION: we don't need to do anything here with length because we're going to stop iteration.
} else {
graph.set((graph) => {
deleteNodeRetainFocus(graph, rawNode, rawNode); // example: empty image node does not make sense
return graph;
});
// OPTIMIZATION: we don't need to do anything here with length because we're going to stop iteration.
}
break;
} else {
graph.set((graph) => {
deleteNodeRetainFocus(graph, rawNode, rawNode); // example: empty image node does not make sense
return graph;
});
// OPTIMIZATION: we don't need to do anything here with length because we're going to stop iteration.
break;
}
}
}
}
return isEmpty;
}
export function hasContent(graph: ImmutableArray<EditorNodeNewLine>) {
for (const newline of graph) {
if (newline.children) {
for (const child of newline.children) {
if (child.content) {
return true;
}
}
}
}
return false;
}
// HACK
function isMention(type: EditorNodeType, text: string) {
return type === EditorNodeType.TEXT_BOLD && text.startsWith('@') && text.length < 300;
}
export function defaultTokenizer(input: string, SupportedNodes: SupportedNodesTokenizerConfig) {
console.log('calling defaultTokenizer', input);
const result: EditorNodeNewLine[] = [];
let buffer = '';
let inNode = null;
let currentNewLine = createNewlineNode([]);
const inputLength = input.length; // don't re-read input length on every iteration
for (let i = 0; i < inputLength; i++) {
buffer += input[i];
if (inNode) {
if (inNode.end && buffer.endsWith(inNode.end)) {
const content = buffer.substring(0, buffer.length - inNode.end.length);
currentNewLine.children!.push({
id: getNextNodeId(), // we do this so react knows to re-render via key, since we use id as key. If we just use an incrementing number here, react may not re-render for a whole new tree.
type: inNode.type,
content,
isFocused: false,
deleteOnBackspace: isMention(inNode.type, content)
});
inNode = null;
buffer = '';
}
} else {
for (const startToken in SupportedNodes) {
if (buffer.endsWith(startToken)) {
// @ts-ignore
const node = SupportedNodes[startToken];
if (node.lookaheadIgnore && input[i + 1] && node.lookaheadIgnore.some((ignore) => ignore === input[i + 1])) {
continue;
}
if (node.type === EditorNodeType.NEWLINE) {
const content = buffer.substring(0, buffer.length - startToken.length);
if (content.length > 0) {
currentNewLine.children!.push({
id: getNextNodeId(), // we do this so react knows to re-render via key, since we use id as key. If we just use an incrementing number here, react may not re-render for a whole new tree.
type: EditorNodeType.TEXT,
content,
isFocused: false,
deleteOnBackspace: isMention(node.type, content)
});
}
inNode = null;
buffer = '';
result.push(currentNewLine);
currentNewLine = createNewlineNode([]);
continue;
}
inNode = node;
if (buffer.length - startToken.length > 0) {
const content = buffer.substring(0, buffer.length - startToken.length);
currentNewLine.children!.push({
id: getNextNodeId(), // we do this so react knows to re-render via key, since we use id as key. If we just use an incrementing number here, react may not re-render for a whole new tree.
type: EditorNodeType.TEXT,
content,
isFocused: false
});
}
if (!node.end) { // some node types like newlines do not have ends
const content = buffer.substring(0, buffer.length - startToken.length);
currentNewLine.children!.push({
id: getNextNodeId(), // we do this so react knows to re-render via key, since we use id as key. If we just use an incrementing number here, react may not re-render for a whole new tree.
type: node.type,
content,
isFocused: false,
deleteOnBackspace: isMention(node.type, content)
});
inNode = null;
}
buffer = '';
}
}
}
}
if (buffer.length > 0) {
currentNewLine.children!.push({
id: getNextNodeId(), // we do this so react knows to re-render via key, since we use id as key. If we just use an incrementing number here, react may not re-render for a whole new tree.
type: EditorNodeType.TEXT,
content: buffer,
isFocused: false
});
result.push(currentNewLine);
}
if (result.length === 0) {
if (currentNewLine.children!.length === 0) {
const emptyTextNode = {
id: getNextNodeId(), // we do this so react knows to re-render via key, since we use id as key. If we just use an incrementing number here, react may not re-render for a whole new tree.
type: EditorNodeType.TEXT,
content: '',
isFocused: true
};
focusNode(emptyTextNode);
currentNewLine.children!.push(emptyTextNode);
}
result.push(currentNewLine);
}
console.log('Tokenizer', input, '->', JSON.stringify(result));
return result;
}
export function toTextTrimmed(node: ImmutableObject<EditorNodeNewLine | Pick<EditorNodeWithoutChildren, 'content'>>, startToken?: string | null, endToken?: string | null, trimToLength?: number) {
if (!('content' in node)) {
return '';
}
const result = trimToLength ? node.content.substring(0, trimToLength) : node.content;
if (!startToken && !endToken) {
return result;
}
// count spaces before and after, then add them before and after the tokens.
let spacesBefore = '';
for (let i = 0; i < result.length; i++) {
if (result[i] === ' ') {
spacesBefore += ' ';
} else {
break;
}
}
let spacesAfter = '';
for (let i = result.length; i > 0; i--) {
if (result[i] === ' ') {
spacesAfter += ' ';
} else {
break;
}
}
return spacesBefore + startToken + result.trim() + endToken + spacesAfter;
}