UNPKG

fastcomments-react-native-sdk

Version:

React Native FastComments Components. Add live commenting to any React Native application.

354 lines (353 loc) 17.8 kB
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' } });