/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable @typescript-eslint/ban-ts-comment */ import React, { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'; import styled from '@emotion/styled'; import { debounce, uniqueId } from 'lodash'; import { color, fontWeight } from 'styled-system'; import { collectSegmentsFromNewLine, collectSegmentStyles, removeNoBreakingSpace, removeNoBreakingSpaceFromArray, localStyle, applyTopActionStyles, emptyLine, } from 'utils/richText'; import { useTheme } from 'hooks'; import { COLOR_DARK, COLOR_LIGHT } from 'theme'; import { Maybe, RichTextContentSegment, RichTextContentSegmentString, RichTextContentSegmentTypeEnum, RichTextContentStringColorEnum, RichTextContentStringStyle, } from 'expo-schema'; import { metrics } from 'config'; import { Visible } from 'components/visible'; const { INPUT_DEBOUNCE } = metrics.RICH_TEXT_EDITOR; const RichText = styled('span')(color, fontWeight, { fontFamily: 'CircularStd-Book', }); const ContentEditableDiv = styled.div` &:empty:before { content: attr(placeholder); pointer-events: none; display: block; /* For Firefox */ } `; export enum TagNameEnum { div = 'div', span = 'span', br = 'br', } const enum LineBreak { br = '
', space = ' ', } export const Content: React.FC = ({ richTextContent, placeholder, handleSubmit, setRichTextContent, segmentStyle, }) => { const [cursorPosition, setCursorPosition] = useState(0); const [localStrings, setLocalStrings] = useState([]); const richRef = useRef() as MutableRefObject; const { activeThemeMode } = useTheme(); const textColor = activeThemeMode === 'dark' ? COLOR_DARK.title : COLOR_LIGHT.title; const { strings, id: segmentId } = richTextContent; const { isBold, isCode, isItalic, isStrikeThrough, isUnderLine } = segmentStyle || { isBold: false, isCode: false, isItalic: false, isStrikeThrough: false, isUnderLine: false, }; const isAnyStyleActive = isBold || isItalic || isStrikeThrough || isUnderLine; const handleSelectedText = useCallback( ({ start, end }: SelectedTextProps) => { const startId = start.getAttribute('id'); const endId = end.getAttribute('id'); const isInputContent = startId && endId; // when draft is loaded into input but user have not edited message const isJustDraft = isInputContent && localStrings.length === 0; const localStringsTemp = isJustDraft ? [...strings] : [...localStrings]; const startIndex = localStringsTemp.findIndex((string) => string.id === startId); const endIndex = localStringsTemp.findIndex((string) => string.id === endId); const slicedSegments = localStringsTemp.slice(startIndex, endIndex + 1); const splicedIds = slicedSegments.map((segment) => segment.id); const styledLocalStringsTemp = localStringsTemp.map((localString) => { if (splicedIds.includes(localString.id)) { const reformedStringStyle = applyTopActionStyles({ string: localString, isBold, isCode, isItalic, isStrikeThrough, isUnderLine, }); return { ...localString, style: reformedStringStyle }; } else { return localString; } }); const existingContent = isJustDraft ? JSON.stringify([...strings]) : JSON.stringify([...localStrings]); if (existingContent !== JSON.stringify(styledLocalStringsTemp)) { setLocalStrings(styledLocalStringsTemp); } }, [isBold, isCode, isItalic, isStrikeThrough, isUnderLine, localStrings, strings] ); const handleSelection = useCallback(() => { const response = getSelectedElements(); const isSelectionValid = response?.startElement && response?.endingElement && response.selection; if (isSelectionValid) { handleSelectedText({ start: response?.startElement as HTMLElement, end: response?.endingElement as HTMLElement, }); } }, [handleSelectedText]); useEffect(() => { handleSelection(); }, [isBold, isItalic, isUnderLine, isStrikeThrough, handleSelection]); useEffect(() => { if (setRichTextContent) { setRichTextContent({ order: 1, id: uniqueId(), type: RichTextContentSegmentTypeEnum.Paragraph, strings: localStrings, variant: 'input', }); } }, [localStrings]); const getCaretAndLine = useCallback(() => { const editable = document.getElementById('contenteditable'); if (editable) { // collapse selection to end window.getSelection()?.collapseToEnd(); const selection = window.getSelection(); const range = selection?.getRangeAt(0); // get anchor node if startContainer parent is editable const selectedNode = editable === range?.startContainer.parentNode ? selection?.anchorNode : range?.startContainer.parentNode; if (!selectedNode) { return { caret: 0, line: 0, }; } // select to top of editable if (editable.firstChild) { range?.setStart(editable?.firstChild as Node, 0); } // do not use 'this' sel anymore since the selection has changed const content = window.getSelection()?.toString(); const text = JSON.stringify(content); const lines = (text.match(/\\n/g) || []).length + 1; // clear selection window.getSelection()?.collapseToEnd(); // minus 2 because of strange text formatting return { caret: text.length - 2, line: lines, }; } return { caret: 0, line: 0, }; }, []); const updateCursorPosition = useCallback(() => { const { caret } = getCaretAndLine(); setCursorPosition(caret); }, [setCursorPosition]); const setCaret = useCallback(() => { const range = document.createRange(); const selection = window.getSelection(); let words: string[] = []; const richChildLength = richRef.current.childNodes.length; [...richRef.current.childNodes].every((child, index) => { const currentWords = child.textContent?.split('') as string[]; const currentWordsCount = currentWords.length; words = [...words, ...currentWords]; if (index === 0) { words.shift(); } const isAtTheEnd = words.length < cursorPosition && richChildLength - 1 === index; if (cursorPosition <= words.length) { if (localStrings.length > 0) { const removePositions = words.length - cursorPosition; const childPosition = currentWordsCount - removePositions - 1; // 1 is here due to difference between index and length e.g index:0 === length:1 if (richRef.current.childNodes[index].childNodes[0]) { range.setStart(richRef.current.childNodes[index].childNodes[0], childPosition); } return false; } } else if (isAtTheEnd) { if (richRef.current.childNodes[index].childNodes[0]) { range.setStart(richRef.current.childNodes[index].childNodes[0], currentWordsCount); } } return true; }); range.collapse(true); selection?.removeAllRanges(); selection?.addRange(range); }, [richRef, cursorPosition, localStrings]); useEffect(() => { if (!isAnyStyleActive) { setCaret(); } }, [strings, richRef, isAnyStyleActive]); const emptyRichContentContainer = useCallback(() => { richRef.current.innerHTML = ''; }, []); const addEmptyLine = useCallback(() => { const exampleSegment = localStrings[localStrings.length - 1]; exampleSegment.value = ''; exampleSegment.onNewLine = true; exampleSegment.style = localStyle; setLocalStrings([...localStrings, exampleSegment]); }, [localStrings]); const storeDivSegments = useCallback((segmentValues: string[]) => { const localStringsTemp: RichTextContentSegmentString[] = []; for (const segValue of segmentValues) { // Handling the line breaks within the span if (segValue.includes(LineBreak.br)) { const lineBreakSegments = segValue.split(LineBreak.br); const parsedLineBreakSegments: string[] = []; const lastIndex = lineBreakSegments.length - 1; lineBreakSegments.forEach((segment, index) => { if (segment) { parsedLineBreakSegments.push(segment); const isSplitDueToSpace = index !== lastIndex && lineBreakSegments[index + 1] === ''; if (isSplitDueToSpace) { parsedLineBreakSegments.push(''); } } else { parsedLineBreakSegments.push(''); } }); parsedLineBreakSegments.forEach((br) => { if (br) { const newItem: RichTextContentSegmentString = { value: ' ' + br.trim(), order: 1, id: uniqueId(), style: localStyle, }; localStringsTemp.push(newItem); } else { localStringsTemp.push({ ...emptyLine, id: uniqueId() }); } }); } else { const newItem: RichTextContentSegmentString = { value: ' ' + segValue.trim(), id: uniqueId(), style: localStyle, order: 1, }; localStringsTemp.push(newItem); } } // It Fix the duplication of segments by distinct on the base of id const key = 'id'; const uniqueSegments = [...new Map(localStringsTemp.map((seg) => [seg[key], seg])).values()]; return uniqueSegments; }, []); const addAndUpdateSegment = useCallback( ( item: HTMLElement, segmentValues: string[], innerLocalStrings: RichTextContentSegmentString[] ) => { const itemId = item.getAttribute('id'); const isStringCode = item.getAttribute('data-is-code') === 'true'; let localStringsTemp: RichTextContentSegmentString[] = []; if (!isStringCode) { const segmentIndex = innerLocalStrings.findIndex((seg) => seg.id === itemId); const localStyleInner = collectSegmentStyles(item); if (segmentIndex < 0) { // When user will change value of style of an existing span localStringsTemp = innerLocalStrings?.map((segment) => { if (segment.id === itemId) { segment.value = ' ' + ` ${segmentValues[0]}`; segment.style = localStyleInner; return segment; } else { return segment; } }); for (const segValue of segmentValues) { // Handling the line breaks within the span if (segValue.includes(LineBreak.br)) { const lineBreakSegments = segValue.split(LineBreak.br); for (const br of lineBreakSegments) { if (br) { const newItem: RichTextContentSegmentString = { ...innerLocalStrings[segmentIndex], value: ' ' + br.trim(), id: innerLocalStrings[segmentIndex] ? innerLocalStrings[segmentIndex].id : uniqueId(), style: localStyle, }; localStringsTemp.push(newItem); } else { localStringsTemp.push({ ...emptyLine, id: uniqueId() }); } } } else { const newItem: RichTextContentSegmentString = { ...innerLocalStrings[segmentIndex], value: ' ' + segValue.trim(), id: innerLocalStrings[segmentIndex] ? innerLocalStrings[segmentIndex].id : uniqueId(), style: localStyle, }; localStringsTemp.push(newItem); } } } } // It Fix the duplication of segments by distinct on the base of id const key = 'id'; const uniqueSegments = [...new Map(localStringsTemp.map((seg) => [seg[key], seg])).values()]; return uniqueSegments; }, [] ); const addPlainSegments = useCallback( (splitStrings: string[]) => { const localStringsTemp = [...localStrings]; for (const stringValue of splitStrings) { // @ts-ignore const newItem: RichTextContentSegmentString = { id: uniqueId(), value: ` ${stringValue}`, style: localStyle, }; localStringsTemp.push(newItem); } emptyRichContentContainer(); setLocalStrings(localStringsTemp); return localStringsTemp; }, [emptyRichContentContainer, localStrings] ); const getSelectedElements = useCallback(() => { const currentSelection = window.getSelection(); const selection = currentSelection?.toString(); if (currentSelection && currentSelection.rangeCount > 0) { const startElement = currentSelection.getRangeAt(0).startContainer.parentNode; const endingElement = currentSelection.getRangeAt(0).endContainer.parentNode; return { selection, startElement, endingElement }; } else { return null; } }, []); const parseRichTextInput = useCallback( ({ children, newLines, oldSegmentIds, }: { children: Element[]; newLines: Element[]; oldSegmentIds: String[]; }) => { let innerLocalStrings: RichTextContentSegmentString[] = localStrings; for (const item of children) { /** * This will process the old segments coming from server/local state */ if (item.tagName.toLowerCase() === TagNameEnum.span) { /** we are checking either server/local state provider is same or user * has performed any of these actions * 1: update style * 2: edit/enter new text in the span * 3: remove text from span and we have   left in the span */ let spanSegments: string[] = item.innerHTML.trim().split(' '); /**Loop spanSegments and if found   as element then remove that element */ // spanSegments = removeNoBreakingSpaceFromArray(spanSegments); /**Remove   before and after the element*/ spanSegments = spanSegments.map((seg) => removeNoBreakingSpace(seg)); if (spanSegments.length === 1) { /** * check if span contain 1 segment * span can contain(s)
, if so line break otherwise simply update text along with styles */ const addedSegments = addAndUpdateSegment( item as HTMLElement, spanSegments, innerLocalStrings ); if (addedSegments) { const key = 'id'; innerLocalStrings = [...innerLocalStrings, ...addedSegments]; innerLocalStrings = [ ...new Map(innerLocalStrings.map((string) => [string[key], string])).values(), ] as unknown as RichTextContentSegmentString[]; } // Write generic function to store segment to existing one / add new / remove existing /** user not enter 2nd work in the span now we can update that span value with style into local state segment */ } else if (spanSegments.length > 1) { /** * check if span contain 2 || > 2 segments * simply update text along with styles */ // converting to HtMLElement to support dataset property // const trimSpanSegment = spanSegments.slice(1, spanSegments.length); const splitAddedSegment = addAndUpdateSegment( item as HTMLElement, spanSegments, innerLocalStrings ); if (splitAddedSegment) { const key = 'id'; innerLocalStrings = [...innerLocalStrings, ...splitAddedSegment]; innerLocalStrings = [ ...new Map(innerLocalStrings.map((string) => [string[key], string])).values(), ] as unknown as RichTextContentSegmentString[]; } /** user entered new words in the span now we need to add new segments with segment style into local state segment */ } const newItemId = item.getAttribute('id'); // Add segment ids to oldSegmentIds Array and compare with existing in local states to remove those who in local state but not here if (newItemId) { oldSegmentIds.push(newItemId); } } else if (item.tagName.toLowerCase() === TagNameEnum.div) { /** * Else block will process all new entered lines of text or empty spaces * each new line will be a div with span as child, span further will contain text or
(in case empty span of div) */ const isChildren = item.children && item.children.length > 0; if (isChildren && item.children[0].tagName.toLowerCase() === TagNameEnum.span) { let spanSegments: string[] = item.children[0].innerHTML.trim().split(' '); /**Loop spanSegments and if found   as element then remove that element */ spanSegments = removeNoBreakingSpaceFromArray(spanSegments); /**Remove   before and after the element*/ spanSegments = spanSegments.map((seg) => removeNoBreakingSpace(seg)); const newLineCollectedSegments = collectSegmentsFromNewLine( item.children[0] as HTMLElement, spanSegments, localStrings[localStrings.length - 1] ); setLocalStrings([...localStrings, ...newLineCollectedSegments]); } newLines.push(item); // Iterate new lines to separate empty lines and text strings for (const newLine of newLines) { const newLineChild = newLine.children[0]; const areNewLineChild = newLine.children.length > 0; // This will work when we have empty line if (areNewLineChild && newLine.children[0].innerHTML === '
') { // create new segment string with onNewLine property true addEmptyLine(); // This will work when we have string in the line } else if (newLineChild.innerHTML.length > 0) { const newLineSpanSegments: string[] = newLineChild.innerHTML.trim().split(' '); const newLineAddedSegment = addAndUpdateSegment( item as HTMLElement, newLineSpanSegments, innerLocalStrings ); if (newLineAddedSegment) { const key = 'id'; innerLocalStrings = [...innerLocalStrings, ...newLineAddedSegment]; innerLocalStrings = [ ...new Map(innerLocalStrings.map((string) => [string[key], string])).values(), ] as unknown as RichTextContentSegmentString[]; } } } } else { if (item.tagName.toLowerCase() === TagNameEnum.br) { innerLocalStrings = [...innerLocalStrings, { ...emptyLine, id: uniqueId() }]; } else { // TODO: Need to handle else, currently dot getting here! } } } setLocalStrings(innerLocalStrings); return innerLocalStrings; }, [richRef, localStrings] ); const handleInputChange = useCallback(() => { if (richRef?.current) { const oldSegmentIds: string[] = []; const newLines: Element[] = []; const richText = richRef.current; const isRichTextHasChildren = richText.children.length > 0; // const isRichTextHasOneChildren = richText.children.length === 1; if (isRichTextHasChildren) { const children = [...richText.children]; const isEverythingInDev = children.every( (value) => value.tagName.toLowerCase() === TagNameEnum.br ); if (isEverythingInDev) { // handel
combined with strings const cleanFromSpaces = removeNoBreakingSpace(richText.innerHTML); const splitInput = cleanFromSpaces.split(' '); const segments = storeDivSegments(splitInput); setLocalStrings(segments); return segments; } else { return parseRichTextInput({ children, newLines, oldSegmentIds }); } } else { const innerText = richRef.current.innerText; if (innerText) { const innerTextSegments: string[] = innerText.split(' '); return addPlainSegments(innerTextSegments); } else { setLocalStrings([]); return []; } } // if ( // isRichTextHasOneChildren && // richRef.current.children[0].tagName.toLowerCase() === TagNameEnum.br // ) { // setLocalStrings([]); // } // To Apply the styling // if (isRichTextHasChildren) { // alert('handle selection'); // handleSelection(); // } } return []; }, [ localStrings, addAndUpdateSegment, addEmptyLine, addPlainSegments, getSelectedElements, handleSelectedText, ]); const debouncedChangeHandler = useCallback(debounce(handleInputChange, INPUT_DEBOUNCE), []); const getTextDecoration = useCallback((style: Maybe | undefined) => { const underLine = style?.underline ? 'underline' : 'none'; return style?.strikethrough ? 'line-through' : underLine; }, []); const getFontStyle = (style: RichTextContentStringStyle) => style?.italics ? 'italic' : 'normal'; const handleEnter = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); const responseStrings = handleInputChange(); if (handleSubmit) { handleSubmit({ id: segmentId, order: 1, variant: 'input', type: RichTextContentSegmentTypeEnum.Paragraph, strings: responseStrings as RichTextContentSegmentString[], }); } } }, [localStrings] ); return (
{strings.map(({ id, style, value, onNewLine }) => { if (value === LineBreak.space) { return (   ); } else { return ( <> {value}
); } })}
); }; type RichTextContentSegmentProps = RichTextContentSegment & { variant: 'input' | 'blur'; }; export type RichTextContentProps = { /** * content contains the rich text content which is holding the segments array */ richTextContent: RichTextContentSegment; setRichTextContent: ({ id, strings, type, order, variant }: RichTextContentSegmentProps) => void; placeholder: string; handleSubmit?: ({ id, strings, type, order, variant }: RichTextContentSegmentProps) => void; segmentStyle?: LocalSegmentStyleProps; }; interface SelectedTextProps { start: HTMLElement; end: HTMLElement; } interface LocalSegmentStyleProps { isBold: boolean; isItalic: boolean; isCode: boolean; isUnderLine: boolean; isStrikeThrough: boolean; }