fastcomments-react-native-sdk
Version:
React Native FastComments Components. Add live commenting to any React Native application.
354 lines (353 loc) • 17.8 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Pressable, StyleSheet, View } from "react-native";
import { EditorNode } from "./editor-node";
import { useHookstate, useHookstateEffect } from "@hookstate/core";
import { useRef } from "react";
import { deleteNodeState } from "./editor-node-transform";
import { createBoldNode, createEmoticonNode, createStrikethroughNode, createUnderlineNode } from "./editor-nodes";
import { getLastFocusedState, getNext, getStateLast, getStateNext, getStatePrev, } from "./node-navigate";
import { insertAfter, insertBefore } from "./node-insert";
import { deleteNodeRetainFocus } from "./node-delete";
import { focusNode, focusNodeState } from "./node-focus";
import { createImageNode, createNewlineNode, createTextNode } from "./node-create";
import { EditorNodeImageTypes, EditorNodeNames, EditorNodeType } from "./node-types";
export function Editor(props) {
const graph = useHookstate(props.graph);
const selectionRef = useRef();
if (props.updateNodesObserver) {
props.updateNodesObserver.updateNodes = (newNodes) => {
console.log('setting new nodes to', newNodes);
graph.set(newNodes);
};
}
// function printGraph() {
// console.log('===== BEGIN EDITOR NODE STRUCTURE =====');
// const graphRaw = graph.get({stealth: true});
// for (const node of graphRaw) {
// console.log(EditorNodeNames[node.type], `(${node.id})`);
// const children = node.children;
// if (children) {
// for (const child of children) {
// console.log(' ', EditorNodeNames[child.type], `(${child.id})`);
// }
// }
// }
// console.log('===== END EDITOR NODE STRUCTURE =====');
// }
useHookstateEffect(() => {
props.onChange(graph);
// uncomment in production
// printGraph();
}, [graph]);
// uncomment to test keyboard losing focus.
// useEffect(() => {
// let timeout = setTimeout(() => {
// nodes.set((_nodes) => {
// return [
// createTextNode('123'),
// createBoldNode('abc')
// ]
// });
// focusNodeState(nodes[1]);
// timeout = setTimeout(() => {
// // nodes[1].content.set(nodes[0].content.get() + nodes[1].content.get());
// nodes.set((nodes) => {
// nodes.splice(0, 1);
// return nodes;
// });
// nodes[0].type.set(EditorNodeType.TEXT);
// focusNodeState(nodes[0]);
// }, 7000);
// }, 3000);
// return () => clearTimeout(timeout);
// }, []);
const getCurrentNode = () => {
return getLastFocusedState(graph);
};
if (props.emoticonBarConfig) {
if (!props.emoticonBarConfig.getCurrentNode) {
props.emoticonBarConfig.getCurrentNode = getCurrentNode;
}
if (!props.emoticonBarConfig.addEmoticon) {
props.emoticonBarConfig.addEmoticon = (currentNode, src) => {
if (currentNode.content.get()) {
graph.set((graph) => {
// if selectionStart is 0, then add a node before the current one
// if selectionStart is the end of the current text node, then add a node after the current one
// if selectionStart is in the middle of the current text node, then split the text node
// new text node goes before current with text before cursor
// then new image node is inserted before current node
// then existing text node, which only gets the text after the cursor position, remains in graph in same spot and is not unfocused.
const selection = selectionRef.current;
if (!selection?.start || selection.start === currentNode.content.get().length) {
// add a node after the current one
const newImageNode = createEmoticonNode(src);
insertAfter(graph, currentNode.id.get(), newImageNode);
if (!getNext(graph, newImageNode.id)) {
currentNode.isFocused.set(false);
const newTextNode = createTextNode('');
focusNode(newTextNode);
// now add in a text node after the emoticon so we can keep typing (also so we can backspace the emoticon)
insertAfter(graph, newImageNode.id, newTextNode);
}
}
else {
const rawContent = currentNode.content.get();
const newTextNodeContent = rawContent.slice(0, selection.start);
const existingTextNodeContent = rawContent.slice(selection.start, rawContent.length);
const newTextNodeBefore = createTextNode(newTextNodeContent);
const newImageNode = createEmoticonNode(src);
currentNode.content.set(existingTextNodeContent);
insertBefore(graph, currentNode.id.get(), newTextNodeBefore);
insertAfter(graph, newTextNodeBefore.id, newImageNode);
}
return graph;
});
}
else {
// if current node is an empty node, just replace it.
currentNode.set(createEmoticonNode(src));
// now add in a text node after the emoticon so we can keep typing (also so we can backspace the emoticon)
const newNode = createTextNode('');
focusNode(newNode);
graph.set((graph) => {
insertAfter(graph, currentNode.id.get(), newNode);
return graph;
});
}
};
}
}
if (props.toolbarConfig) {
if (!props.toolbarConfig.getCurrentNode) {
props.toolbarConfig.getCurrentNode = getCurrentNode;
}
function toggleElementType(node, type, createFn) {
if (node && node.get()) {
// TODO bold selected content. We now track the cursor position/selection in text nodes so this should be easy... Can split nodes similar to addEmoticon().
const nodeType = node.type.get();
if (nodeType !== type) {
graph.set((nodes) => {
// add a node after this one that is bold, and focus it.
const newNode = createFn('');
focusNode(newNode);
node.isFocused.set(false);
insertAfter(nodes, node.id.get(), newNode);
return nodes;
});
}
else if (nodeType === type) {
const rawNode = node.get();
if (!rawNode.content) {
const prev = getStatePrev(graph, rawNode.id);
if (prev?.get()) {
console.log('node has no content, and has previous node. (removing, focusing)', rawNode.id, prev.id.get());
deleteNodeState(graph, rawNode.id);
// prev is now the current node.
select(prev);
}
}
else {
// we COULD toggle here, but it might be weird.
}
}
}
else {
// TODO toggle
}
}
if (props.toolbarConfig.boldButton && !props.toolbarConfig.toggleBold) {
props.toolbarConfig.toggleBold = (node) => {
toggleElementType(node, EditorNodeType.TEXT_BOLD, createBoldNode);
};
}
if (props.toolbarConfig.underlineButton && !props.toolbarConfig.toggleUnderline) {
props.toolbarConfig.toggleUnderline = (node) => {
toggleElementType(node, EditorNodeType.TEXT_UNDERLINE, createUnderlineNode);
};
}
if (props.toolbarConfig.strikethroughButton && !props.toolbarConfig.toggleStrikethrough) {
props.toolbarConfig.toggleStrikethrough = (node) => {
toggleElementType(node, EditorNodeType.TEXT_STRIKETHROUGH, createStrikethroughNode);
};
}
if (props.toolbarConfig.imageButton) {
if (!props.toolbarConfig.uploadImage) {
throw new Error('Toolbar config uploadImage() must be defined if image uploads are allowed!'); // could enforce via types?
}
if (!props.toolbarConfig.selectAndInsertImageAfterCurrentNode) {
props.toolbarConfig.selectAndInsertImageAfterCurrentNode = async (node) => {
const pickedPath = await props.toolbarConfig.getImagePathToInsert();
let finalPath;
if (!pickedPath) {
return;
}
if (typeof pickedPath === 'string' && pickedPath.startsWith('http')) {
finalPath = pickedPath;
}
else if (typeof pickedPath === 'object') {
finalPath = await props.toolbarConfig.uploadImage(node, pickedPath);
}
if (!finalPath) {
return;
}
const newImageNode = createImageNode(finalPath);
if (!node || !node.get()) {
node = getStateLast(graph);
}
// before: root -> text node A (selected)
// after: root -> text node A (not selected) -> newline node -> image -> newline node -> text node B (now selected)
const newSelectedTextNode = createTextNode('');
const imageNewLine = createNewlineNode();
imageNewLine.children = [newImageNode];
const textNewLine = createNewlineNode();
textNewLine.children = [newSelectedTextNode];
graph.set((nodes) => {
insertAfter(nodes, node.id.get(), imageNewLine);
insertAfter(nodes, imageNewLine.id, textNewLine);
return nodes;
});
focusNode(newSelectedTextNode);
};
}
}
if (props.toolbarConfig.gifPickerButton) {
if (!props.toolbarConfig.getGIFPathToInsert) {
throw new Error('Toolbar config getGIFPathToInsert() must be defined if gif uploads are allowed!'); // could enforce via types?
}
if (!props.toolbarConfig.selectAndInsertGIFAfterCurrentNode) {
props.toolbarConfig.selectAndInsertGIFAfterCurrentNode = async (node) => {
const publicGifPath = await props.toolbarConfig.getGIFPathToInsert();
if (!publicGifPath) {
return;
}
const newImageNode = createImageNode(publicGifPath);
if (!node || !node.get()) {
node = getStateLast(graph);
}
// before: root -> text node A (selected)
// after: root -> text node A (not selected) -> newline node -> image -> newline node -> text node B (now selected)
const newSelectedTextNode = createTextNode('');
const imageNewLine = createNewlineNode();
imageNewLine.children = [newImageNode];
const textNewLine = createNewlineNode();
textNewLine.children = [newSelectedTextNode];
graph.set((nodes) => {
insertAfter(nodes, node.id.get(), imageNewLine);
insertAfter(nodes, imageNewLine.id, textNewLine);
return nodes;
});
focusNode(newSelectedTextNode);
};
}
}
}
function select(node) {
try {
props.onFocus && props.onFocus();
if (EditorNodeImageTypes.includes(node.type.get())) {
console.log('FOCUSING IMAGE');
// if we're selecting an image, is there a node in front of it? then select that
const next = getStateNext(graph, node.id.get());
if (next && next.get({ stealth: true })) {
select(next);
}
else {
// otherwise, we should create a node in front of the image and select it.
const newTextNode = createTextNode('');
graph.set((graph) => {
insertAfter(graph, node.id.get(), newTextNode);
return graph;
});
focusNode(newTextNode);
}
}
else {
console.log('FOCUSING OTHER', node.id.get(), EditorNodeNames[node.type.get()], node.isFocused.get());
focusNodeState(node);
}
}
catch (e) {
console.error(e);
}
}
function deselect(node) {
console.log('deselecting', node.id.get());
if (node.isFocused.get()) {
node.isFocused.set(false);
props.onBlur && props.onBlur();
}
}
// taking a State<Node> here caused a ton of confusion, so now we just take a regular JS object.
function doDelete(node, focusNodeState) {
if (!node || typeof node !== 'object') {
console.error('Tried to delete empty node!', typeof node);
return;
}
const rawNode = node.get({ noproxy: true, stealth: true });
if (!rawNode || typeof rawNode !== 'object') {
console.error('Tried to delete empty node!', typeof node);
return;
}
graph.set((nodes) => {
const deleteNode = node.get({ noproxy: true, stealth: true });
const focusNode = focusNodeState.get({ noproxy: true, stealth: true });
deleteNodeRetainFocus(nodes, deleteNode, focusNode);
return nodes;
});
}
function onTryNewline(node) {
if (props.isMultiLine) {
console.log('Trying to create a new line.', node.id.get());
// add a newline node
// add a text node
// focus new text node
graph.set((nodes) => {
node.isFocused.set(false); // we don't focus this node anymore
// add a node after this one that is bold, and focus it.
const newNewlineNode = createNewlineNode();
const newTextNode = createTextNode('');
newNewlineNode.children = [newTextNode];
focusNode(newTextNode);
insertAfter(nodes, node.id.get(), newNewlineNode);
return nodes;
});
}
else {
console.log('Ignoring new line request.', node.id.get());
}
}
function updateNodeContent(node, content) {
if (node) {
node.content.set(content);
}
// console.log('updateNodeContent', id, node!.content.get(), '->', content);
}
return _jsxs(View, { style: props.style, children: [_jsxs(Pressable, { onPress: () => select(getStateLast(graph)), style: styles.inputArea, children: [props.placeholder, graph.map((node) => node && node.id !== undefined && _jsx(View, { style: props.isMultiLine ? styles.editorRow : styles.editorRowSingleLine, children: node.children && node.children.map((node) => node && node.id !== undefined &&
_jsx(EditorNode, { nodeState: node, textStyle: props.textStyle, onBlur: () => deselect(node), onChangeContent: (newContent) => updateNodeContent(node, newContent), onFocus: () => select(node), doDelete: () => doDelete(node, node), setSelection: (selection) => {
selectionRef.current = selection;
}, doDeleteNodeBefore: () => {
const nodeBefore = getStatePrev(graph, node.id.get());
if (nodeBefore) {
doDelete(nodeBefore, node);
}
}, onTryNewline: () => onTryNewline(node), isMultiLine: props.isMultiLine }, node.id.get())) }, node.id.get()))] }), props.emoticonBar && props.emoticonBarConfig && props.emoticonBar(props.emoticonBarConfig), props.toolbar && props.toolbarConfig && props.toolbar(props.toolbarConfig)] });
}
const styles = StyleSheet.create({
inputArea: {
flexDirection: 'column',
padding: 5
},
editorRow: {
maxWidth: '100%',
flexDirection: 'row',
alignItems: 'flex-start',
flexWrap: 'wrap'
},
editorRowSingleLine: {
maxWidth: '100%',
flexDirection: 'row',
alignItems: 'flex-start',
flexWrap: 'nowrap'
}
});